// 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 "capturedialog.h" #include "daemonclient.h" #include "ipc_ids.h" #include "keyrow.h" #include "mapping.h" #include "mappings.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; // Footer view of the daemon. It is filled by the asynchronous callbacks of // DaemonClient, which arrive on the GTK main loop, so nothing here ever waits // for the daemon. struct DaemonFooter { bool reachable = false; std::string device; std::string lastKey; } daemonFooter; GtkWidget* daemonLabel = nullptr; gkeymapper::DaemonClient daemonClient; // 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 (!daemonFooter.reachable) { text = _("Daemon: not reachable"); } else { text = _("Daemon: running"); if (!daemonFooter.device.empty()) { g_autofree gchar* device = g_strdup_printf(_("Device: %s"), daemonFooter.device.c_str()); text += " · "; text += device; } if (!daemonFooter.lastKey.empty()) { text += " · "; text += daemonFooter.lastKey; } } gtk_label_set_text(GTK_LABEL(daemonLabel), text.c_str()); } static void on_daemon_status(const gkeymapper::DaemonStatus& status) { daemonFooter.reachable = status.reachable; daemonFooter.device = status.device; if (!status.reachable) { daemonFooter.lastKey.clear(); // stale once the daemon is gone } refresh_footer(); } static void on_daemon_key(int index, const std::string& action) { g_autofree gchar* text = g_strdup_printf(_("Last key: G%d → %s"), index + 1, action.c_str()); daemonFooter.lastKey = text; refresh_footer(); } 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; } if (sel == 0) { 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 { 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; } 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); g_signal_connect(dialog, "response", G_CALLBACK(gtk_window_destroy), NULL); 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); } } 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; } void build_ui(GtkApplication* app) { 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* helpSection = g_menu_new(); g_menu_append(helpSection, _("About"), "app.about"); g_menu_append_section(menu, NULL, G_MENU_MODEL(helpSection)); 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); } const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"), 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(typeOptions); 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]); } // 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); gtk_window_present(GTK_WINDOW(window)); daemonClient.init(on_daemon_status, on_daemon_key); } static void on_activate(GtkApplication* app, gpointer /*user_data*/) { 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)); build_ui(app); } } // 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); 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), argc, argv); g_object_unref(app); return status; }