// 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 "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; 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; } 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); 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), "0.1.0"); 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"); std::string iconPath = find_asset("icon.png"); GError* error = nullptr; GdkTexture* texture = gdk_texture_new_from_filename(iconPath.c_str(), &error); if (texture) { gtk_about_dialog_set_logo(GTK_ABOUT_DIALOG(dialog), GDK_PAINTABLE(texture)); g_object_unref(texture); } else if (error) { g_printerr("Could not load icon: %s\n", error->message); g_error_free(error); } 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); gtk_window_set_icon_name(GTK_WINDOW(window), "gkeymapper"); GtkWidget* titleBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); GtkWidget* iconImage = gtk_image_new_from_file(find_asset("icon.png").c_str()); 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); // Title / subtitle GtkWidget* headerBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2); gtk_widget_set_margin_top(headerBox, 16); gtk_widget_set_margin_bottom(headerBox, 8); gtk_widget_set_margin_start(headerBox, 16); gtk_widget_set_margin_end(headerBox, 16); gtk_box_append(GTK_BOX(outer), headerBox); GtkWidget* title = gtk_label_new(nullptr); gtk_label_set_markup(GTK_LABEL(title), "G-Key Mapper"); gtk_label_set_xalign(GTK_LABEL(title), 0.0f); gtk_box_append(GTK_BOX(headerBox), title); 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_box_append(GTK_BOX(headerBox), 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 GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); 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* 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(footer), 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(footer), hint); gtk_window_present(GTK_WINDOW(window)); } 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("com.example.gkeymapper", 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; }