// 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 #include #include #include #ifndef PACKAGE #define PACKAGE "gkeymapper" #endif #ifndef LOCALEDIR #define LOCALEDIR "/usr/local/share/locale" #endif #define _(s) gettext(s) #define N_(s) (s) namespace { constexpr int NUM_KEYS = 9; struct KeyRow { int index; // 0..8 -> G1..G9 GtkWidget* keyLabel = nullptr; GtkWidget* typeDrop = nullptr; GtkWidget* targetEntry = nullptr; GtkWidget* captureButton = nullptr; }; 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)); } struct CaptureSession { KeyRow* row = nullptr; GtkWidget* displayLabel = nullptr; std::vector steps; std::set heldKeys; // non-modifier keyvals currently held (ignores auto-repeat) }; static bool is_modifier_keyval(guint keyval) { switch (keyval) { case GDK_KEY_Control_L: case GDK_KEY_Control_R: case GDK_KEY_Alt_L: case GDK_KEY_Alt_R: case GDK_KEY_Shift_L: case GDK_KEY_Shift_R: case GDK_KEY_Super_L: case GDK_KEY_Super_R: case GDK_KEY_Meta_L: case GDK_KEY_Meta_R: case GDK_KEY_ISO_Level3_Shift: // AltGr return true; default: return false; } } // Format one step like the reference project: modifiers joined with '+', // e.g. "ctrl+shift+t". Multiple steps are joined with ',' ("ctrl+c,ctrl+v"). static std::string format_capture_step(GdkModifierType state, guint keyval) { std::string step; if (state & GDK_CONTROL_MASK) { step += "ctrl+"; } if (state & GDK_ALT_MASK) { step += "alt+"; } if (state & GDK_SHIFT_MASK) { step += "shift+"; } if (state & (GDK_SUPER_MASK | GDK_META_MASK)) { step += "super+"; } const char* name = gdk_keyval_name(keyval); std::string key = name ? name : "?"; std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return std::tolower(c); }); step += key; return step; } static std::string join_capture_steps(const std::vector& steps) { std::string out; for (size_t i = 0; i < steps.size(); ++i) { if (i > 0) { out += ","; } out += steps[i]; } return out; } static void update_capture_display(CaptureSession* session) { std::string text = join_capture_steps(session->steps); if (text.empty()) { text = _("No keys recorded yet"); } gtk_label_set_text(GTK_LABEL(session->displayLabel), text.c_str()); } static gboolean on_capture_key_pressed(GtkEventControllerKey* /*controller*/, guint keyval, guint /*keycode*/, GdkModifierType state, gpointer userData) { auto* session = static_cast(userData); if (is_modifier_keyval(keyval)) { return TRUE; // modifiers are recorded as part of the next real key } if (session->heldKeys.count(keyval)) { return TRUE; // ignore auto-repeat while the key is held down } session->heldKeys.insert(keyval); constexpr GdkModifierType kModMask = static_cast( GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_ALT_MASK | GDK_SUPER_MASK | GDK_META_MASK); session->steps.push_back(format_capture_step( static_cast(state & kModMask), keyval)); update_capture_display(session); return TRUE; } static gboolean on_capture_key_released(GtkEventControllerKey* /*controller*/, guint keyval, guint /*keycode*/, GdkModifierType /*state*/, gpointer userData) { auto* session = static_cast(userData); session->heldKeys.erase(keyval); return TRUE; } static void on_capture_session_destroy(GtkWidget* /*widget*/, gpointer userData) { delete static_cast(userData); } static void on_capture_done_clicked(GtkButton* btn, gpointer userData) { auto* session = static_cast(userData); if (!session->steps.empty()) { gtk_editable_set_text(GTK_EDITABLE(session->row->targetEntry), join_capture_steps(session->steps).c_str()); } GtkWindow* window = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(btn))); gtk_window_destroy(window); // triggers session cleanup via "destroy" } static void on_capture_cancel_clicked(GtkButton* btn, gpointer /*userData*/) { GtkWindow* window = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(btn))); gtk_window_destroy(window); // triggers session cleanup via "destroy" } static void on_capture_clear_clicked(GtkButton* /*btn*/, gpointer userData) { auto* session = static_cast(userData); session->steps.clear(); session->heldKeys.clear(); update_capture_display(session); } static void open_key_capture_dialog(KeyRow* row, GtkWindow* parent) { auto* session = new CaptureSession(); session->row = row; GtkWidget* dialog = gtk_window_new(); gtk_window_set_title(GTK_WINDOW(dialog), _("Capture key sequence")); gtk_window_set_transient_for(GTK_WINDOW(dialog), parent); gtk_window_set_modal(GTK_WINDOW(dialog), TRUE); gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); gtk_window_set_default_size(GTK_WINDOW(dialog), 540, 300); g_signal_connect(dialog, "destroy", G_CALLBACK(on_capture_session_destroy), session); GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 16); gtk_widget_set_margin_top(outer, 20); gtk_widget_set_margin_bottom(outer, 20); gtk_widget_set_margin_start(outer, 20); gtk_widget_set_margin_end(outer, 20); gtk_window_set_child(GTK_WINDOW(dialog), outer); GtkWidget* title = gtk_label_new(nullptr); gtk_label_set_markup(GTK_LABEL(title), g_strdup_printf("%s", _("Capture key sequence"))); gtk_label_set_xalign(GTK_LABEL(title), 0.0f); gtk_box_append(GTK_BOX(outer), title); GtkWidget* info = gtk_label_new(_("Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded too). Click Done to apply or Cancel to discard.")); gtk_label_set_wrap(GTK_LABEL(info), TRUE); gtk_label_set_xalign(GTK_LABEL(info), 0.0f); gtk_widget_add_css_class(info, "dim-label"); gtk_box_append(GTK_BOX(outer), info); session->displayLabel = gtk_label_new(_("No keys recorded yet")); gtk_label_set_xalign(GTK_LABEL(session->displayLabel), 0.0f); gtk_label_set_ellipsize(GTK_LABEL(session->displayLabel), PANGO_ELLIPSIZE_MIDDLE); gtk_widget_add_css_class(session->displayLabel, "monospace"); gtk_widget_set_margin_top(session->displayLabel, 12); gtk_widget_set_margin_bottom(session->displayLabel, 12); gtk_widget_set_margin_start(session->displayLabel, 12); gtk_widget_set_margin_end(session->displayLabel, 12); GtkWidget* frame = gtk_frame_new(nullptr); gtk_frame_set_child(GTK_FRAME(frame), session->displayLabel); gtk_widget_set_size_request(frame, -1, 64); gtk_widget_set_vexpand(frame, TRUE); gtk_box_append(GTK_BOX(outer), frame); gtk_box_append(GTK_BOX(outer), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); // Buttons: Clear on the left, Cancel/Done grouped on the right. GtkWidget* buttonRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); gtk_box_append(GTK_BOX(outer), buttonRow); GtkWidget* clearBtn = gtk_button_new_with_label(_("Clear")); gtk_widget_set_size_request(clearBtn, 110, -1); g_signal_connect(clearBtn, "clicked", G_CALLBACK(on_capture_clear_clicked), session); gtk_box_append(GTK_BOX(buttonRow), clearBtn); GtkWidget* spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); gtk_widget_set_hexpand(spacer, TRUE); gtk_box_append(GTK_BOX(buttonRow), spacer); GtkWidget* endBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_box_append(GTK_BOX(buttonRow), endBox); GtkWidget* cancelBtn = gtk_button_new_with_label(_("Cancel")); gtk_widget_set_size_request(cancelBtn, 110, -1); g_signal_connect(cancelBtn, "clicked", G_CALLBACK(on_capture_cancel_clicked), nullptr); gtk_box_append(GTK_BOX(endBox), cancelBtn); GtkWidget* doneBtn = gtk_button_new_with_label(_("Done")); gtk_widget_set_size_request(doneBtn, 110, -1); g_signal_connect(doneBtn, "clicked", G_CALLBACK(on_capture_done_clicked), session); gtk_box_append(GTK_BOX(endBox), doneBtn); // Key events go to the dialog; buttons must not steal Space/Return. GtkEventController* keyCtrl = gtk_event_controller_key_new(); g_signal_connect(keyCtrl, "key-pressed", G_CALLBACK(on_capture_key_pressed), session); g_signal_connect(keyCtrl, "key-released", G_CALLBACK(on_capture_key_released), session); gtk_widget_add_controller(dialog, keyCtrl); gtk_widget_set_focusable(clearBtn, FALSE); gtk_widget_set_focusable(cancelBtn, FALSE); gtk_widget_set_focusable(doneBtn, FALSE); 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) { 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)); } static bool write_mappings_to_file(const std::string& path) { std::ofstream out(path); if (!out) { return false; } out << "# gKeyMapper mappings (G1..G9)\n"; for (const auto& row : rows) { const char* type = "disabled"; switch (gtk_drop_down_get_selected(GTK_DROP_DOWN(row.typeDrop))) { case 0: type = "key"; break; case 1: type = "command"; break; default: type = "disabled"; break; } const char* target = gtk_editable_get_text(GTK_EDITABLE(row.targetEntry)); out << "G" << (row.index + 1) << "=" << type << ":" << (target ? target : "") << "\n"; } out.close(); return true; } void on_save_clicked(GtkButton* /*btn*/, gpointer /*userData*/) { const char* home = g_get_home_dir(); std::string dir = std::string(home) + "/.config/gKeyMapper"; g_mkdir_with_parents(dir.c_str(), 0755); std::string path = dir + "/mappings.conf"; if (!write_mappings_to_file(path)) { g_printerr(_("Could not save %s.\n"), path.c_str()); return; } std::cout << _("Mappings saved to ") << path << "\n"; } static void import_mappings_from_file(const std::string& path) { std::ifstream in(path); if (!in) { g_printerr(_("Could not open %s.\n"), path.c_str()); return; } std::string line; while (std::getline(in, line)) { if (line.empty() || line[0] == '#') { continue; } auto eq = line.find('='); if (eq == std::string::npos) { continue; } std::string key = line.substr(0, eq); if (key.size() < 2 || key[0] != 'G') { continue; } int n = 0; try { n = std::stoi(key.substr(1)) - 1; } catch (...) { continue; } if (n < 0 || n >= (int)rows.size()) { continue; } std::string rest = line.substr(eq + 1); auto col = rest.find(':'); std::string typeToken = (col == std::string::npos) ? rest : rest.substr(0, col); std::string target = (col == std::string::npos) ? std::string() : rest.substr(col + 1); guint idx = 2; if (typeToken == "key") { idx = 0; } else if (typeToken == "command") { idx = 1; } else if (typeToken == "disabled") { idx = 2; } gtk_drop_down_set_selected(GTK_DROP_DOWN(rows[n].typeDrop), idx); gtk_editable_set_text(GTK_EDITABLE(rows[n].targetEntry), target.c_str()); } } 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 (!write_mappings_to_file(path)) { 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) { import_mappings_from_file(path); 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(headers[c]); gtk_label_set_markup(GTK_LABEL(h), g_strdup_printf("%s", headers[c])); 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(NUM_KEYS); for (int i = 0; i < NUM_KEYS; ++i) { rows[i].index = i; // G key label GtkWidget* keyLabel = gtk_label_new(g_strdup_printf("G%d", i + 1)); gtk_label_set_markup(GTK_LABEL(keyLabel), g_strdup_printf("G%d", i + 1)); 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; }