Files
gKeyMapper/src/main.cpp
2026-10-18 16:59:51 +02:00

554 lines
22 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <iostream>
#include <locale.h>
#include <glib/gi18n.h>
#include <string>
#include <vector>
#include "capturedialog.h"
#include "daemonclient.h"
#include "ipc_ids.h"
#include "keyrow.h"
#include "mapping.h"
#include "mappings.h"
#include "singleinstance.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<KeyRow> 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;
bool configOk = true;
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");
text += " · ";
if (daemonFooter.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"), daemonFooter.device.c_str());
text += device;
}
if (!daemonFooter.configOk) {
text += " · ";
text += _("Configuration error");
}
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.configOk = status.configOk;
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<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));
}
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));
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<KeyRow*>(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);
// 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;
}
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("<b>%s</b>", 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"),
_("Key (hold)"), 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("<b>G%d</b>", 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]);
}
// Start from the saved mappings. Without this 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).
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);
gtk_window_present(GTK_WINDOW(window));
daemonClient.init(on_daemon_status, on_daemon_key);
}
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* 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));
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);
// 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.
gkeymapper::replace_running_instance(gkeymapper::ipc::kAppId);
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;
}