// 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 #include #include #include #include #include #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 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(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(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(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& 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(width) * static_cast(height) * 4, 0); for (int y = 0; y < height; ++y) { const guchar* row = source + static_cast(y) * static_cast(stride); for (int x = 0; x < width; ++x) { unsigned char* target = pixels.data() + (static_cast(y) * static_cast(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 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("%s", 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("G%d", 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[] = {"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 args; args.reserve(static_cast(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(args.size()) - 1, args.data()); g_object_unref(app); return status; }