Files
gKeyMapper/src/main.cpp
2026-10-18 22:53:39 +02:00

924 lines
39 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// gKeyMapper - GTK4 GUI for remapping the G keys (G1-G9) of the Logitech G910.
// GUI only for now. Mappings are stored as a simple text file.
#include <gtk/gtk.h>
#include <iostream>
#include <locale.h>
#include <glib/gi18n.h>
#include <string>
#include <vector>
#include "autostart.h"
#include "capturedialog.h"
#include "daemonclient.h"
#include "help.h"
#include "ipc_ids.h"
#include "keyrow.h"
#include "mapping.h"
#include "mappings.h"
#include "optionsdialog.h"
#include "popovers.h"
#include "settings.h"
#include "singleinstance.h"
#include "trayicon.h"
#ifndef PACKAGE
#define PACKAGE "gkeymapper"
#endif
#ifndef LOCALEDIR
#define LOCALEDIR "/usr/local/share/locale"
#endif
namespace {
using gkeymapper::KeyRow;
using gkeymapper::kNumKeys;
std::vector<KeyRow> rows;
// The rows of the instance this start replaced, as a mappings text (empty when
// there was none, or when it did not hand them over - an older build). main()
// fills this in before the window exists; build_ui() starts from it instead of
// from the file, so edits that were never saved survive a second start.
std::string handedOverRows;
// Set by main() when the session's autostart entry started this instance (the
// --start-hidden argument): the window is built but stays in the tray, so a login
// does not put it in the way. on_activate is what reads it - a window that is
// built later (a language switch) comes up the way it was left.
bool startHidden = false;
// The daemon view behind the footer. It is filled by the asynchronous callbacks
// of DaemonClient, which arrive on the GTK main loop, so nothing here ever waits
// for the daemon; the options window shows the same data in full.
gkeymapper::DaemonView daemonView;
GtkWidget* daemonLabel = nullptr;
gkeymapper::DaemonClient daemonClient;
// The window can be hidden instead of closed (the tray brings it back), and the
// options window keeps showing the daemon status while it is open.
GtkWidget* mainWindow = nullptr;
gkeymapper::TrayIcon tray;
gkeymapper::GuiSettings guiSettings;
gkeymapper::OptionsDialog optionsDialog;
// Rebuilds the footer line from the state above. " · " is a separator, not a
// sentence, so it is not translated.
static void refresh_footer() {
if (daemonLabel == nullptr) {
return;
}
std::string text;
if (!daemonView.reachable) {
text = _("Daemon: not reachable");
// Hint for users who installed the package but did not (or could not)
// enable the daemon service during installation.
gtk_widget_set_tooltip_text(daemonLabel,
_("Run: systemctl --user enable --now gkeymapper-daemon"));
} else {
gtk_widget_set_tooltip_text(daemonLabel, nullptr);
text = _("Daemon: running");
text += " · ";
if (daemonView.device.empty()) {
// Reachable but no keyboard: unplugged, unsupported, or not found
// yet. Saying so is the difference between "broken" and "unplugged".
text += _("Device: not found");
} else {
g_autofree gchar* device =
g_strdup_printf(_("Device: %s"), daemonView.device.c_str());
text += device;
}
if (!daemonView.configOk) {
text += " · ";
text += _("Configuration error");
}
if (!daemonView.lastKey.empty()) {
text += " · ";
g_autofree gchar* lastKey =
g_strdup_printf(_("Last key: %s"), daemonView.lastKey.c_str());
text += lastKey;
}
}
gtk_label_set_text(GTK_LABEL(daemonLabel), text.c_str());
}
static void on_daemon_status(const gkeymapper::DaemonStatus& status) {
daemonView.reachable = status.reachable;
daemonView.configOk = status.configOk;
daemonView.version = status.version;
daemonView.device = status.device;
daemonView.configPath = status.configPath;
if (!status.reachable) {
daemonView.lastKey.clear(); // stale once the daemon is gone
}
refresh_footer();
optionsDialog.update(daemonView);
}
static void on_daemon_key(int index, const std::string& action) {
// Just the fact ("G4 → key:ctrl+v"), no label: the footer and the options
// window each put their own translated label in front of it.
daemonView.lastKey = "G" + std::to_string(index + 1) + " → " + action;
refresh_footer();
optionsDialog.update(daemonView);
}
static void on_pick_file_response(GtkDialog* dialog, int response, gpointer userData) {
if (response == GTK_RESPONSE_ACCEPT) {
KeyRow* row = static_cast<KeyRow*>(userData);
GtkFileChooser* chooser = GTK_FILE_CHOOSER(dialog);
g_autoptr(GFile) file = gtk_file_chooser_get_file(chooser);
char* path = g_file_get_path(file);
if (path) {
gtk_editable_set_text(GTK_EDITABLE(row->targetEntry), path);
g_free(path);
}
}
gtk_window_destroy(GTK_WINDOW(dialog));
}
static void pick_command_file(KeyRow* row, GtkWindow* parent) {
GtkWidget* dialog = gtk_file_chooser_dialog_new(_("Choose file"),
parent,
GTK_FILE_CHOOSER_ACTION_OPEN,
_("_Cancel"), GTK_RESPONSE_CANCEL,
_("_Open"), GTK_RESPONSE_ACCEPT,
NULL);
g_signal_connect(dialog, "response", G_CALLBACK(on_pick_file_response), row);
gtk_window_present(GTK_WINDOW(dialog));
}
void on_capture_clicked(GtkButton* btn, gpointer userData) {
auto* row = static_cast<KeyRow*>(userData);
guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(row->typeDrop));
GtkWindow* window = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(btn)));
if (sel == 1) {
pick_command_file(row, window);
return;
}
// "Key" and "Key (hold)" capture the same thing: a hold mapping is a single
// step that the daemon keeps down while the G key is held.
if (sel == 0 || sel == 3) {
gkeymapper::open_key_capture_dialog(row, window);
}
}
static void update_row_state(KeyRow* row) {
guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(row->typeDrop));
gboolean enabled = (sel != 2);
gtk_widget_set_sensitive(row->targetEntry, enabled);
gtk_widget_set_sensitive(row->captureButton, enabled);
if (sel == 1) {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Choose file..."));
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
_("e.g. /usr/bin/firefox or custom command"));
} else if (sel == 3) {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Capture"));
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
_("e.g. ctrl+w (held while the G key is held)"));
} else {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Capture"));
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
_("e.g. ctrl+c, ctrl+v"));
}
gtk_widget_set_size_request(row->captureButton, 180, -1);
}
static void on_type_changed(GObject* /*object*/, GParamSpec* /*pspec*/, gpointer userData) {
update_row_state(static_cast<KeyRow*>(userData));
}
void on_save_clicked(GtkButton* /*btn*/, gpointer /*userData*/) {
const std::string path = gkeymapper::default_config_path();
g_autofree gchar* dir = g_path_get_dirname(path.c_str());
g_mkdir_with_parents(dir, 0755);
if (!gkeymapper::write_mappings(path, rows)) {
g_printerr(_("Could not save %s.\n"), path.c_str());
return;
}
// Best effort: the daemon would pick the save up through its file watch
// within about half a second, this just applies it right away.
daemonClient.reload();
std::cout << _("Mappings saved to ") << path << "\n";
}
static void on_export_response(GtkDialog* dialog, int response, gpointer /*userData*/) {
if (response == GTK_RESPONSE_ACCEPT) {
GtkFileChooser* chooser = GTK_FILE_CHOOSER(dialog);
g_autoptr(GFile) file = gtk_file_chooser_get_file(chooser);
char* path = g_file_get_path(file);
if (path) {
if (!gkeymapper::write_mappings(path, rows)) {
g_printerr(_("Could not export mappings.\n"));
}
g_free(path);
}
}
gtk_window_destroy(GTK_WINDOW(dialog));
}
static void on_import_response(GtkDialog* dialog, int response, gpointer /*userData*/) {
if (response == GTK_RESPONSE_ACCEPT) {
GtkFileChooser* chooser = GTK_FILE_CHOOSER(dialog);
g_autoptr(GFile) file = gtk_file_chooser_get_file(chooser);
char* path = g_file_get_path(file);
if (path) {
gkeymapper::read_mappings(path, rows);
g_free(path);
}
}
gtk_window_destroy(GTK_WINDOW(dialog));
}
static std::string find_asset(const std::string& name);
// The icons are compiled into the executable (data/icons.gresource.xml), so the
// GUI has them even when no assets are installed. The file lookup stays as a
// fallback for a build without the resource; because the resource is embedded,
// it wins over any installed file.
static const char* kIconResource = "/dev/rager/gkeymapper/icon.png";
static const char* kIconThemeResource = "/dev/rager/gkeymapper/icons";
static bool have_embedded_icon() {
return g_resources_get_info(kIconResource, G_RESOURCE_LOOKUP_FLAGS_NONE, nullptr, nullptr,
nullptr);
}
static GtkWidget* make_icon_image(int pixelSize) {
GtkWidget* image = have_embedded_icon()
? gtk_image_new_from_resource(kIconResource)
: gtk_image_new_from_file(find_asset("icon.png").c_str());
gtk_image_set_pixel_size(GTK_IMAGE(image), pixelSize);
return image;
}
// GtkAboutDialog makes its texts selectable and hands the keyboard focus to the
// program name (the first focusable widget), so the dialog opens with the name
// drawn as selected — the blue block nobody asked for. Nothing in that dialog is
// worth copying (the version is short, and the options window shows it too), so
// the labels are switched off as selectable; that also stops the name from taking
// the focus, and no other label claims it afterwards.
static void make_labels_unselectable(GtkWidget* widget) {
if (GTK_IS_LABEL(widget)) {
gtk_label_set_selectable(GTK_LABEL(widget), FALSE);
}
for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr;
child = gtk_widget_get_next_sibling(child)) {
make_labels_unselectable(child);
}
}
static void show_about_dialog(GtkWindow* parent) {
GtkWidget* dialog = gtk_about_dialog_new();
gtk_about_dialog_set_program_name(GTK_ABOUT_DIALOG(dialog), _("gKeyMapper"));
gtk_about_dialog_set_version(GTK_ABOUT_DIALOG(dialog), gkeymapper::ipc::kVersion);
gtk_about_dialog_set_comments(GTK_ABOUT_DIALOG(dialog),
_("G-Key mapper for the Logitech G910 (GTK4)."));
gtk_about_dialog_set_website(GTK_ABOUT_DIALOG(dialog), "https://rager.dev");
gtk_about_dialog_set_website_label(GTK_ABOUT_DIALOG(dialog), "Thomas Rager - rager.dev");
GdkTexture* texture = nullptr;
if (have_embedded_icon()) {
texture = gdk_texture_new_from_resource(kIconResource);
} else {
std::string iconPath = find_asset("icon.png");
GError* error = nullptr;
texture = gdk_texture_new_from_filename(iconPath.c_str(), &error);
if (!texture && error) {
g_printerr("Could not load icon: %s\n", error->message);
g_error_free(error);
}
}
if (texture) {
gtk_about_dialog_set_logo(GTK_ABOUT_DIALOG(dialog), GDK_PAINTABLE(texture));
g_object_unref(texture);
}
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
// Before it is mapped: that is when the focus would go to the program name.
make_labels_unselectable(dialog);
// No "response" handler: GtkAboutDialog has no such signal in GTK4 (only
// GtkDialog subclasses with buttons, like GtkFileChooserDialog, do). Closing
// it goes through close-request, whose default handler destroys the window.
gtk_window_present(GTK_WINDOW(dialog));
}
static void do_export(GtkWindow* parent) {
GtkWidget* dialog = gtk_file_chooser_dialog_new(_("Export configuration"),
parent,
GTK_FILE_CHOOSER_ACTION_SAVE,
_("_Cancel"), GTK_RESPONSE_CANCEL,
_("_Save"), GTK_RESPONSE_ACCEPT,
NULL);
gtk_file_chooser_set_current_name(GTK_FILE_CHOOSER(dialog), "gkeymapper.conf");
g_signal_connect(dialog, "response", G_CALLBACK(on_export_response), NULL);
gtk_window_present(GTK_WINDOW(dialog));
}
static void do_import(GtkWindow* parent) {
GtkWidget* dialog = gtk_file_chooser_dialog_new(_("Import configuration"),
parent,
GTK_FILE_CHOOSER_ACTION_OPEN,
_("_Cancel"), GTK_RESPONSE_CANCEL,
_("_Open"), GTK_RESPONSE_ACCEPT,
NULL);
g_signal_connect(dialog, "response", G_CALLBACK(on_import_response), NULL);
gtk_window_present(GTK_WINDOW(dialog));
}
static void action_export(GSimpleAction* /*action*/, GVariant* /*parameter*/, gpointer userData) {
GtkApplication* app = GTK_APPLICATION(userData);
GtkWindow* window = gtk_application_get_active_window(app);
if (window) {
do_export(window);
}
}
static void action_import(GSimpleAction* /*action*/, GVariant* /*parameter*/, gpointer userData) {
GtkApplication* app = GTK_APPLICATION(userData);
GtkWindow* window = gtk_application_get_active_window(app);
if (window) {
do_import(window);
}
}
static void action_about(GSimpleAction* /*action*/, GVariant* /*parameter*/, gpointer userData) {
GtkApplication* app = GTK_APPLICATION(userData);
GtkWindow* window = gtk_application_get_active_window(app);
if (window) {
show_about_dialog(window);
}
}
// Exported by GApplication as org.gtk.Actions, so a new start can close this
// instance over D-Bus before it takes over (see singleinstance.h).
static void action_quit(GSimpleAction* /*action*/, GVariant* /*parameter*/, gpointer userData) {
g_application_quit(G_APPLICATION(userData));
}
static std::string find_asset(const std::string& name) {
const char* env = g_getenv("GKEYMAPPER_ASSETS_DIR");
if (env && *env) {
std::string path = std::string(env) + "/" + name;
if (g_file_test(path.c_str(), G_FILE_TEST_EXISTS)) {
return path;
}
}
std::string local = std::string("./assets/") + name;
if (g_file_test(local.c_str(), G_FILE_TEST_EXISTS)) {
return local;
}
#ifndef ASSETS_DIR
#define ASSETS_DIR "/usr/local/share/gkeymapper/assets"
#endif
std::string installed = std::string(ASSETS_DIR) + "/" + name;
if (g_file_test(installed.c_str(), G_FILE_TEST_EXISTS)) {
return installed;
}
return local;
}
// Size of the pixmap handed to the panel; the panel scales it to its own size.
constexpr int kTrayIconSize = 64;
// How long a start from the autostart entry waits for the tray icon before it
// gives up and shows the window (on_start_hidden_timeout).
constexpr guint kStartHiddenGraceMs = 5000;
// Shown and hidden are the two states the tray works with: a hidden window is
// still a window, so the app keeps running and the tray stays the way back.
static void show_main_window() {
if (mainWindow == nullptr) {
return;
}
gtk_widget_set_visible(mainWindow, TRUE);
// present() also restores a window the window manager had minimized.
gtk_window_present(GTK_WINDOW(mainWindow));
}
static void hide_main_window() {
if (mainWindow != nullptr) {
gtk_widget_set_visible(mainWindow, FALSE);
}
}
// Hiding is only safe while the tray really hosts the icon: without one there
// would be no way back to the window, so the app quits as usual.
static bool hides_to_tray() {
return guiSettings.minimizeToTray && tray.ready();
}
// Closing the window: with the tray option on it is hidden, otherwise the default
// handler closes the window and the app quits with it.
static gboolean on_close_request(GtkWindow* /*window*/, gpointer /*userData*/) {
if (!hides_to_tray()) {
return FALSE;
}
hide_main_window();
return TRUE;
}
// GTK4 has no signal for "minimized" (GdkToplevel has no properties either, only
// gdk_toplevel_get_state), so the one state that needs an action is polled: one
// call per tick, and only while the option is on and the window is up.
static gboolean on_minimize_watch(gpointer /*userData*/) {
if (!hides_to_tray() || mainWindow == nullptr || !gtk_widget_get_visible(mainWindow)) {
return G_SOURCE_CONTINUE;
}
GdkSurface* surface = gtk_native_get_surface(GTK_NATIVE(mainWindow));
if (surface != nullptr && GDK_IS_TOPLEVEL(surface) &&
(gdk_toplevel_get_state(GDK_TOPLEVEL(surface)) & GDK_TOPLEVEL_STATE_MINIMIZED) != 0) {
hide_main_window();
}
return G_SOURCE_CONTINUE;
}
// A start from the session's autostart entry keeps the window out of the way, but
// only a hosted tray icon can bring it back - a desktop without one would leave the
// program with no window and no way to open one. Nothing knows that at startup
// (the panel answers asynchronously), so the window is built hidden and appears
// here if the icon did not turn up in the meantime.
static gboolean on_start_hidden_timeout(gpointer /*userData*/) {
if (!tray.ready()) {
g_printerr("gkeymapper: no tray icon, showing the window\n");
show_main_window();
}
return G_SOURCE_REMOVE;
}
// The panel is another process and cannot read the icon compiled into this
// binary, so the tray icon travels as a pixmap. GdkPixbuf hands out RGBA, the
// protocol wants ARGB32 in network byte order, so the bytes are reordered here.
static bool load_tray_icon(std::vector<unsigned char>& pixels, int& width, int& height) {
GError* error = nullptr;
GdkPixbuf* pixbuf = have_embedded_icon()
? gdk_pixbuf_new_from_resource(kIconResource, &error)
: gdk_pixbuf_new_from_file(find_asset("icon.png").c_str(), &error);
if (pixbuf == nullptr) {
g_printerr("gkeymapper: no tray icon: %s\n",
error != nullptr ? error->message : "unknown error");
g_clear_error(&error);
return false;
}
if (gdk_pixbuf_get_width(pixbuf) > kTrayIconSize ||
gdk_pixbuf_get_height(pixbuf) > kTrayIconSize) {
GdkPixbuf* scaled =
gdk_pixbuf_scale_simple(pixbuf, kTrayIconSize, kTrayIconSize, GDK_INTERP_BILINEAR);
g_object_unref(pixbuf);
pixbuf = scaled;
}
if (!gdk_pixbuf_get_has_alpha(pixbuf)) {
GdkPixbuf* withAlpha = gdk_pixbuf_add_alpha(pixbuf, FALSE, 0, 0, 0);
g_object_unref(pixbuf);
pixbuf = withAlpha;
}
width = gdk_pixbuf_get_width(pixbuf);
height = gdk_pixbuf_get_height(pixbuf);
const int stride = gdk_pixbuf_get_rowstride(pixbuf);
const guchar* source = gdk_pixbuf_get_pixels(pixbuf);
pixels.assign(static_cast<size_t>(width) * static_cast<size_t>(height) * 4, 0);
for (int y = 0; y < height; ++y) {
const guchar* row = source + static_cast<size_t>(y) * static_cast<size_t>(stride);
for (int x = 0; x < width; ++x) {
unsigned char* target =
pixels.data() + (static_cast<size_t>(y) * static_cast<size_t>(width) + x) * 4;
target[0] = row[x * 4 + 3]; // A
target[1] = row[x * 4 + 0]; // R
target[2] = row[x * 4 + 1]; // G
target[3] = row[x * 4 + 2]; // B
}
}
g_object_unref(pixbuf);
return true;
}
static void start_tray(GtkApplication* app) {
std::vector<unsigned char> pixels;
int width = 0;
int height = 0;
if (!load_tray_icon(pixels, width, height)) {
return;
}
tray.start(std::move(pixels), width, height, show_main_window,
[app]() { g_application_quit(G_APPLICATION(app)); });
}
static void build_ui(GtkApplication* app, bool show);
static void open_options_dialog();
// Rebuilt from scratch when the language changes: the widgets keep the strings
// they were made with, so switching means building them again. Runs as an idle,
// because the caller is the dropdown that this destroys.
static gboolean rebuild_ui_idle(gpointer /*userData*/) {
if (mainWindow == nullptr) {
return G_SOURCE_REMOVE;
}
GtkApplication* app = gtk_window_get_application(GTK_WINDOW(mainWindow));
// Both are transient windows: they go with the rebuild and come back if they
// were open, so nothing on screen is left in the old language.
const bool helpOpen = gkeymapper::help_window_open();
const bool optionsOpen = optionsDialog.is_open();
// A window that was in the tray (or was built hidden at login) stays there: the
// new one comes up the way the old one was, not in front of the user.
const bool windowShown = gtk_widget_get_visible(mainWindow);
// Rows that were not saved yet are widget state, not file content; without
// this the rebuilt window would silently show the last saved file again.
const gkeymapper::MappingSet edits = gkeymapper::collect_mappings(rows);
gkeymapper::apply_gui_language(guiSettings.language);
// Destroying the last window would end the application, so it is held while
// there is none.
g_application_hold(G_APPLICATION(app));
optionsDialog.close();
gkeymapper::close_help_window();
daemonLabel = nullptr; // dangling from here on, and a reply may still arrive
gtk_window_destroy(GTK_WINDOW(mainWindow));
mainWindow = nullptr;
build_ui(app, windowShown);
gkeymapper::apply_mappings(edits, rows);
if (helpOpen) {
gkeymapper::show_help_window(GTK_WINDOW(mainWindow));
}
if (optionsOpen) {
open_options_dialog();
}
g_application_release(G_APPLICATION(app));
refresh_footer();
// The panel mirrors the menu once, so the new labels have to be announced.
tray.refresh_labels();
return G_SOURCE_REMOVE;
}
// Chosen in the options window. Only remembered here; the switch itself is what
// the user sees next, and that is the rebuild.
static void on_language_selected(const std::string& language) {
if (language == guiSettings.language) {
return;
}
guiSettings.language = language;
gkeymapper::save_gui_settings(guiSettings);
g_idle_add(rebuild_ui_idle, nullptr);
}
static void open_options_dialog() {
// None of the switches needs a restart: the tray one decides what closing
// does next, autostart only writes or removes the session's entry, and the
// language one rebuilds the windows right away.
gkeymapper::OptionsState state;
state.trayAvailable = tray.ready();
state.autostart = gkeymapper::autostart_enabled();
state.settings = guiSettings;
state.view = daemonView;
optionsDialog.show(mainWindow != nullptr ? GTK_WINDOW(mainWindow) : nullptr, state,
[](bool minimizeToTray) {
guiSettings.minimizeToTray = minimizeToTray;
gkeymapper::save_gui_settings(guiSettings);
},
[](bool autostart) {
// Starting the window at login is a convenience; the G
// keys work without it (the daemon is what has to run).
if (!gkeymapper::set_autostart_enabled(autostart)) {
g_printerr("gkeymapper: autostart entry not %s\n",
autostart ? "written" : "removed");
}
},
on_language_selected);
}
static void action_help(GSimpleAction* /*action*/, GVariant* /*parameter*/,
gpointer /*userData*/) {
gkeymapper::show_help_window(mainWindow != nullptr ? GTK_WINDOW(mainWindow) : nullptr);
}
static void action_options(GSimpleAction* /*action*/, GVariant* /*parameter*/,
gpointer /*userData*/) {
open_options_dialog();
}
void build_ui(GtkApplication* app, bool show) {
GtkWidget* window = gtk_application_window_new(app);
gtk_window_set_title(GTK_WINDOW(window), _("gKeyMapper – Logitech G910"));
gtk_window_set_resizable(GTK_WINDOW(window), FALSE);
GtkWidget* headerBar = gtk_header_bar_new();
gtk_window_set_titlebar(GTK_WINDOW(window), headerBar);
// Serve "gkeymapper" from the embedded icons, so the window icon resolves
// even without an installed hicolor theme.
gtk_icon_theme_add_resource_path(
gtk_icon_theme_get_for_display(gtk_widget_get_display(window)), kIconThemeResource);
gtk_window_set_icon_name(GTK_WINDOW(window), "gkeymapper");
GtkWidget* titleBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
GtkWidget* iconImage = make_icon_image(24);
gtk_image_set_pixel_size(GTK_IMAGE(iconImage), 24);
gtk_box_append(GTK_BOX(titleBox), iconImage);
gtk_box_append(GTK_BOX(titleBox), gtk_label_new(_("gKeyMapper")));
gtk_header_bar_set_title_widget(GTK_HEADER_BAR(headerBar), titleBox);
GMenu* menu = g_menu_new();
g_menu_append(menu, _("Export configuration..."), "app.export-config");
g_menu_append(menu, _("Import configuration..."), "app.import-config");
GMenu* optionsSection = g_menu_new();
g_menu_append(optionsSection, _("Options"), "app.options");
g_menu_append_section(menu, NULL, G_MENU_MODEL(optionsSection));
GMenu* helpSection = g_menu_new();
g_menu_append(helpSection, _("Help"), "app.help");
g_menu_append(helpSection, _("About"), "app.about");
g_menu_append_section(menu, NULL, G_MENU_MODEL(helpSection));
// While the window is hidden to the tray the menu is the second way out, so
// Quit is there even though closing the window usually does the same.
GMenu* quitSection = g_menu_new();
g_menu_append(quitSection, _("Quit"), "app.quit");
g_menu_append_section(menu, NULL, G_MENU_MODEL(quitSection));
GtkWidget* menuButton = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(menuButton), "open-menu-symbolic");
gtk_menu_button_set_menu_model(GTK_MENU_BUTTON(menuButton), G_MENU_MODEL(menu));
gtk_header_bar_pack_end(GTK_HEADER_BAR(headerBar), menuButton);
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_window_set_child(GTK_WINDOW(window), outer);
// Subtitle only: the app name is already in the header bar, and the heading
// spelled it differently ("G-Key Mapper") on top of that.
GtkWidget* subtitle =
gtk_label_new(_("G1 to G9 of the Logitech G910 remapped to keys or commands"));
gtk_label_set_xalign(GTK_LABEL(subtitle), 0.0f);
gtk_widget_add_css_class(subtitle, "dim-label");
gtk_widget_set_margin_top(subtitle, 16);
gtk_widget_set_margin_bottom(subtitle, 8);
gtk_widget_set_margin_start(subtitle, 16);
gtk_widget_set_margin_end(subtitle, 16);
gtk_box_append(GTK_BOX(outer), subtitle);
// Table with all nine G keys (no scrolling needed, everything stays visible)
GtkWidget* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
gtk_grid_set_column_spacing(GTK_GRID(grid), 8);
gtk_widget_set_margin_top(grid, 8);
gtk_widget_set_margin_bottom(grid, 4);
gtk_widget_set_margin_start(grid, 16);
gtk_widget_set_margin_end(grid, 16);
gtk_widget_set_hexpand(grid, TRUE);
gtk_box_append(GTK_BOX(outer), grid);
// Column headers
const char* headers[] = { _("G-Key"), _("Type"), _("Target (Key/Command)"), _("Action") };
for (int c = 0; c < 4; ++c) {
GtkWidget* h = gtk_label_new(nullptr);
g_autofree gchar* headerMarkup = g_strdup_printf("<b>%s</b>", headers[c]);
gtk_label_set_markup(GTK_LABEL(h), headerMarkup);
gtk_widget_add_css_class(h, "dim-label");
gtk_grid_attach(GTK_GRID(grid), h, c, 0, 1, 1);
}
// GTK draws the check mark of the selected entry right behind the text of a
// dropdown row, so every entry carries a trailing space. The catalogs keep
// the bare words; the space is added here, where the model is built. The
// strings are copied by gtk_drop_down_new_from_strings, so the array can be
// freed once every dropdown has been created.
const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"),
_("Key (hold)"), nullptr };
char** typeShown = g_new(char*, 5);
for (int i = 0; typeOptions[i] != nullptr; ++i) {
typeShown[i] = g_strconcat(typeOptions[i], " ", nullptr);
}
typeShown[4] = nullptr;
rows.resize(kNumKeys);
for (int i = 0; i < kNumKeys; ++i) {
rows[i].index = i;
// G key label
GtkWidget* keyLabel = gtk_label_new(nullptr);
g_autofree gchar* keyMarkup = g_strdup_printf("<b>G%d</b>", i + 1);
gtk_label_set_markup(GTK_LABEL(keyLabel), keyMarkup);
gtk_grid_attach(GTK_GRID(grid), keyLabel, 0, i + 1, 1, 1);
rows[i].keyLabel = keyLabel;
// Type dropdown (fixed width so rows don't jump between languages)
GtkWidget* drop = gtk_drop_down_new_from_strings((const char* const*)typeShown);
gtk_drop_down_set_selected(GTK_DROP_DOWN(drop), 0);
gtk_widget_set_size_request(drop, 150, -1);
gtk_grid_attach(GTK_GRID(grid), drop, 1, i + 1, 1, 1);
rows[i].typeDrop = drop;
g_signal_connect(drop, "notify::selected", G_CALLBACK(on_type_changed), &rows[i]);
// Target input (wide: commands and file paths need room)
GtkWidget* entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), _("e.g. ctrl+c, ctrl+v"));
gtk_widget_set_size_request(entry, 440, -1);
gtk_widget_set_hexpand(entry, TRUE);
gtk_grid_attach(GTK_GRID(grid), entry, 2, i + 1, 1, 1);
rows[i].targetEntry = entry;
// Capture button
GtkWidget* capture = gtk_button_new_with_label(_("Capture"));
gtk_widget_set_size_request(capture, 180, -1);
g_signal_connect(capture, "clicked", G_CALLBACK(on_capture_clicked), &rows[i]);
gtk_grid_attach(GTK_GRID(grid), capture, 3, i + 1, 1, 1);
rows[i].captureButton = capture;
update_row_state(&rows[i]);
}
g_strfreev(typeShown);
// Start from the rows the replaced instance had on screen, or - when this is
// the only instance - from the saved mappings. Without either the rows are
// empty and the first "Save mappings" of a freshly opened window would
// overwrite the user's file with empty rows; a missing file is not an error
// (first start). Taken once: after this the live rows are the source.
if (!handedOverRows.empty()) {
gkeymapper::apply_mappings_text(handedOverRows, rows);
handedOverRows.clear();
} else {
const std::string configPath = gkeymapper::default_config_path();
if (g_file_test(configPath.c_str(), G_FILE_TEST_EXISTS)) {
gkeymapper::read_mappings(configPath, rows);
}
}
// Footer with save button and the daemon status line
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_top(footer, 8);
gtk_widget_set_margin_bottom(footer, 16);
gtk_widget_set_margin_start(footer, 16);
gtk_widget_set_margin_end(footer, 16);
gtk_box_append(GTK_BOX(outer), footer);
GtkWidget* footerRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_box_append(GTK_BOX(footer), footerRow);
GtkWidget* saveBtn = gtk_button_new_with_label(_("Save mappings"));
// No "suggested-action" class on purpose: it gets a strong accent color
// (e.g. Mint-Y green) that clashes with the rest of the layout. The plain
// button follows the system theme like everything else.
g_signal_connect(saveBtn, "clicked", G_CALLBACK(on_save_clicked), nullptr);
gtk_box_append(GTK_BOX(footerRow), saveBtn);
GtkWidget* hint = gtk_label_new(_("Saved to ~/.config/gKeyMapper/mappings.conf"));
gtk_widget_add_css_class(hint, "dim-label");
gtk_widget_set_halign(hint, GTK_ALIGN_END);
gtk_widget_set_hexpand(hint, TRUE);
gtk_box_append(GTK_BOX(footerRow), hint);
// Daemon status: control plane only, the keypress path never leaves the
// daemon. Text starts neutral so the footer does not change height when the
// first reply arrives. Plain label on purpose: GTK4's GtkStatusbar would add
// a message stack (pop/push) and is exposed over AT-SPI as a generic filler,
// so it brings no benefit here.
daemonLabel = gtk_label_new(_("Daemon: checking..."));
gtk_widget_add_css_class(daemonLabel, "dim-label");
gtk_label_set_xalign(GTK_LABEL(daemonLabel), 0.0f);
gtk_label_set_ellipsize(GTK_LABEL(daemonLabel), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(footer), daemonLabel);
mainWindow = window;
// The type dropdowns and the header bar menu are popovers; without this they
// grab the pointer and freeze the rest of the desktop while they are open.
gkeymapper::make_popovers_release_input(GTK_WINDOW(window));
// Closing is a request, not the end: the handler decides whether the window
// hides to the tray or the app quits with it.
g_signal_connect(window, "close-request", G_CALLBACK(on_close_request), nullptr);
// `show` is false for the one start that must not put a window in the way: the
// one the session's autostart entry makes (on_start_hidden_timeout is what then
// decides whether it has to appear after all).
if (show) {
gtk_window_present(GTK_WINDOW(window));
}
}
static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
// One window, always. A second "activate" (a desktop launch, or D-Bus
// activation while the app is open) must not build another window - main()
// replaces a running instance before this runs, so normally there is none.
GList* windows = gtk_application_get_windows(app);
if (windows != nullptr) {
gtk_window_present(GTK_WINDOW(windows->data));
return;
}
GSimpleAction* exportAction = g_simple_action_new("export-config", NULL);
g_signal_connect(exportAction, "activate", G_CALLBACK(action_export), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(exportAction));
GSimpleAction* importAction = g_simple_action_new("import-config", NULL);
g_signal_connect(importAction, "activate", G_CALLBACK(action_import), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(importAction));
GSimpleAction* aboutAction = g_simple_action_new("about", NULL);
g_signal_connect(aboutAction, "activate", G_CALLBACK(action_about), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(aboutAction));
GSimpleAction* helpAction = g_simple_action_new("help", NULL);
g_signal_connect(helpAction, "activate", G_CALLBACK(action_help), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(helpAction));
GSimpleAction* optionsAction = g_simple_action_new("options", NULL);
g_signal_connect(optionsAction, "activate", G_CALLBACK(action_options), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(optionsAction));
GSimpleAction* quitAction = g_simple_action_new("quit", NULL);
g_signal_connect(quitAction, "activate", G_CALLBACK(action_quit), app);
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(quitAction));
const char* helpAccels[] = {"F1", NULL};
gtk_application_set_accels_for_action(app, "app.help", helpAccels);
const char* quitAccels[] = {"<Control>q", NULL};
gtk_application_set_accels_for_action(app, "app.quit", quitAccels);
// A start from the session leaves the window in the tray; a start by hand shows
// it (main() reads the flag off the command line).
build_ui(app, !startHidden);
// From here on this instance answers a new start with the rows its window
// shows, so the instance that replaces it does not lose edits that were never
// saved (see replace_running_instance). It has to come after build_ui(), which
// is what fills the rows.
gkeymapper::serve_rows_handover([]() { return gkeymapper::mappings_text(rows); });
// One-time, and deliberately not part of build_ui(): the window is rebuilt
// when the language changes, while the tray, its poll and the daemon client
// keep running as they are.
start_tray(app);
if (startHidden) {
// The tray is what keeps a hidden window reachable; is it one? Only the
// panel can answer that, and it does so asynchronously (start_tray above is
// the question). Until then the window stays hidden and comes up if that
// question was never answered.
g_timeout_add(kStartHiddenGraceMs, on_start_hidden_timeout, nullptr);
}
// Four checks per second: one cheap call into the backend each, quick enough
// that a minimized window does not linger in the task list. It does nothing
// while the tray option is off.
g_timeout_add(250, on_minimize_watch, nullptr);
daemonClient.init(on_daemon_status, on_daemon_key);
}
} // namespace
int main(int argc, char** argv) {
setlocale(LC_ALL, "");
const char* localedir = g_getenv("GKEYMAPPER_LOCALEDIR");
if (!localedir || !*localedir) {
localedir = LOCALEDIR;
}
bindtextdomain(PACKAGE, localedir);
bind_textdomain_codeset(PACKAGE, "UTF-8");
textdomain(PACKAGE);
// Window preferences (the tray option, the language) live in their own file
// next to the mappings, so a mapping file stays a mapping file.
guiSettings = gkeymapper::load_gui_settings();
// A saved language overrides the session's, and it has to be in place before
// the first translated string is built.
gkeymapper::apply_gui_language(guiSettings.language);
// The autostart entry starts the window in the tray and says so with an argument
// of its own (kStartHiddenArg). GTK does not know that argument and would stop
// on it, so it is taken out of the command line here, before it sees it.
std::vector<char*> args;
args.reserve(static_cast<size_t>(argc) + 1);
for (int i = 0; i < argc; ++i) {
if (g_strcmp0(argv[i], gkeymapper::kStartHiddenArg) == 0) {
startHidden = true;
continue;
}
args.push_back(argv[i]);
}
args.push_back(nullptr);
// One GUI only, and it is the one that was just started: close the instance
// that already owns the app id (an older build, or the same one) before
// becoming the primary instance. Without this the new process just hands over
// to the old one and its stale window stays on screen. Its rows come along,
// so a mapping that was typed but not saved is not lost with it.
gkeymapper::replace_running_instance(gkeymapper::ipc::kAppId, &handedOverRows);
GtkApplication* app = gtk_application_new(gkeymapper::ipc::kAppId,
G_APPLICATION_FLAGS_NONE);
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);
int status = g_application_run(G_APPLICATION(app), static_cast<int>(args.size()) - 1,
args.data());
g_object_unref(app);
return status;
}