Files
gKeyMapper/src/main.cpp

687 lines
26 KiB
C++
Raw Normal View History

2026-10-16 22:11:52 +02:00
// 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 <algorithm>
2026-10-17 02:42:09 +02:00
#include <cctype>
2026-10-16 22:11:52 +02:00
#include <fstream>
#include <iostream>
#include <locale.h>
#include <libintl.h>
2026-10-17 02:42:09 +02:00
#include <set>
2026-10-16 22:11:52 +02:00
#include <string>
#include <vector>
#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<KeyRow> rows;
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));
}
2026-10-17 02:42:09 +02:00
struct CaptureSession {
KeyRow* row = nullptr;
GtkWidget* displayLabel = nullptr;
std::vector<std::string> steps;
std::set<guint> 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<std::string>& 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<CaptureSession*>(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<GdkModifierType>(
GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_ALT_MASK |
GDK_SUPER_MASK | GDK_META_MASK);
session->steps.push_back(format_capture_step(
static_cast<GdkModifierType>(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<CaptureSession*>(userData);
session->heldKeys.erase(keyval);
return TRUE;
}
static void on_capture_session_destroy(GtkWidget* /*widget*/, gpointer userData) {
delete static_cast<CaptureSession*>(userData);
}
static void on_capture_done_clicked(GtkButton* btn, gpointer userData) {
auto* session = static_cast<CaptureSession*>(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<CaptureSession*>(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("<span size='large' weight='bold'>%s</span>",
_("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));
}
2026-10-16 22:11:52 +02:00
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));
2026-10-17 02:42:09 +02:00
GtkWindow* window = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(btn)));
2026-10-16 22:11:52 +02:00
if (sel == 1) {
pick_command_file(row, window);
return;
}
if (sel == 0) {
2026-10-17 02:42:09 +02:00
open_key_capture_dialog(row, window);
2026-10-16 22:11:52 +02:00
}
}
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..."));
2026-10-17 02:42:09 +02:00
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
_("e.g. /usr/bin/firefox or custom command"));
2026-10-16 22:11:52 +02:00
} else {
gtk_button_set_label(GTK_BUTTON(row->captureButton), _("Capture"));
2026-10-17 02:42:09 +02:00
gtk_entry_set_placeholder_text(GTK_ENTRY(row->targetEntry),
_("e.g. ctrl+c, ctrl+v"));
2026-10-16 22:11:52 +02:00
}
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));
}
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");
2026-10-17 02:42:09 +02:00
gtk_about_dialog_set_website_label(GTK_ABOUT_DIALOG(dialog), "Thomas Rager - rager.dev");
2026-10-16 22:11:52 +02:00
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), "<span size='x-large' weight='bold'>G-Key Mapper</span>");
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("<b>%s</b>", 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("<b>G%d</b>", 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();
2026-10-17 02:42:09 +02:00
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), _("e.g. ctrl+c, ctrl+v"));
2026-10-16 22:11:52 +02:00
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"));
2026-10-17 02:42:09 +02:00
// 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.
2026-10-16 22:11:52 +02:00
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;
}