924 lines
39 KiB
C++
924 lines
39 KiB
C++
// 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 "autostart.h"
|
||
#include "capturedialog.h"
|
||
#include "daemonclient.h"
|
||
#include "help.h"
|
||
#include "ipc_ids.h"
|
||
#include "keyrow.h"
|
||
#include "mapping.h"
|
||
#include "mappings.h"
|
||
#include "optionsdialog.h"
|
||
#include "popovers.h"
|
||
#include "settings.h"
|
||
#include "singleinstance.h"
|
||
#include "trayicon.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;
|
||
|
||
// The rows of the instance this start replaced, as a mappings text (empty when
|
||
// there was none, or when it did not hand them over - an older build). main()
|
||
// fills this in before the window exists; build_ui() starts from it instead of
|
||
// from the file, so edits that were never saved survive a second start.
|
||
std::string handedOverRows;
|
||
|
||
// Set by main() when the session's autostart entry started this instance (the
|
||
// --start-hidden argument): the window is built but stays in the tray, so a login
|
||
// does not put it in the way. on_activate is what reads it - a window that is
|
||
// built later (a language switch) comes up the way it was left.
|
||
bool startHidden = false;
|
||
|
||
// The daemon view behind the footer. It is filled by the asynchronous callbacks
|
||
// of DaemonClient, which arrive on the GTK main loop, so nothing here ever waits
|
||
// for the daemon; the options window shows the same data in full.
|
||
gkeymapper::DaemonView daemonView;
|
||
|
||
GtkWidget* daemonLabel = nullptr;
|
||
gkeymapper::DaemonClient daemonClient;
|
||
|
||
// The window can be hidden instead of closed (the tray brings it back), and the
|
||
// options window keeps showing the daemon status while it is open.
|
||
GtkWidget* mainWindow = nullptr;
|
||
gkeymapper::TrayIcon tray;
|
||
gkeymapper::GuiSettings guiSettings;
|
||
gkeymapper::OptionsDialog optionsDialog;
|
||
|
||
// 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 (!daemonView.reachable) {
|
||
text = _("Daemon: not reachable");
|
||
// Hint for users who installed the package but did not (or could not)
|
||
// enable the daemon service during installation.
|
||
gtk_widget_set_tooltip_text(daemonLabel,
|
||
_("Run: systemctl --user enable --now gkeymapper-daemon"));
|
||
} else {
|
||
gtk_widget_set_tooltip_text(daemonLabel, nullptr);
|
||
text = _("Daemon: running");
|
||
text += " · ";
|
||
if (daemonView.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"), daemonView.device.c_str());
|
||
text += device;
|
||
}
|
||
if (!daemonView.configOk) {
|
||
text += " · ";
|
||
text += _("Configuration error");
|
||
}
|
||
if (!daemonView.lastKey.empty()) {
|
||
text += " · ";
|
||
g_autofree gchar* lastKey =
|
||
g_strdup_printf(_("Last key: %s"), daemonView.lastKey.c_str());
|
||
text += lastKey;
|
||
}
|
||
}
|
||
gtk_label_set_text(GTK_LABEL(daemonLabel), text.c_str());
|
||
}
|
||
|
||
static void on_daemon_status(const gkeymapper::DaemonStatus& status) {
|
||
daemonView.reachable = status.reachable;
|
||
daemonView.configOk = status.configOk;
|
||
daemonView.version = status.version;
|
||
daemonView.device = status.device;
|
||
daemonView.configPath = status.configPath;
|
||
if (!status.reachable) {
|
||
daemonView.lastKey.clear(); // stale once the daemon is gone
|
||
}
|
||
refresh_footer();
|
||
optionsDialog.update(daemonView);
|
||
}
|
||
|
||
static void on_daemon_key(int index, const std::string& action) {
|
||
// Just the fact ("G4 → key:ctrl+v"), no label: the footer and the options
|
||
// window each put their own translated label in front of it.
|
||
daemonView.lastKey = "G" + std::to_string(index + 1) + " → " + action;
|
||
refresh_footer();
|
||
optionsDialog.update(daemonView);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// GtkAboutDialog makes its texts selectable and hands the keyboard focus to the
|
||
// program name (the first focusable widget), so the dialog opens with the name
|
||
// drawn as selected — the blue block nobody asked for. Nothing in that dialog is
|
||
// worth copying (the version is short, and the options window shows it too), so
|
||
// the labels are switched off as selectable; that also stops the name from taking
|
||
// the focus, and no other label claims it afterwards.
|
||
static void make_labels_unselectable(GtkWidget* widget) {
|
||
if (GTK_IS_LABEL(widget)) {
|
||
gtk_label_set_selectable(GTK_LABEL(widget), FALSE);
|
||
}
|
||
for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr;
|
||
child = gtk_widget_get_next_sibling(child)) {
|
||
make_labels_unselectable(child);
|
||
}
|
||
}
|
||
|
||
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);
|
||
// Before it is mapped: that is when the focus would go to the program name.
|
||
make_labels_unselectable(dialog);
|
||
// 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;
|
||
}
|
||
|
||
// Size of the pixmap handed to the panel; the panel scales it to its own size.
|
||
constexpr int kTrayIconSize = 64;
|
||
|
||
// How long a start from the autostart entry waits for the tray icon before it
|
||
// gives up and shows the window (on_start_hidden_timeout).
|
||
constexpr guint kStartHiddenGraceMs = 5000;
|
||
|
||
// Shown and hidden are the two states the tray works with: a hidden window is
|
||
// still a window, so the app keeps running and the tray stays the way back.
|
||
static void show_main_window() {
|
||
if (mainWindow == nullptr) {
|
||
return;
|
||
}
|
||
gtk_widget_set_visible(mainWindow, TRUE);
|
||
// present() also restores a window the window manager had minimized.
|
||
gtk_window_present(GTK_WINDOW(mainWindow));
|
||
}
|
||
|
||
static void hide_main_window() {
|
||
if (mainWindow != nullptr) {
|
||
gtk_widget_set_visible(mainWindow, FALSE);
|
||
}
|
||
}
|
||
|
||
// Hiding is only safe while the tray really hosts the icon: without one there
|
||
// would be no way back to the window, so the app quits as usual.
|
||
static bool hides_to_tray() {
|
||
return guiSettings.minimizeToTray && tray.ready();
|
||
}
|
||
|
||
// Closing the window: with the tray option on it is hidden, otherwise the default
|
||
// handler closes the window and the app quits with it.
|
||
static gboolean on_close_request(GtkWindow* /*window*/, gpointer /*userData*/) {
|
||
if (!hides_to_tray()) {
|
||
return FALSE;
|
||
}
|
||
hide_main_window();
|
||
return TRUE;
|
||
}
|
||
|
||
// GTK4 has no signal for "minimized" (GdkToplevel has no properties either, only
|
||
// gdk_toplevel_get_state), so the one state that needs an action is polled: one
|
||
// call per tick, and only while the option is on and the window is up.
|
||
static gboolean on_minimize_watch(gpointer /*userData*/) {
|
||
if (!hides_to_tray() || mainWindow == nullptr || !gtk_widget_get_visible(mainWindow)) {
|
||
return G_SOURCE_CONTINUE;
|
||
}
|
||
GdkSurface* surface = gtk_native_get_surface(GTK_NATIVE(mainWindow));
|
||
if (surface != nullptr && GDK_IS_TOPLEVEL(surface) &&
|
||
(gdk_toplevel_get_state(GDK_TOPLEVEL(surface)) & GDK_TOPLEVEL_STATE_MINIMIZED) != 0) {
|
||
hide_main_window();
|
||
}
|
||
return G_SOURCE_CONTINUE;
|
||
}
|
||
|
||
// A start from the session's autostart entry keeps the window out of the way, but
|
||
// only a hosted tray icon can bring it back - a desktop without one would leave the
|
||
// program with no window and no way to open one. Nothing knows that at startup
|
||
// (the panel answers asynchronously), so the window is built hidden and appears
|
||
// here if the icon did not turn up in the meantime.
|
||
static gboolean on_start_hidden_timeout(gpointer /*userData*/) {
|
||
if (!tray.ready()) {
|
||
g_printerr("gkeymapper: no tray icon, showing the window\n");
|
||
show_main_window();
|
||
}
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
|
||
// The panel is another process and cannot read the icon compiled into this
|
||
// binary, so the tray icon travels as a pixmap. GdkPixbuf hands out RGBA, the
|
||
// protocol wants ARGB32 in network byte order, so the bytes are reordered here.
|
||
static bool load_tray_icon(std::vector<unsigned char>& pixels, int& width, int& height) {
|
||
GError* error = nullptr;
|
||
GdkPixbuf* pixbuf = have_embedded_icon()
|
||
? gdk_pixbuf_new_from_resource(kIconResource, &error)
|
||
: gdk_pixbuf_new_from_file(find_asset("icon.png").c_str(), &error);
|
||
if (pixbuf == nullptr) {
|
||
g_printerr("gkeymapper: no tray icon: %s\n",
|
||
error != nullptr ? error->message : "unknown error");
|
||
g_clear_error(&error);
|
||
return false;
|
||
}
|
||
if (gdk_pixbuf_get_width(pixbuf) > kTrayIconSize ||
|
||
gdk_pixbuf_get_height(pixbuf) > kTrayIconSize) {
|
||
GdkPixbuf* scaled =
|
||
gdk_pixbuf_scale_simple(pixbuf, kTrayIconSize, kTrayIconSize, GDK_INTERP_BILINEAR);
|
||
g_object_unref(pixbuf);
|
||
pixbuf = scaled;
|
||
}
|
||
if (!gdk_pixbuf_get_has_alpha(pixbuf)) {
|
||
GdkPixbuf* withAlpha = gdk_pixbuf_add_alpha(pixbuf, FALSE, 0, 0, 0);
|
||
g_object_unref(pixbuf);
|
||
pixbuf = withAlpha;
|
||
}
|
||
width = gdk_pixbuf_get_width(pixbuf);
|
||
height = gdk_pixbuf_get_height(pixbuf);
|
||
const int stride = gdk_pixbuf_get_rowstride(pixbuf);
|
||
const guchar* source = gdk_pixbuf_get_pixels(pixbuf);
|
||
pixels.assign(static_cast<size_t>(width) * static_cast<size_t>(height) * 4, 0);
|
||
for (int y = 0; y < height; ++y) {
|
||
const guchar* row = source + static_cast<size_t>(y) * static_cast<size_t>(stride);
|
||
for (int x = 0; x < width; ++x) {
|
||
unsigned char* target =
|
||
pixels.data() + (static_cast<size_t>(y) * static_cast<size_t>(width) + x) * 4;
|
||
target[0] = row[x * 4 + 3]; // A
|
||
target[1] = row[x * 4 + 0]; // R
|
||
target[2] = row[x * 4 + 1]; // G
|
||
target[3] = row[x * 4 + 2]; // B
|
||
}
|
||
}
|
||
g_object_unref(pixbuf);
|
||
return true;
|
||
}
|
||
|
||
static void start_tray(GtkApplication* app) {
|
||
std::vector<unsigned char> pixels;
|
||
int width = 0;
|
||
int height = 0;
|
||
if (!load_tray_icon(pixels, width, height)) {
|
||
return;
|
||
}
|
||
tray.start(std::move(pixels), width, height, show_main_window,
|
||
[app]() { g_application_quit(G_APPLICATION(app)); });
|
||
}
|
||
|
||
static void build_ui(GtkApplication* app, bool show);
|
||
static void open_options_dialog();
|
||
|
||
// Rebuilt from scratch when the language changes: the widgets keep the strings
|
||
// they were made with, so switching means building them again. Runs as an idle,
|
||
// because the caller is the dropdown that this destroys.
|
||
static gboolean rebuild_ui_idle(gpointer /*userData*/) {
|
||
if (mainWindow == nullptr) {
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
GtkApplication* app = gtk_window_get_application(GTK_WINDOW(mainWindow));
|
||
// Both are transient windows: they go with the rebuild and come back if they
|
||
// were open, so nothing on screen is left in the old language.
|
||
const bool helpOpen = gkeymapper::help_window_open();
|
||
const bool optionsOpen = optionsDialog.is_open();
|
||
// A window that was in the tray (or was built hidden at login) stays there: the
|
||
// new one comes up the way the old one was, not in front of the user.
|
||
const bool windowShown = gtk_widget_get_visible(mainWindow);
|
||
// Rows that were not saved yet are widget state, not file content; without
|
||
// this the rebuilt window would silently show the last saved file again.
|
||
const gkeymapper::MappingSet edits = gkeymapper::collect_mappings(rows);
|
||
|
||
gkeymapper::apply_gui_language(guiSettings.language);
|
||
|
||
// Destroying the last window would end the application, so it is held while
|
||
// there is none.
|
||
g_application_hold(G_APPLICATION(app));
|
||
optionsDialog.close();
|
||
gkeymapper::close_help_window();
|
||
daemonLabel = nullptr; // dangling from here on, and a reply may still arrive
|
||
gtk_window_destroy(GTK_WINDOW(mainWindow));
|
||
mainWindow = nullptr;
|
||
|
||
build_ui(app, windowShown);
|
||
gkeymapper::apply_mappings(edits, rows);
|
||
if (helpOpen) {
|
||
gkeymapper::show_help_window(GTK_WINDOW(mainWindow));
|
||
}
|
||
if (optionsOpen) {
|
||
open_options_dialog();
|
||
}
|
||
g_application_release(G_APPLICATION(app));
|
||
|
||
refresh_footer();
|
||
// The panel mirrors the menu once, so the new labels have to be announced.
|
||
tray.refresh_labels();
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
|
||
// Chosen in the options window. Only remembered here; the switch itself is what
|
||
// the user sees next, and that is the rebuild.
|
||
static void on_language_selected(const std::string& language) {
|
||
if (language == guiSettings.language) {
|
||
return;
|
||
}
|
||
guiSettings.language = language;
|
||
gkeymapper::save_gui_settings(guiSettings);
|
||
g_idle_add(rebuild_ui_idle, nullptr);
|
||
}
|
||
|
||
static void open_options_dialog() {
|
||
// None of the switches needs a restart: the tray one decides what closing
|
||
// does next, autostart only writes or removes the session's entry, and the
|
||
// language one rebuilds the windows right away.
|
||
gkeymapper::OptionsState state;
|
||
state.trayAvailable = tray.ready();
|
||
state.autostart = gkeymapper::autostart_enabled();
|
||
state.settings = guiSettings;
|
||
state.view = daemonView;
|
||
optionsDialog.show(mainWindow != nullptr ? GTK_WINDOW(mainWindow) : nullptr, state,
|
||
[](bool minimizeToTray) {
|
||
guiSettings.minimizeToTray = minimizeToTray;
|
||
gkeymapper::save_gui_settings(guiSettings);
|
||
},
|
||
[](bool autostart) {
|
||
// Starting the window at login is a convenience; the G
|
||
// keys work without it (the daemon is what has to run).
|
||
if (!gkeymapper::set_autostart_enabled(autostart)) {
|
||
g_printerr("gkeymapper: autostart entry not %s\n",
|
||
autostart ? "written" : "removed");
|
||
}
|
||
},
|
||
on_language_selected);
|
||
}
|
||
|
||
static void action_help(GSimpleAction* /*action*/, GVariant* /*parameter*/,
|
||
gpointer /*userData*/) {
|
||
gkeymapper::show_help_window(mainWindow != nullptr ? GTK_WINDOW(mainWindow) : nullptr);
|
||
}
|
||
|
||
static void action_options(GSimpleAction* /*action*/, GVariant* /*parameter*/,
|
||
gpointer /*userData*/) {
|
||
open_options_dialog();
|
||
}
|
||
|
||
void build_ui(GtkApplication* app, bool show) {
|
||
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* optionsSection = g_menu_new();
|
||
g_menu_append(optionsSection, _("Options"), "app.options");
|
||
g_menu_append_section(menu, NULL, G_MENU_MODEL(optionsSection));
|
||
|
||
GMenu* helpSection = g_menu_new();
|
||
g_menu_append(helpSection, _("Help"), "app.help");
|
||
g_menu_append(helpSection, _("About"), "app.about");
|
||
g_menu_append_section(menu, NULL, G_MENU_MODEL(helpSection));
|
||
|
||
// While the window is hidden to the tray the menu is the second way out, so
|
||
// Quit is there even though closing the window usually does the same.
|
||
GMenu* quitSection = g_menu_new();
|
||
g_menu_append(quitSection, _("Quit"), "app.quit");
|
||
g_menu_append_section(menu, NULL, G_MENU_MODEL(quitSection));
|
||
|
||
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);
|
||
}
|
||
|
||
// GTK draws the check mark of the selected entry right behind the text of a
|
||
// dropdown row, so every entry carries a trailing space. The catalogs keep
|
||
// the bare words; the space is added here, where the model is built. The
|
||
// strings are copied by gtk_drop_down_new_from_strings, so the array can be
|
||
// freed once every dropdown has been created.
|
||
const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"),
|
||
_("Key (hold)"), nullptr };
|
||
char** typeShown = g_new(char*, 5);
|
||
for (int i = 0; typeOptions[i] != nullptr; ++i) {
|
||
typeShown[i] = g_strconcat(typeOptions[i], " ", nullptr);
|
||
}
|
||
typeShown[4] = 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((const char* const*)typeShown);
|
||
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]);
|
||
}
|
||
g_strfreev(typeShown);
|
||
|
||
// Start from the rows the replaced instance had on screen, or - when this is
|
||
// the only instance - from the saved mappings. Without either 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). Taken once: after this the live rows are the source.
|
||
if (!handedOverRows.empty()) {
|
||
gkeymapper::apply_mappings_text(handedOverRows, rows);
|
||
handedOverRows.clear();
|
||
} else {
|
||
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);
|
||
|
||
mainWindow = window;
|
||
// The type dropdowns and the header bar menu are popovers; without this they
|
||
// grab the pointer and freeze the rest of the desktop while they are open.
|
||
gkeymapper::make_popovers_release_input(GTK_WINDOW(window));
|
||
// Closing is a request, not the end: the handler decides whether the window
|
||
// hides to the tray or the app quits with it.
|
||
g_signal_connect(window, "close-request", G_CALLBACK(on_close_request), nullptr);
|
||
// `show` is false for the one start that must not put a window in the way: the
|
||
// one the session's autostart entry makes (on_start_hidden_timeout is what then
|
||
// decides whether it has to appear after all).
|
||
if (show) {
|
||
gtk_window_present(GTK_WINDOW(window));
|
||
}
|
||
}
|
||
|
||
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* helpAction = g_simple_action_new("help", NULL);
|
||
g_signal_connect(helpAction, "activate", G_CALLBACK(action_help), app);
|
||
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(helpAction));
|
||
|
||
GSimpleAction* optionsAction = g_simple_action_new("options", NULL);
|
||
g_signal_connect(optionsAction, "activate", G_CALLBACK(action_options), app);
|
||
g_action_map_add_action(G_ACTION_MAP(app), G_ACTION(optionsAction));
|
||
|
||
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));
|
||
|
||
const char* helpAccels[] = {"F1", NULL};
|
||
gtk_application_set_accels_for_action(app, "app.help", helpAccels);
|
||
const char* quitAccels[] = {"<Control>q", NULL};
|
||
gtk_application_set_accels_for_action(app, "app.quit", quitAccels);
|
||
|
||
// A start from the session leaves the window in the tray; a start by hand shows
|
||
// it (main() reads the flag off the command line).
|
||
build_ui(app, !startHidden);
|
||
|
||
// From here on this instance answers a new start with the rows its window
|
||
// shows, so the instance that replaces it does not lose edits that were never
|
||
// saved (see replace_running_instance). It has to come after build_ui(), which
|
||
// is what fills the rows.
|
||
gkeymapper::serve_rows_handover([]() { return gkeymapper::mappings_text(rows); });
|
||
|
||
// One-time, and deliberately not part of build_ui(): the window is rebuilt
|
||
// when the language changes, while the tray, its poll and the daemon client
|
||
// keep running as they are.
|
||
start_tray(app);
|
||
if (startHidden) {
|
||
// The tray is what keeps a hidden window reachable; is it one? Only the
|
||
// panel can answer that, and it does so asynchronously (start_tray above is
|
||
// the question). Until then the window stays hidden and comes up if that
|
||
// question was never answered.
|
||
g_timeout_add(kStartHiddenGraceMs, on_start_hidden_timeout, nullptr);
|
||
}
|
||
// Four checks per second: one cheap call into the backend each, quick enough
|
||
// that a minimized window does not linger in the task list. It does nothing
|
||
// while the tray option is off.
|
||
g_timeout_add(250, on_minimize_watch, nullptr);
|
||
daemonClient.init(on_daemon_status, on_daemon_key);
|
||
}
|
||
} // 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);
|
||
|
||
// Window preferences (the tray option, the language) live in their own file
|
||
// next to the mappings, so a mapping file stays a mapping file.
|
||
guiSettings = gkeymapper::load_gui_settings();
|
||
|
||
// A saved language overrides the session's, and it has to be in place before
|
||
// the first translated string is built.
|
||
gkeymapper::apply_gui_language(guiSettings.language);
|
||
|
||
// The autostart entry starts the window in the tray and says so with an argument
|
||
// of its own (kStartHiddenArg). GTK does not know that argument and would stop
|
||
// on it, so it is taken out of the command line here, before it sees it.
|
||
std::vector<char*> args;
|
||
args.reserve(static_cast<size_t>(argc) + 1);
|
||
for (int i = 0; i < argc; ++i) {
|
||
if (g_strcmp0(argv[i], gkeymapper::kStartHiddenArg) == 0) {
|
||
startHidden = true;
|
||
continue;
|
||
}
|
||
args.push_back(argv[i]);
|
||
}
|
||
args.push_back(nullptr);
|
||
|
||
// 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. Its rows come along,
|
||
// so a mapping that was typed but not saved is not lost with it.
|
||
gkeymapper::replace_running_instance(gkeymapper::ipc::kAppId, &handedOverRows);
|
||
|
||
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), static_cast<int>(args.size()) - 1,
|
||
args.data());
|
||
g_object_unref(app);
|
||
return status;
|
||
}
|