From 95f101c0c92316072f71b3486e1924c40e627f04 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 18 Oct 2026 18:26:06 +0200 Subject: [PATCH] Autostart option added --- AGENTS.md | 138 ++++++++- README.md | 57 ++++ docs/daemon-plan.md | 38 +++ po/de.po | 176 +++++++++++- po/en.po | 171 +++++++++++- po/es.po | 174 +++++++++++- po/fr.po | 180 +++++++++++- po/gkeymapper.pot | 268 +++++++++++++++--- src/autostart.cpp | 117 ++++++++ src/autostart.h | 25 ++ src/daemonclient.h | 12 + src/help.cpp | 115 ++++++++ src/help.h | 21 ++ src/main.cpp | 323 +++++++++++++++++++-- src/mappings.cpp | 10 +- src/mappings.h | 8 + src/optionsdialog.cpp | 279 +++++++++++++++++++ src/optionsdialog.h | 83 ++++++ src/settings.cpp | 95 +++++++ src/settings.h | 33 +++ src/trayicon.cpp | 635 ++++++++++++++++++++++++++++++++++++++++++ src/trayicon.h | 109 ++++++++ 22 files changed, 2987 insertions(+), 80 deletions(-) create mode 100644 src/autostart.cpp create mode 100644 src/autostart.h create mode 100644 src/help.cpp create mode 100644 src/help.h create mode 100644 src/optionsdialog.cpp create mode 100644 src/optionsdialog.h create mode 100644 src/settings.cpp create mode 100644 src/settings.h create mode 100644 src/trayicon.cpp create mode 100644 src/trayicon.h diff --git a/AGENTS.md b/AGENTS.md index 9b90682..7802c63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,11 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/` (the background service). Shared API lives in `namespace gkeymapper`. `shared/mapping` owns the mappings file format, `shared/keys` the canonical key-name table (lowercase GDK names -> Linux `KEY_*`), `shared/ipc_ids.h` the - app id, the version and the D-Bus names both sides use. + app id, the version and the D-Bus names both sides use. The GUI side is + `main.cpp` (window, actions) plus `keyrow.h`, `mappings`, `capturedialog`, + `daemonclient`, `singleinstance`, `trayicon` (StatusNotifierItem + dbusmenu), + `settings` (`gui.conf`), `autostart` (the session's `.desktop` entry), `help` + and `optionsdialog`. - The Makefile globs sources: `make` builds the GUI (`src/*.cpp` + `shared/*.cpp` + the generated `build/gresources.c`, see Assets) **and** the daemon (`daemon/*.cpp` + `shared/*.cpp`, needs `libusb-1.0-0-dev`, @@ -95,6 +99,12 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/` not an error), so a save from a fresh window rewrites the file instead of emptying it. App id: `dev.rager.gkeymapper` (`ipc::kAppId`); the About dialog uses `ipc::kVersion`. +- The About dialog is a stock `GtkAboutDialog`, but its texts get switched off as + selectable before it is mapped (`make_labels_unselectable`): GTK hands the + keyboard focus to the program name and a focused selectable `GtkLabel` selects + its whole text, so the dialog opened with the name drawn as a blue block. Do + not "clean that up" back — as long as the labels are selectable there is always + one that grabs the focus and shows up marked. - GUI ↔ daemon is D-Bus on the session bus, GDBus (GIO) on both sides: `src/daemonclient.{h,cpp}` is the client, `daemon/ipc_dbus.{h,cpp}` the service. The daemon owns `dev.rager.gkeymapper.Daemon`, **not** the plain app @@ -121,6 +131,71 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/` `DaemonClient` uses async calls and signal subscriptions, which arrive on the GTK main loop, and it follows `NameOwnerChanged` so the footer reacts when the daemon starts or stops. +- The tray icon (`src/trayicon.{h,cpp}`) is a `StatusNotifierItem` plus a + `com.canonical.dbusmenu` menu, spoken over GIO — **not** through libxapp or + libappindicator: both are GTK3 libraries (measured: `ldd libxapp.so.1` → + `libgtk-3.so.0`, deps `libgtk-3-0` + `libdbusmenu-gtk3-4`), and loading GTK3 + next to GTK4 in one process is not supported. GTK4 also has no tray API at all + (`GtkStatusIcon` is GTK3). This way the feature adds no build dependency. The + icon travels as `IconPixmap` (ARGB32, network byte order): the panel is another + process and cannot resolve the icons compiled into the binary via GResource. +- In the dbusmenu layout the **children array holds full nodes** + (`(ia{sv}av)` each), never bare ids: `libdbusmenu-glib`, which the panel's + watcher uses, walks them and aborts the whole process on anything else + (`g_variant_serialised_n_children: code should not be reached`) — that crashed + `xapp-sn-watcher` on every registration until it was fixed. Do not "simplify" + this back to ids. +- The panel's watcher sends its clicks batched as `EventGroup` (a list of + `(isvu)`), not as single `Event` calls: both must be handled, `EventGroup` is the + path a real click takes (verified with `dbus-monitor`). Both entries go through + one `handle_event(id, eventId)`, which only reacts to `"clicked"` on the Show and + Close ids. +- Two watcher names exist (`org.kde.StatusNotifierWatcher` and the older + `org.x.StatusNotifierWatcher`; on Mint both belong to `xapp-sn-watcher`). Both are + watched and the item registers **once**, preferring the `org.kde.` name — a + second registration would add a second icon. `ready()` (registered with a + watcher that still owns the name) is what gates hiding the window. +- The GUI's own preferences (minimize to tray, language) live in + `~/.config/gKeyMapper/gui.conf` (GKeyFile, `src/settings.{h,cpp}`), deliberately + **not** in `mappings.conf`, so a mapping file stays a mapping file and can be + exported/imported without dragging a window preference along. +- Autostart is **not** a setting in `gui.conf`: it is the presence of the entry + the session reads at login, `~/.config/autostart/gkeymapper.desktop` + (`src/autostart.{h,cpp}`). The switch writes that file or removes it, so there + is no flag that could disagree with what the session actually does; + `autostart_enabled()` treats `Hidden=true` and `X-GNOME-Autostart-enabled=false` + as off, because the desktop's own startup tool disables an entry instead of + deleting it. The `Exec` line is the binary that is running now + (`g_file_read_link("/proc/self/exe")`, quoted, falls back to the name from + `PATH`), so an enabled switch starts the build the user was looking at, and the + `Comment` reuses the About text (no second sentence for translators). Only the + **window** is started; the daemon is a systemd user service and stays untouched + — the GUI is a convenience, the daemon is the part that has to run. +- `Minimize to tray` hides the window on close (`"close-request"` handler) and on + minimize. GTK4 has **no** minimize signal and `GdkToplevel` exposes no + properties, so `gdk_toplevel_get_state()` is polled every 250 ms while the + option is on. Hiding happens only while `tray.ready()` — without a hosted icon + there would be no way back to the window, so a close then quits as usual. The + tray menu's Show uses `gtk_widget_set_visible` + `gtk_window_present` (which also + restores a minimized window). +- Help, options and the tray menu are separate windows/files instead of a + `GMenu`: a `GMenu` cannot host a switch, and the options window is deliberately + non-modal so the footer keeps updating while it is open. The daemon data behind + both is one `DaemonView` (last status reply + last key); the footer shows it in + one line, the options window in full. +- The language is a setting in `gui.conf`, not something that is guessed: gettext + gets it through `LANGUAGE` plus a fresh `setlocale` (`apply_gui_language`), and + that is also why the English catalog is an identity one — an explicit `en` has + to work on a German desktop. Widgets keep the strings they were built with, so + switching rebuilds the windows (`rebuild_ui_idle` in `main.cpp`): the main + window is destroyed and built again, options and help are closed and reopened + if they were open, and the tray menu is announced to the panel with + `LayoutUpdated` (it mirrors the menu once, so the revision is bumped). The + application is held across the gap — destroying the last window would end the + process. The rows go over with `collect_mappings`/`apply_mappings`, so a + language switch keeps unsaved edits; never rebuild the window without that + step. Rebuilding also means the poll and the tray are **not** started in + `build_ui()`: they are one-time, in `on_activate`. - `DaemonService::pump()` dispatches messages once per daemon loop iteration, so a control call is answered within one read timeout (≤500 ms) and outgoing signals are flushed then too. Keep it off the keypress path. @@ -361,3 +436,64 @@ is up. Three notes for tests: sees. - `XTest`-synthesized clicks need the window mapped and focused; the `Save` button is `push button` + name `Mappings speichern` in the German locale. + +The tray icon, the help and the options window need neither hardware nor a click: + +- Is the item registered? The panel watcher lists it by name: + `gdbus call --session --dest org.kde.StatusNotifierWatcher --object-path + /StatusNotifierWatcher --method org.freedesktop.DBus.Properties.Get + org.kde.StatusNotifierWatcher RegisteredStatusNotifierItems` — the entry is + `org.kde.StatusNotifierItem--1/StatusNotifierItem`. A second entry (same + pid, two registrations) means the "register once" rule broke. +- Its menu is readable without the panel: + `gdbus call --session --dest org.kde.StatusNotifierItem--1 --object-path + /MenuBar --method com.canonical.dbusmenu.GetLayout 0 -1` returns the root node + with the two children. Drive a click the way the panel does — **batched**, as + `EventGroup`, because that is the path a real click takes: + `gdbus call … --method com.canonical.dbusmenu.EventGroup + "[(1, 'clicked', <'', >, 0)]"` shows the window, id `2` quits the program. + A single `Event` call works too but is not what the watcher sends. +- The watcher is fragile by construction: it walks the layout it mirrors, so a + malformed node (bare ids instead of full nodes) aborts it. After a change to + the layout, check it survived (`pgrep -a xapp-sn-watcher`) and that no new + `coredumpctl list xapp-sn-watcher` entry appeared. +- Minimize-to-tray is testable without the WM: send the `WM_CHANGE_STATE` client + message (`IconicState = 3`) to the window id from `wmctrl -l` with python-xlib + (`root.send_event(..., SubstructureRedirect|SubstructureNotify)`) — `wmctrl -b + add,hidden` does **not** work, the WM ignores a client-set state. With the + option on, the window disappears from `wmctrl -l` within ~250 ms (the poll) and + the process stays; `Show` from the menu brings it back. With the option off, a + close quits the process. +- The help and options windows are plain `GtkWindow`s in the same process, so + they are reachable over their app actions: + `gdbus call --session --dest dev.rager.gkeymapper --object-path + /dev/rager/gkeymapper --method org.gtk.Actions.Activate help "[]" "{}"` + (same with `options`), then screenshot with + `import -window $(wmctrl -l | grep -i Hilfe | awk '{print $1}') …`. The options + switch can be clicked with `xtest` at `xwininfo`'s absolute position plus its + offset in the window (~422, 56), and the result is visible in `gui.conf` and in + the close behavior right away, no restart. +- The language dropdown sits at about (82, 192) from the options window's + top-left, and its popup entries follow about 25 px apart (the list opens below + the widget). Check the result in three places: `gui.conf` (`language=de`), + the rebuilt window, and the tray menu (`GetLayout` then says "Anzeigen"). + Writing `language=en` into `gui.conf` and starting the GUI covers the startup + path without a click; a `language=` (empty value) is "follow the session". + One pitfall while clicking: the options window is transient for the main + window, so it always stays above it — clicks meant for the main window hit the + options window unless it is closed first. +- The autostart switch needs no click to read and one click to write, because the + `.desktop` entry *is* the state: put `$HOME/.config/autostart/gkeymapper.desktop` + there (or delete it), close the options window (`wmctrl -i -c `) and + activate the `options` action again — the switch shows what the file says. A + single `xtest` click below the tray switch (about (428, 149) from the window's + top-left; find it by looking for the accent blue, the tray switch is grey when + `gui.conf` has no `minimizeToTray`) writes or removes the file right away. + **Use a throwaway `HOME`** — with the real one the next login would start the + repo build. `src/autostart.cpp` also compiles on its own (glib only, plus + `-DGETTEXT_PACKAGE`), which is the fastest way to cover the round trip and the + two "disabled" spellings (`Hidden=true`, + `X-GNOME-Autostart-enabled=false`). +- A daemon that stops and starts again (`systemctl --user stop/start + gkeymapper-daemon`) is the cheapest way to see the options window update while + it is open (the rows change, the switches must not). diff --git a/README.md b/README.md index b9ab2f5..80b449b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # gKeyMapper +![C++17](https://img.shields.io/badge/C%2B%2B-17-00599C?logo=cplusplus&logoColor=white) +![GTK 4](https://img.shields.io/badge/GTK-4-7FE719?logo=gtk&logoColor=black) +![GLib/GIO](https://img.shields.io/badge/GLib%2FGIO-D--Bus-4A86CF) +![libusb](https://img.shields.io/badge/libusb-1.0-c8a200) +![xkbcommon](https://img.shields.io/badge/xkbcommon-layout--aware-6E4C13) +![i18n](https://img.shields.io/badge/i18n-de%20%7C%20en%20%7C%20es%20%7C%20fr-blueviolet) +![systemd](https://img.shields.io/badge/systemd-user%20service-30D475?logo=systemd&logoColor=white) +![Platform](https://img.shields.io/badge/platform-Linux%20Mint%20%7C%20Ubuntu-FCC624?logo=linux&logoColor=black) + GTK4 application (C++) for remapping the G keys **G1–G9** of a Logitech **G910** to other keys or commands (Linux Mint / Ubuntu). @@ -8,6 +17,12 @@ G-keys from the keyboard and replays the actions is implemented as well; the GUI and the daemon talk over a small D-Bus channel (status, reload, test) — see `docs/daemon-plan.md`. +**Only the daemon has to run.** It is the part that reads the G keys and replays +the action, and it runs as a systemd user service. The window is for editing the +mappings and for the status: it may be closed at any time, and starting it with +the session (`Options` → `Start with the session`) is a convenience, not a +requirement. + Documentation and source comments are written in English. ## Requirements @@ -93,6 +108,44 @@ build — and then shows its own window, so a stale window can never stay next t fresh one. Row edits that were not saved are lost with the replaced window, because saving is always explicit (`Save mappings`). +### Menu, tray icon and minimize to tray + +The menu button in the title bar holds `Export configuration...` / `Import +configuration...`, `Options`, `Help` (also `F1`), `About` and `Quit` (`Ctrl+Q`). + +* `Help` opens a one-page explanation of the nine rows, the four types, the + capture dialog, the daemon status and the tray. +* `Options` opens a small window with the GUI's own switches and the daemon in + detail: state, version, keyboard, the config file it reads and the last G key + that fired (the footer says the same in one line). The window is not modal, so + it keeps updating while it is open. One of its switches is the language + (`System language`, or a fixed `de`/`en`/`es`/`fr`): it switches the window, the + menus and the tray menu right away — nothing to restart, and rows that were not + saved yet are kept. +* `Start with the session` opens the window when you log in. It writes the usual + autostart entry (`~/.config/autostart/gkeymapper.desktop`, starting the binary + that is running) and removes it again when you switch it off; an entry that the + desktop's own startup tool has disabled counts as off, too. Like the window + itself this is a convenience: the G keys do not need it, only the daemon has to + run (see `Running the daemon` below). + +`Minimize to tray` keeps gKeyMapper reachable from the panel: the window is +hidden instead of closed when you close or minimize it, and the tray icon's menu +shows it again (`Show`) or quits the program (`Close`). Closing still quits when +the option is off — or when no panel hosts the tray icon, because then there +would be no way back to the window. The option is remembered in +`~/.config/gKeyMapper/gui.conf`, a file of its own so `mappings.conf` stays a +pure mapping file. The same file holds the chosen language; with `System +language` (the default) the desktop's locale decides. + +The tray icon is a StatusNotifierItem plus a `com.canonical.dbusmenu` menu over +GIO — the same protocol libxapp and libappindicator wrap, only without their GTK3 +dependency (a GTK4 process must not load GTK3). It needs no package of its own and +works wherever the protocol is hosted: the Cinnamon panel (via `xapp-sn-watcher`), +KDE, Unity or GNOME with the AppIndicator extension. The icon travels as a pixmap +because the panel is a different process and cannot read the icons compiled into +the binary. + ## Running the daemon The daemon reads G1–G9 from the keyboard and replays the configured action @@ -288,6 +341,10 @@ src/mappings.{h,cpp} # GUI glue: config file <-> row widgets src/capturedialog.{h,cpp} # key-capture dialog src/daemonclient.{h,cpp} # D-Bus client for the daemon (status/reload) src/singleinstance.{h,cpp} # one GUI only: a new start closes a running instance +src/trayicon.{h,cpp} # tray icon: StatusNotifierItem + dbusmenu over GIO +src/settings.{h,cpp} # gui.conf (window preferences, e.g. minimize to tray) +src/help.{h,cpp} # help window +src/optionsdialog.{h,cpp} # options window: switches + detailed daemon status shared/mapping.{h,cpp} # mappings format + shortcut parsing (GUI & daemon) shared/keys.{h,cpp} # canonical key names -> Linux key codes shared/keymap.{h,cpp} # layout-aware key lookups (xkbcommon), GUI + daemon diff --git a/docs/daemon-plan.md b/docs/daemon-plan.md index c3b8a39..99e78dc 100644 --- a/docs/daemon-plan.md +++ b/docs/daemon-plan.md @@ -361,6 +361,39 @@ Start-up orders: comes from a build without the action — and `on_activate` re-presents the window that is already there instead of building a second one. A stale window therefore never stays next to a fresh one, and the daemon is not involved. +- Menu, help and options: `Help` (also `F1`) opens a one-page explanation of the + rows, types, capture dialog, daemon status and tray; `Options` opens a small + non-modal window with the GUI's own switches (tray behaviour, language, + autostart) and the daemon in detail (state, version, keyboard, config file, last + key), while the footer keeps the one-line summary. A `GMenu` cannot host a + switch, so the options are a window. +- Autostart is the session's `.desktop` entry and nothing else + (`src/autostart.{h,cpp}`): `~/.config/autostart/gkeymapper.desktop` existing (and + not switched off by the desktop's own tool) *is* "on", so the switch cannot + disagree with what the session reads at login. It starts the window, never the + daemon: the daemon is a user service, and it stays the only part that has to + run. The window is a convenience — the mappings can be edited any time, and the + G keys work with it closed. +- Language switching is immediate, without a restart and without losing rows that + were not saved: gettext is handed the code through `LANGUAGE` plus a fresh + `setlocale`, and the windows are built again (the rows are carried over with + `collect_mappings`/`apply_mappings`; the tray menu is announced with + `LayoutUpdated`). `System language` is the default and follows the desktop; + the choice lives in `gui.conf` and is applied before the first string is built, + so a restart keeps it. +- `Minimize to tray`: closing or minimizing hides the window instead of quitting, + and the tray icon shows it again (`Show`) or quits the program (`Close`). GTK4 + has no minimize signal and `GdkToplevel` has no properties, so the state is + polled (250 ms) while the option is on; hiding happens only while a panel really + hosts the icon — without one there would be no way back to the window. The + preference lives in `~/.config/gKeyMapper/gui.conf`, next to `mappings.conf` but + a file of its own, so a mapping file stays a mapping file. +- The tray icon is a `StatusNotifierItem` plus a `com.canonical.dbusmenu` menu + spoken over GIO (`src/trayicon.{h,cpp}`): the protocol libxapp and + libappindicator wrap, without their GTK3 dependency — a GTK4 process must not + load GTK3. The icon travels as an `IconPixmap`, because the panel is another + process and cannot read the icons compiled into the binary. Both watcher names + are watched and the item registers once, preferring `org.kde.StatusNotifierWatcher`. ## 6. Code layout @@ -368,6 +401,11 @@ Start-up orders: src/ # GUI (existing) daemonclient.{h,cpp} # D-Bus client (status/reload/signals) singleinstance.{h,cpp} # one GUI only: a new start closes the running instance + trayicon.{h,cpp} # tray icon: StatusNotifierItem + dbusmenu over GIO + settings.{h,cpp} # gui.conf (window preferences, e.g. minimize to tray) + autostart.{h,cpp} # the session's autostart entry (~/.config/autostart) + help.{h,cpp} # help window + optionsdialog.{h,cpp} # options window: switches + detailed daemon status daemon/ # as implemented (see §6 above) ipc_dbus.{h,cpp} # GDBus service (control plane) shared/ diff --git a/po/de.po b/po/de.po index 8ab4587..bff1489 100644 --- a/po/de.po +++ b/po/de.po @@ -151,8 +151,180 @@ msgstr "Gerät: nicht gefunden" msgid "Configuration error" msgstr "Konfigurationsfehler" -msgid "Last key: G%d → %s" -msgstr "Letzte Taste: G%d → %s" +msgid "Last key: %s" +msgstr "Letzte Taste: %s" msgid "Daemon: checking..." msgstr "Daemon: wird geprüft ..." + +msgid "Options" +msgstr "Optionen" + +msgid "Help" +msgstr "Hilfe" + +msgid "Quit" +msgstr "Beenden" + +msgid "Show" +msgstr "Anzeigen" + +msgid "Close" +msgstr "Beenden" + +msgid "Close window" +msgstr "Fenster schließen" + +msgid "Window" +msgstr "Fenster" + +msgid "Minimize to tray" +msgstr "In den Infobereich minimieren" + +msgid "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." +msgstr "" +"Beim Schließen oder Minimieren wird das Fenster versteckt statt beendet. Das " +"Symbol im Infobereich zeigt es wieder (Anzeigen) und beendet das Programm " +"(Beenden)." + +msgid "Language" +msgstr "Sprache" + +msgid "System language" +msgstr "Systemsprache" + +msgid "The interface switches right away, including the tray menu." +msgstr "Die Oberfläche wechselt sofort – auch das Menü im Infobereich." + +msgid "Start with the session" +msgstr "Beim Anmelden starten" + +msgid "" +"Opens this window when you log in. Not needed for the G keys - the daemon " +"does the work and is the only part that has to run." +msgstr "" +"Öffnet dieses Fenster beim Anmelden. Für die G-Tasten nicht nötig – die " +"Arbeit macht der Daemon, und nur der muss laufen." + +msgid "No tray is available on this desktop, so the window cannot be hidden." +msgstr "" +"Auf diesem Desktop gibt es keinen Infobereich, daher kann das Fenster nicht " +"versteckt werden." + +msgid "Daemon status" +msgstr "Daemon-Status" + +msgid "Tray icon" +msgstr "Infobereich" + +msgid "State" +msgstr "Zustand" + +msgid "Version" +msgstr "Version" + +msgid "Device" +msgstr "Gerät" + +msgid "Configuration file" +msgstr "Konfigurationsdatei" + +msgid "Last key" +msgstr "Letzte Taste" + +msgid "active" +msgstr "aktiv" + +msgid "not available" +msgstr "nicht verfügbar" + +msgid "running" +msgstr "läuft" + +msgid "not reachable" +msgstr "nicht erreichbar" + +msgid "not found" +msgstr "nicht gefunden" + +msgid "" +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." +msgstr "" +"Die neun Zeilen – eine pro G-Taste. Typ wählen, Ziel eingeben oder " +"aufnehmen und Mappings speichern drücken." + +msgid "Key – presses the captured sequence once, e.g. ctrl+c." +msgstr "Taste – drückt die aufgenommene Sequenz einmal, z. B. ctrl+c." + +msgid "" +"Key (hold) – presses it and keeps it down while the G key is held, " +"e.g. for push-to-talk. Only a single step can be held." +msgstr "" +"Taste (halten) – drückt sie und hält sie, solange die G-Taste " +"gehalten wird, z. B. für Push-to-Talk. Nur ein einzelner Schritt kann " +"gehalten werden." + +msgid "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox " +"--new-window." +msgstr "" +"Befehl – führt ein Programm oder eine Kommandozeile aus, z. B. " +"/usr/bin/firefox --new-window." + +msgid "Disabled – the key does nothing." +msgstr "Deaktiviert – die Taste tut nichts." + +msgid "" +"Capture – records the keys you press next: press them, then " +"Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." +msgstr "" +"Aufnehmen – zeichnet die nächsten Tastenanschläge auf: drücken, dann " +"Fertig. Modifikatoren bleiben erhalten (shift, altgr, …) und die " +"Namen sind layoutunabhängig, ctrl+z bedeutet also die Taste, die auf deinem " +"Layout z tippt." + +msgid "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." +msgstr "" +"Mappings speichern – schreibt ~/.config/gKeyMapper/mappings.conf und " +"lässt den Daemon sie neu laden. Die Menüeinträge Konfiguration " +"exportieren... und Konfiguration importieren... machen dasselbe " +"mit einer Datei deiner Wahl." + +msgid "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. " +"Options shows the same in more detail." +msgstr "" +"Daemon-Status – die Fußzeile zeigt, ob gkeymapper-daemon läuft, " +"welche Tastatur gefunden wurde und welche G-Taste zuletzt ausgelöst hat. " +"Optionen zeigt dasselbe ausführlicher." + +msgid "" +"Autostart – Start with the session only opens this window when " +"you log in. The G keys work without it: gkeymapper-daemon (a user service) " +"reads them and replays the actions, and that daemon is the only part that has " +"to run." +msgstr "" +"Autostart – Beim Anmelden starten öffnet nur dieses Fenster. " +"Die G-Tasten funktionieren auch ohne: gkeymapper-daemon (ein Benutzerdienst) " +"liest sie und spielt die Aktionen ab – nur dieser Daemon muss laufen." + +msgid "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved are lost " +"with it." +msgstr "" +"Infobereich und Fenster – mit In den Infobereich minimieren " +"wird das Fenster beim Schließen oder Minimieren versteckt statt geschlossen; " +"das Symbol im Infobereich zeigt es wieder (Anzeigen) und beendet das " +"Programm (Beenden). Ein erneuter Start von gKeyMapper schließt das " +"alte Fenster – nicht gespeicherte Zeilen gehen damit verloren." diff --git a/po/en.po b/po/en.po index 23b890b..738531a 100644 --- a/po/en.po +++ b/po/en.po @@ -151,8 +151,175 @@ msgstr "Device: not found" msgid "Configuration error" msgstr "Configuration error" -msgid "Last key: G%d → %s" -msgstr "Last key: G%d → %s" +msgid "Last key: %s" +msgstr "Last key: %s" msgid "Daemon: checking..." msgstr "Daemon: checking..." + +msgid "Options" +msgstr "Options" + +msgid "Help" +msgstr "Help" + +msgid "Quit" +msgstr "Quit" + +msgid "Show" +msgstr "Show" + +msgid "Close" +msgstr "Close" + +msgid "Close window" +msgstr "Close window" + +msgid "Window" +msgstr "Window" + +msgid "Minimize to tray" +msgstr "Minimize to tray" + +msgid "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." +msgstr "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." + +msgid "Language" +msgstr "Language" + +msgid "System language" +msgstr "System language" + +msgid "The interface switches right away, including the tray menu." +msgstr "The interface switches right away, including the tray menu." + +msgid "Start with the session" +msgstr "Start with the session" + +msgid "" +"Opens this window when you log in. Not needed for the G keys - the daemon " +"does the work and is the only part that has to run." +msgstr "" +"Opens this window when you log in. Not needed for the G keys - the daemon " +"does the work and is the only part that has to run." + +msgid "No tray is available on this desktop, so the window cannot be hidden." +msgstr "No tray is available on this desktop, so the window cannot be hidden." + +msgid "Daemon status" +msgstr "Daemon status" + +msgid "Tray icon" +msgstr "Tray icon" + +msgid "State" +msgstr "State" + +msgid "Version" +msgstr "Version" + +msgid "Device" +msgstr "Device" + +msgid "Configuration file" +msgstr "Configuration file" + +msgid "Last key" +msgstr "Last key" + +msgid "active" +msgstr "active" + +msgid "not available" +msgstr "not available" + +msgid "running" +msgstr "running" + +msgid "not reachable" +msgstr "not reachable" + +msgid "not found" +msgstr "not found" + +msgid "" +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." +msgstr "" +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." + +msgid "Key – presses the captured sequence once, e.g. ctrl+c." +msgstr "Key – presses the captured sequence once, e.g. ctrl+c." + +msgid "" +"Key (hold) – presses it and keeps it down while the G key is held, " +"e.g. for push-to-talk. Only a single step can be held." +msgstr "" +"Key (hold) – presses it and keeps it down while the G key is held, " +"e.g. for push-to-talk. Only a single step can be held." + +msgid "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox " +"--new-window." +msgstr "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox " +"--new-window." + +msgid "Disabled – the key does nothing." +msgstr "Disabled – the key does nothing." + +msgid "" +"Capture – records the keys you press next: press them, then " +"Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." +msgstr "" +"Capture – records the keys you press next: press them, then " +"Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." + +msgid "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." +msgstr "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." + +msgid "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. " +"Options shows the same in more detail." +msgstr "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. " +"Options shows the same in more detail." + +msgid "" +"Autostart – Start with the session only opens this window when " +"you log in. The G keys work without it: gkeymapper-daemon (a user service) " +"reads them and replays the actions, and that daemon is the only part that has " +"to run." +msgstr "" +"Autostart – Start with the session only opens this window when " +"you log in. The G keys work without it: gkeymapper-daemon (a user service) " +"reads them and replays the actions, and that daemon is the only part that has " +"to run." + +msgid "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved are lost " +"with it." +msgstr "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved are lost " +"with it." diff --git a/po/es.po b/po/es.po index 79e0671..42a1d44 100644 --- a/po/es.po +++ b/po/es.po @@ -149,8 +149,178 @@ msgstr "Dispositivo: no encontrado" msgid "Configuration error" msgstr "Error de configuración" -msgid "Last key: G%d → %s" -msgstr "Última tecla: G%d → %s" +msgid "Last key: %s" +msgstr "Última tecla: %s" msgid "Daemon: checking..." msgstr "Demonio: comprobando..." + +msgid "Options" +msgstr "Opciones" + +msgid "Help" +msgstr "Ayuda" + +msgid "Quit" +msgstr "Salir" + +msgid "Show" +msgstr "Mostrar" + +msgid "Close" +msgstr "Cerrar" + +msgid "Close window" +msgstr "Cerrar ventana" + +msgid "Window" +msgstr "Ventana" + +msgid "Minimize to tray" +msgstr "Minimizar a la bandeja" + +msgid "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." +msgstr "" +"Al cerrar o minimizar, la ventana se oculta en lugar de salir. El icono de " +"la bandeja la vuelve a mostrar (Mostrar) y cierra el programa (Cerrar)." + +msgid "Language" +msgstr "Idioma" + +msgid "System language" +msgstr "Idioma del sistema" + +msgid "The interface switches right away, including the tray menu." +msgstr "La interfaz cambia al instante, incluido el menú de la bandeja." + +msgid "Start with the session" +msgstr "Iniciar con la sesión" + +msgid "" +"Opens this window when you log in. Not needed for the G keys - the daemon " +"does the work and is the only part that has to run." +msgstr "" +"Abre esta ventana al iniciar la sesión. No hace falta para las teclas G: el " +"trabajo lo hace el daemon, y solo él tiene que estar en marcha." + +msgid "No tray is available on this desktop, so the window cannot be hidden." +msgstr "" +"Este escritorio no tiene bandeja, así que la ventana no se puede ocultar." + +msgid "Daemon status" +msgstr "Estado del daemon" + +msgid "Tray icon" +msgstr "Bandeja" + +msgid "State" +msgstr "Estado" + +msgid "Version" +msgstr "Versión" + +msgid "Device" +msgstr "Dispositivo" + +msgid "Configuration file" +msgstr "Archivo de configuración" + +msgid "Last key" +msgstr "Última tecla" + +msgid "active" +msgstr "activo" + +msgid "not available" +msgstr "no disponible" + +msgid "running" +msgstr "en ejecución" + +msgid "not reachable" +msgstr "no accesible" + +msgid "not found" +msgstr "no encontrado" + +msgid "" +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." +msgstr "" +"Las nueve filas – una por tecla G. Elige el tipo, introduce o captura " +"el destino y pulsa Guardar asignaciones." + +msgid "Key – presses the captured sequence once, e.g. ctrl+c." +msgstr "Tecla – pulsa la secuencia capturada una vez, p. ej. ctrl+c." + +msgid "" +"Key (hold) – presses it and keeps it down while the G key is held, " +"e.g. for push-to-talk. Only a single step can be held." +msgstr "" +"Tecla (mantener) – la pulsa y la mantiene mientras se mantiene la " +"tecla G, p. ej. para pulsar para hablar. Solo se puede mantener un paso." + +msgid "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox " +"--new-window." +msgstr "" +"Comando – ejecuta un programa o una línea de comandos, p. ej. " +"/usr/bin/firefox --new-window." + +msgid "Disabled – the key does nothing." +msgstr "Desactivado – la tecla no hace nada." + +msgid "" +"Capture – records the keys you press next: press them, then " +"Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." +msgstr "" +"Capturar – graba las siguientes teclas que pulses: púlsalas y luego " +"Aceptar. Los modificadores se conservan (shift, altgr, …) y los " +"nombres no dependen de la distribución, así que ctrl+z es la tecla que " +"escribe z en tu distribución." + +msgid "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." +msgstr "" +"Guardar asignaciones – escribe ~/.config/gKeyMapper/mappings.conf y " +"hace que el daemon la recargue. Las entradas de menú Exportar " +"configuración... e Importar configuración... hacen lo mismo con " +"el archivo que elijas." + +msgid "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. " +"Options shows the same in more detail." +msgstr "" +"Estado del daemon – el pie de ventana muestra si gkeymapper-daemon " +"está en ejecución, qué teclado ha encontrado y la última tecla G que se ha " +"pulsado. Opciones muestra lo mismo con más detalle." + +msgid "" +"Autostart – Start with the session only opens this window when " +"you log in. The G keys work without it: gkeymapper-daemon (a user service) " +"reads them and replays the actions, and that daemon is the only part that has " +"to run." +msgstr "" +"Inicio automático – Iniciar con la sesión solo abre esta " +"ventana. Las teclas G funcionan sin ella: gkeymapper-daemon (un servicio de " +"usuario) las lee y reproduce las acciones, y ese daemon es lo único que tiene " +"que estar en marcha." + +msgid "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved are lost " +"with it." +msgstr "" +"Bandeja y ventana – con Minimizar a la bandeja, al cerrar o " +"minimizar la ventana se oculta en lugar de cerrarse; el icono de la bandeja " +"la vuelve a mostrar (Mostrar) y cierra el programa (Cerrar). " +"Volver a iniciar gKeyMapper cierra la ventana antigua: las filas no " +"guardadas se pierden con ella." diff --git a/po/fr.po b/po/fr.po index e1bfcb6..90aac75 100644 --- a/po/fr.po +++ b/po/fr.po @@ -149,8 +149,184 @@ msgstr "Appareil : introuvable" msgid "Configuration error" msgstr "Erreur de configuration" -msgid "Last key: G%d → %s" -msgstr "Dernière touche : G%d → %s" +msgid "Last key: %s" +msgstr "Dernière touche : %s" msgid "Daemon: checking..." msgstr "Démon : vérification..." + +msgid "Options" +msgstr "Options" + +msgid "Help" +msgstr "Aide" + +msgid "Quit" +msgstr "Quitter" + +msgid "Show" +msgstr "Afficher" + +msgid "Close" +msgstr "Fermer" + +msgid "Close window" +msgstr "Fermer la fenêtre" + +msgid "Window" +msgstr "Fenêtre" + +msgid "Minimize to tray" +msgstr "Réduire dans la zone de notification" + +msgid "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." +msgstr "" +"À la fermeture ou à la réduction, la fenêtre est masquée au lieu de quitter. " +"L'icône de la zone de notification la réaffiche (Afficher) et quitte le " +"programme (Fermer)." + +msgid "Language" +msgstr "Langue" + +msgid "System language" +msgstr "Langue du système" + +msgid "The interface switches right away, including the tray menu." +msgstr "" +"L'interface change immédiatement, y compris le menu de la zone de " +"notification." + +msgid "Start with the session" +msgstr "Démarrer avec la session" + +msgid "" +"Opens this window when you log in. Not needed for the G keys - the daemon " +"does the work and is the only part that has to run." +msgstr "" +"Ouvre cette fenêtre à l'ouverture de la session. Inutile pour les touches G : " +"c'est le démon qui fait le travail, et lui seul doit tourner." + +msgid "No tray is available on this desktop, so the window cannot be hidden." +msgstr "" +"Ce bureau n'a pas de zone de notification, la fenêtre ne peut donc pas être " +"masquée." + +msgid "Daemon status" +msgstr "État du démon" + +msgid "Tray icon" +msgstr "Zone de notification" + +msgid "State" +msgstr "État" + +msgid "Version" +msgstr "Version" + +msgid "Device" +msgstr "Périphérique" + +msgid "Configuration file" +msgstr "Fichier de configuration" + +msgid "Last key" +msgstr "Dernière touche" + +msgid "active" +msgstr "actif" + +msgid "not available" +msgstr "non disponible" + +msgid "running" +msgstr "en cours" + +msgid "not reachable" +msgstr "inaccessible" + +msgid "not found" +msgstr "introuvable" + +msgid "" +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." +msgstr "" +"Les neuf lignes – une par touche G. Choisissez le type, saisissez ou " +"capturez la cible puis cliquez sur Enregistrer les attributions." + +msgid "Key – presses the captured sequence once, e.g. ctrl+c." +msgstr "Touche – presse une fois la séquence capturée, par ex. ctrl+c." + +msgid "" +"Key (hold) – presses it and keeps it down while the G key is held, " +"e.g. for push-to-talk. Only a single step can be held." +msgstr "" +"Touche (maintenue) – l'appuie et la maintient tant que la touche G " +"est maintenue, p. ex. pour appuyer-pour-parler. Une seule étape peut être " +"maintenue." + +msgid "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox " +"--new-window." +msgstr "" +"Commande – lance un programme ou une ligne de commande, par ex. " +"/usr/bin/firefox --new-window." + +msgid "Disabled – the key does nothing." +msgstr "Désactivé – la touche ne fait rien." + +msgid "" +"Capture – records the keys you press next: press them, then " +"Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." +msgstr "" +"Capturer – enregistre les touches que vous pressez ensuite : " +"pressez-les puis Terminer. Les modificateurs sont conservés (shift, " +"altgr, …) et les noms ne dépendent pas de la disposition, donc ctrl+z " +"désigne la touche qui tape z sur votre disposition." + +msgid "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." +msgstr "" +"Enregistrer les attributions – écrit " +"~/.config/gKeyMapper/mappings.conf et fait recharger le fichier au démon. " +"Les entrées de menu Exporter la configuration... et Importer la " +"configuration... font la même chose avec le fichier de votre choix." + +msgid "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. " +"Options shows the same in more detail." +msgstr "" +"État du démon – le pied de fenêtre indique si gkeymapper-daemon est " +"en cours d'exécution, quel clavier a été trouvé et la dernière touche G " +"déclenchée. Options affiche la même chose plus en détail." + +msgid "" +"Autostart – Start with the session only opens this window when " +"you log in. The G keys work without it: gkeymapper-daemon (a user service) " +"reads them and replays the actions, and that daemon is the only part that has " +"to run." +msgstr "" +"Démarrage automatique – Démarrer avec la session ouvre " +"seulement cette fenêtre. Les touches G fonctionnent sans elle : " +"gkeymapper-daemon (un service utilisateur) les lit et rejoue les actions, et " +"ce démon est la seule partie qui doit tourner." + +msgid "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved are lost " +"with it." +msgstr "" +"Zone de notification et fenêtre – avec Réduire dans la zone de " +"notification, la fenêtre est masquée au lieu d'être fermée lorsque vous " +"la fermez ou la réduisez ; l'icône de la zone de notification la réaffiche " +"(Afficher) et quitte le programme (Fermer). Relancer " +"gKeyMapper ferme l'ancienne fenêtre : les lignes non enregistrées sont " +"perdues avec elle." diff --git a/po/gkeymapper.pot b/po/gkeymapper.pot index 8be6765..78524c5 100644 --- a/po/gkeymapper.pot +++ b/po/gkeymapper.pot @@ -17,186 +17,362 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: src/main.cpp:55 +#: src/main.cpp:126 msgid "Choose file" msgstr "" -#: src/main.cpp:58 src/main.cpp:419 src/main.cpp:431 +#: src/main.cpp:129 src/main.cpp:300 src/main.cpp:312 msgid "_Cancel" msgstr "" -#: src/main.cpp:59 src/main.cpp:432 +#: src/main.cpp:130 src/main.cpp:313 msgid "_Open" msgstr "" -#: src/main.cpp:129 src/main.cpp:218 +#: src/capturedialog.cpp:115 src/capturedialog.cpp:226 msgid "No keys recorded yet" msgstr "" -#: src/main.cpp:187 +#: src/capturedialog.cpp:199 src/capturedialog.cpp:215 msgid "Capture key sequence" msgstr "" -#: src/main.cpp:191 +#: src/capturedialog.cpp:250 msgid "Clear" msgstr "" -#: src/main.cpp:192 +#: src/capturedialog.cpp:262 msgid "Cancel" msgstr "" -#: src/main.cpp:193 +#: src/capturedialog.cpp:267 msgid "Done" msgstr "" -#: src/main.cpp:213 +#: src/capturedialog.cpp:220 msgid "" "Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are " "recorded too). Click Done to apply or Cancel to discard." msgstr "" -#: src/main.cpp:146 +#: src/main.cpp:160 msgid "Choose file..." msgstr "" -#: src/main.cpp:148 +#: src/main.cpp:162 msgid "e.g. /usr/bin/firefox or custom command" msgstr "" -#: src/main.cpp:150 src/main.cpp:154 src/main.cpp:436 +#: src/main.cpp:164 src/main.cpp:168 src/main.cpp:685 msgid "Capture" msgstr "" -#: src/main.cpp:156 src/main.cpp:429 +#: src/main.cpp:170 src/main.cpp:678 msgid "e.g. ctrl+c, ctrl+v" msgstr "" -#: src/main.cpp:152 +#: src/main.cpp:166 msgid "e.g. ctrl+w (held while the G key is held)" msgstr "" -#: src/main.cpp:306 +#: src/main.cpp:185 #, c-format msgid "Could not save %s.\n" msgstr "" -#: src/main.cpp:310 +#: src/main.cpp:193 msgid "Mappings saved to " msgstr "" -#: src/main.cpp:316 +#: src/mappings.cpp:41 #, c-format msgid "Could not open %s.\n" msgstr "" -#: src/main.cpp:368 +#: src/main.cpp:203 msgid "Could not export mappings.\n" msgstr "" -#: src/main.cpp:393 src/main.cpp:499 +#: src/main.cpp:264 src/main.cpp:590 src/trayicon.cpp:370 src/trayicon.cpp:500 msgid "gKeyMapper" msgstr "" -#: src/main.cpp:396 +#: src/autostart.cpp:97 src/main.cpp:267 src/trayicon.cpp:371 msgid "G-Key mapper for the Logitech G910 (GTK4)." msgstr "" -#: src/main.cpp:416 +#: src/main.cpp:297 msgid "Export configuration" msgstr "" -#: src/main.cpp:420 +#: src/main.cpp:301 msgid "_Save" msgstr "" -#: src/main.cpp:428 +#: src/main.cpp:309 msgid "Import configuration" msgstr "" -#: src/main.cpp:488 +#: src/main.cpp:575 msgid "gKeyMapper – Logitech G910" msgstr "" -#: src/main.cpp:503 +#: src/main.cpp:594 msgid "Export configuration..." msgstr "" -#: src/main.cpp:504 +#: src/main.cpp:595 msgid "Import configuration..." msgstr "" -#: src/main.cpp:507 +#: src/main.cpp:603 msgid "About" msgstr "" -#: src/main.cpp:531 +#: src/main.cpp:623 msgid "G1 to G9 of the Logitech G910 remapped to keys or commands" msgstr "" -#: src/main.cpp:548 +#: src/main.cpp:644 msgid "G-Key" msgstr "" -#: src/main.cpp:548 +#: src/main.cpp:644 msgid "Type" msgstr "" -#: src/main.cpp:548 +#: src/main.cpp:644 msgid "Target (Key/Command)" msgstr "" -#: src/main.cpp:548 +#: src/main.cpp:644 msgid "Action" msgstr "" -#: src/main.cpp:404 +#: src/main.cpp:653 msgid "Key" msgstr "" -#: src/main.cpp:404 +#: src/main.cpp:653 msgid "Command" msgstr "" -#: src/main.cpp:404 +#: src/main.cpp:653 msgid "Disabled" msgstr "" -#: src/main.cpp:405 +#: src/main.cpp:654 msgid "Key (hold)" msgstr "" -#: src/main.cpp:603 +#: src/main.cpp:713 msgid "Save mappings" msgstr "" -#: src/main.cpp:608 +#: src/main.cpp:720 msgid "Saved to ~/.config/gKeyMapper/mappings.conf" msgstr "" -#: src/main.cpp:55 +#: src/main.cpp:63 msgid "Daemon: not reachable" msgstr "" -#: src/main.cpp:57 +#: src/main.cpp:65 msgid "Daemon: running" msgstr "" -#: src/main.cpp:62 +#: src/main.cpp:70 msgid "Device: not found" msgstr "" -#: src/main.cpp:65 +#: src/main.cpp:73 msgid "Device: %s" msgstr "" -#: src/main.cpp:70 +#: src/main.cpp:78 src/optionsdialog.cpp:212 msgid "Configuration error" msgstr "" -#: src/main.cpp:92 -msgid "Last key: G%d → %s" +#: src/main.cpp:83 +msgid "Last key: %s" msgstr "" -#: src/main.cpp:475 +#: src/main.cpp:731 msgid "Daemon: checking..." msgstr "" + +#: src/main.cpp:598 src/optionsdialog.cpp:81 +msgid "Options" +msgstr "" + +#: src/help.cpp:67 src/help.cpp:86 src/main.cpp:602 +msgid "Help" +msgstr "" + +#: src/main.cpp:609 +msgid "Quit" +msgstr "" + +#: src/trayicon.cpp:136 +msgid "Show" +msgstr "" + +#: src/trayicon.cpp:139 +msgid "Close" +msgstr "" + +#: src/help.cpp:95 src/optionsdialog.cpp:164 +msgid "Close window" +msgstr "" + +#: src/optionsdialog.cpp:95 +msgid "Window" +msgstr "" + +#: src/optionsdialog.cpp:97 +msgid "Minimize to tray" +msgstr "" + +#: src/optionsdialog.cpp:200 +msgid "" +"Closing or minimizing the window hides it instead of quitting. The tray icon " +"shows it again (Show) and quits the program (Close)." +msgstr "" + +#: src/optionsdialog.cpp:128 src/optionsdialog.cpp:136 +msgid "Language" +msgstr "" + +#: src/optionsdialog.cpp:131 +msgid "System language" +msgstr "" + +#: src/optionsdialog.cpp:141 +msgid "The interface switches right away, including the tray menu." +msgstr "" + +#: src/optionsdialog.cpp:110 +msgid "Start with the session" +msgstr "" + +#: src/optionsdialog.cpp:122 +msgid "" +"Opens this window when you log in. Not needed for the G keys - the daemon " +"does the work and is the only part that has to run." +msgstr "" + +#: src/optionsdialog.cpp:202 +msgid "No tray is available on this desktop, so the window cannot be hidden." +msgstr "" + +#: src/optionsdialog.cpp:144 +msgid "Daemon status" +msgstr "" + +#: src/optionsdialog.cpp:157 +msgid "Tray icon" +msgstr "" + +#: src/optionsdialog.cpp:158 +msgid "State" +msgstr "" + +#: src/optionsdialog.cpp:159 +msgid "Version" +msgstr "" + +#: src/optionsdialog.cpp:160 +msgid "Device" +msgstr "" + +#: src/optionsdialog.cpp:161 +msgid "Configuration file" +msgstr "" + +#: src/optionsdialog.cpp:162 +msgid "Last key" +msgstr "" + +#: src/optionsdialog.cpp:205 +msgid "active" +msgstr "" + +#: src/optionsdialog.cpp:205 +msgid "not available" +msgstr "" + +#: src/optionsdialog.cpp:206 +msgid "running" +msgstr "" + +#: src/optionsdialog.cpp:206 +msgid "not reachable" +msgstr "" + +#: src/optionsdialog.cpp:208 +msgid "not found" +msgstr "" + +#: src/help.cpp:34 +msgid "" +"The nine rows – one per G key. Choose the type, then enter or capture " +"the target and press Save mappings." +msgstr "" + +#: src/help.cpp:36 +msgid "Key – presses the captured sequence once, e.g. ctrl+c." +msgstr "" + +#: src/help.cpp:37 +msgid "" +"Key (hold) – presses it and keeps it down while the G key is held, " +"e.g. for push-to-talk. Only a single step can be held." +msgstr "" + +#: src/help.cpp:39 +msgid "" +"Command – runs a program or a command line, e.g. /usr/bin/firefox " +"--new-window." +msgstr "" + +#: src/help.cpp:40 +msgid "Disabled – the key does nothing." +msgstr "" + +#: src/help.cpp:41 +msgid "" +"Capture – records the keys you press next: press them, then " +"Done. Modifiers are kept (shift, altgr, …) and the names are layout " +"independent, so ctrl+z means the key that types z on your layout." +msgstr "" + +#: src/help.cpp:44 +msgid "" +"Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes " +"the daemon reload it. The menu entries Export configuration... and " +"Import configuration... do the same with a file you choose." +msgstr "" + +#: src/help.cpp:47 +msgid "" +"Daemon status – the footer shows whether gkeymapper-daemon is " +"running, which keyboard it found and the last G key that fired. " +"Options shows the same in more detail." +msgstr "" + +#: src/help.cpp:50 +msgid "" +"Autostart – Start with the session only opens this window when " +"you log in. The G keys work without it: gkeymapper-daemon (a user service) " +"reads them and replays the actions, and that daemon is the only part that has " +"to run." +msgstr "" + +#: src/help.cpp:53 +msgid "" +"Tray and window – with Minimize to tray the window is hidden " +"instead of closed when you close or minimize it; the tray icon shows it " +"again (Show) and quits the program (Close). Starting " +"gKeyMapper again closes the old window – rows that were not saved are lost " +"with it." +msgstr "" diff --git a/src/autostart.cpp b/src/autostart.cpp new file mode 100644 index 0000000..fd3d065 --- /dev/null +++ b/src/autostart.cpp @@ -0,0 +1,117 @@ +// gKeyMapper - the autostart entry that opens the window at login. +// +// What this starts is the window, never the daemon: the daemon is the part that +// has to run for the G keys to work, it is a systemd user service (see data/), and +// a desktop file does not own it. The window is a convenience - the mappings can +// be edited any time, and the G keys work with the window closed. + +#include "autostart.h" + +#include +#include +#include + +namespace gkeymapper { + +namespace { +const char* kGroup = "Desktop Entry"; + +std::string autostart_dir() { + return std::string(g_get_user_config_dir()) + "/autostart"; +} + +// The binary that is running now. A build in the working tree keeps working after +// `make` because the path stays valid, and an installed one resolves to +// /usr/local/bin/gkeymapper, so the entry always starts the program the user is +// looking at. Only without /proc the name is left to PATH. +std::string program_path() { + GError* error = nullptr; + g_autofree gchar* path = g_file_read_link("/proc/self/exe", &error); + if (path != nullptr) { + return path; + } + g_clear_error(&error); + return "gkeymapper"; +} + +// An entry can be there and still do nothing: the desktop's startup-applications +// tool disables it instead of deleting it. Both spellings mean "off" here. +bool disabled_by_desktop(GKeyFile* file) { + GError* itemError = nullptr; + const gboolean hidden = g_key_file_get_boolean(file, kGroup, "Hidden", &itemError); + const bool isHidden = itemError == nullptr && hidden != FALSE; + g_clear_error(&itemError); + + const gboolean gnomeEnabled = + g_key_file_get_boolean(file, kGroup, "X-GNOME-Autostart-enabled", &itemError); + const bool isDisabled = itemError == nullptr && gnomeEnabled == FALSE; + g_clear_error(&itemError); + + return isHidden || isDisabled; +} +} // namespace + +std::string autostart_file_path() { + return autostart_dir() + "/gkeymapper.desktop"; +} + +bool autostart_enabled() { + GKeyFile* file = g_key_file_new(); + GError* error = nullptr; + // A missing file is the normal state for "off", not an error. + const gboolean loaded = + g_key_file_load_from_file(file, autostart_file_path().c_str(), G_KEY_FILE_NONE, &error); + g_clear_error(&error); + + const bool enabled = loaded != FALSE && !disabled_by_desktop(file); + g_key_file_unref(file); + return enabled; +} + +bool set_autostart_enabled(bool enabled) { + const std::string path = autostart_file_path(); + + if (!enabled) { + if (!g_file_test(path.c_str(), G_FILE_TEST_EXISTS)) { + return true; // nothing to undo + } + if (g_remove(path.c_str()) != 0) { + g_printerr("gkeymapper: could not remove %s\n", path.c_str()); + return false; + } + return true; + } + + g_autofree gchar* directory = g_path_get_dirname(path.c_str()); + if (g_mkdir_with_parents(directory, 0700) != 0) { + g_printerr("gkeymapper: could not create %s\n", directory); + return false; + } + + GKeyFile* file = g_key_file_new(); + g_key_file_set_string(file, kGroup, "Type", "Application"); + g_key_file_set_string(file, kGroup, "Name", "gKeyMapper"); + // The desktop shows this next to the name; the wording is the one from the + // About dialog, so no translator has to carry a second sentence for it. + g_key_file_set_string(file, kGroup, "Comment", + _("G-Key mapper for the Logitech G910 (GTK4).")); + // Quoted: a path may contain a space. + g_autofree gchar* command = g_strdup_printf("\"%s\"", program_path().c_str()); + g_key_file_set_string(file, kGroup, "Exec", command); + g_key_file_set_string(file, kGroup, "Icon", "gkeymapper"); + g_key_file_set_boolean(file, kGroup, "Terminal", FALSE); + g_key_file_set_boolean(file, kGroup, "StartupNotify", FALSE); + g_key_file_set_string(file, kGroup, "Categories", "Utility;"); + + GError* error = nullptr; + const gboolean written = g_key_file_save_to_file(file, path.c_str(), &error); + if (written == FALSE) { + g_printerr("gkeymapper: could not write %s: %s\n", path.c_str(), + error != nullptr ? error->message : "unknown error"); + g_clear_error(&error); + } + g_key_file_unref(file); + return written != FALSE; +} + +} // namespace gkeymapper diff --git a/src/autostart.h b/src/autostart.h new file mode 100644 index 0000000..fcb2229 --- /dev/null +++ b/src/autostart.h @@ -0,0 +1,25 @@ +// gKeyMapper - the autostart entry that opens the window at login. +#ifndef GKEYMAPPER_AUTOSTART_H +#define GKEYMAPPER_AUTOSTART_H + +#include + +namespace gkeymapper { + +// The session's autostart entry: ~/.config/autostart/gkeymapper.desktop. Every +// desktop reads that directory at login, so the switch is one file and nothing +// else - no flag in gui.conf that could disagree with it. +std::string autostart_file_path(); + +// True when that file exists and starts the program. An entry the desktop's own +// tool has switched off (Hidden=true, or X-GNOME-Autostart-enabled=false) counts +// as off as well, so the switch shows what really happens at login. +bool autostart_enabled(); + +// Writes the entry (starting the binary that is running now) or removes it. +// False when the file could not be written or removed. +bool set_autostart_enabled(bool enabled); + +} // namespace gkeymapper + +#endif // GKEYMAPPER_AUTOSTART_H diff --git a/src/daemonclient.h b/src/daemonclient.h index 1be8524..9d4a492 100644 --- a/src/daemonclient.h +++ b/src/daemonclient.h @@ -18,6 +18,18 @@ struct DaemonStatus { std::string configPath; }; +// The daemon as the GUI shows it: the last status reply plus what the signals +// added since (the last G key that fired). The footer shows a one-line summary of +// it, the options window the full picture. +struct DaemonView { + bool reachable = false; + bool configOk = true; + std::string version; + std::string device; + std::string configPath; + std::string lastKey; +}; + // Control-plane client for the daemon. Every call is asynchronous and its // result arrives on the GTK main loop, so the UI never blocks on the daemon // (a call is answered at most one daemon loop iteration late). Keypresses never diff --git a/src/help.cpp b/src/help.cpp new file mode 100644 index 0000000..1438b62 --- /dev/null +++ b/src/help.cpp @@ -0,0 +1,115 @@ +// gKeyMapper - the help window. +// +// One page, no scrolling and no search: the program has nine rows and four +// types, so the help is a handful of paragraphs. Each paragraph is one +// translated string including its bold term, so a translator sees the sentence +// the user sees. + +#include "help.h" + +#include + +namespace gkeymapper { + +namespace { + +GtkWidget* helpWindow = nullptr; + +// One paragraph: Pango markup (the bold term), wrapped at a readable width. +GtkWidget* paragraph(const char* text) { + GtkWidget* label = gtk_label_new(nullptr); + gtk_label_set_markup(GTK_LABEL(label), text); + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_max_width_chars(GTK_LABEL(label), 62); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + gtk_widget_set_halign(label, GTK_ALIGN_START); + return label; +} + +void on_destroy(GtkWidget* /*widget*/, gpointer /*userData*/) { + helpWindow = nullptr; +} + +const char* kParagraphs[] = { + N_("The nine rows – one per G key. Choose the type, then enter or capture the " + "target and press Save mappings."), + N_("Key – presses the captured sequence once, e.g. ctrl+c."), + N_("Key (hold) – presses it and keeps it down while the G key is held, e.g. for " + "push-to-talk. Only a single step can be held."), + N_("Command – runs a program or a command line, e.g. /usr/bin/firefox --new-window."), + N_("Disabled – the key does nothing."), + N_("Capture – records the keys you press next: press them, then Done. " + "Modifiers are kept (shift, altgr, …) and the names are layout independent, so ctrl+z " + "means the key that types z on your layout."), + N_("Save mappings – writes ~/.config/gKeyMapper/mappings.conf and makes the daemon " + "reload it. The menu entries Export configuration... and Import " + "configuration... do the same with a file you choose."), + N_("Daemon status – the footer shows whether gkeymapper-daemon is running, which " + "keyboard it found and the last G key that fired. Options shows the same in " + "more detail."), + N_("Autostart – Start with the session only opens this window when you log " + "in. The G keys work without it: gkeymapper-daemon (a user service) reads them and " + "replays the actions, and that daemon is the only part that has to run."), + N_("Tray and window – with Minimize to tray the window is hidden instead of " + "closed when you close or minimize it; the tray icon shows it again (Show) and " + "quits the program (Close). Starting gKeyMapper again closes the old window – " + "rows that were not saved are lost with it."), +}; + +} // namespace + +void show_help_window(GtkWindow* parent) { + if (helpWindow != nullptr) { + gtk_window_present(GTK_WINDOW(helpWindow)); + return; + } + GtkWidget* window = gtk_window_new(); + gtk_window_set_title(GTK_WINDOW(window), _("Help")); + gtk_window_set_resizable(GTK_WINDOW(window), FALSE); + // Themed like the main window: same icon, same resource path (registered by + // build_ui, which always runs before the menu can be used). + gtk_window_set_icon_name(GTK_WINDOW(window), "gkeymapper"); + if (parent != nullptr) { + gtk_window_set_transient_for(GTK_WINDOW(window), parent); + } + + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_widget_set_margin_top(box, 18); + gtk_widget_set_margin_bottom(box, 18); + gtk_widget_set_margin_start(box, 18); + gtk_widget_set_margin_end(box, 18); + gtk_window_set_child(GTK_WINDOW(window), box); + + GtkWidget* heading = gtk_label_new(nullptr); + // The heading is the same word as the menu entry, only bigger; the markup is + // added around the translation so no translator has to carry tags for it. + g_autofree gchar* headingMarkup = g_strdup_printf("%s", _("Help")); + gtk_label_set_markup(GTK_LABEL(heading), headingMarkup); + gtk_label_set_xalign(GTK_LABEL(heading), 0.0f); + gtk_box_append(GTK_BOX(box), heading); + + for (const char* text : kParagraphs) { + gtk_box_append(GTK_BOX(box), paragraph(_(text))); + } + + GtkWidget* closeButton = gtk_button_new_with_label(_("Close window")); + gtk_widget_set_halign(closeButton, GTK_ALIGN_END); + g_signal_connect_swapped(closeButton, "clicked", G_CALLBACK(gtk_window_destroy), window); + gtk_box_append(GTK_BOX(box), closeButton); + + g_signal_connect(window, "destroy", G_CALLBACK(on_destroy), nullptr); + helpWindow = window; + gtk_window_present(GTK_WINDOW(window)); +} + +bool help_window_open() { + return helpWindow != nullptr; +} + +void close_help_window() { + if (helpWindow != nullptr) { + gtk_window_destroy(GTK_WINDOW(helpWindow)); + } +} + +} // namespace gkeymapper diff --git a/src/help.h b/src/help.h new file mode 100644 index 0000000..eb87978 --- /dev/null +++ b/src/help.h @@ -0,0 +1,21 @@ +// gKeyMapper - the help window. +#ifndef GKEYMAPPER_HELP_H +#define GKEYMAPPER_HELP_H + +#include + +namespace gkeymapper { + +// Explains the window in one page: what the nine rows, the four types, the +// capture dialog, the daemon status and the tray do. Opens the help window, or +// brings the open one to the front. +void show_help_window(GtkWindow* parent); + +// True while the window is on screen. Its texts are translated when it is built, +// so a language switch closes it (and reopens it if it was open). +bool help_window_open(); +void close_help_window(); + +} // namespace gkeymapper + +#endif // GKEYMAPPER_HELP_H diff --git a/src/main.cpp b/src/main.cpp index 782ed6c..cc9bebd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,13 +9,18 @@ #include #include +#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 "settings.h" #include "singleinstance.h" +#include "trayicon.h" #ifndef PACKAGE #define PACKAGE "gkeymapper" @@ -32,19 +37,21 @@ using gkeymapper::kNumKeys; std::vector 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; +// 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() { @@ -52,47 +59,53 @@ static void refresh_footer() { return; } std::string text; - if (!daemonFooter.reachable) { + if (!daemonView.reachable) { text = _("Daemon: not reachable"); } else { text = _("Daemon: running"); text += " · "; - if (daemonFooter.device.empty()) { + 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"), daemonFooter.device.c_str()); + g_strdup_printf(_("Device: %s"), daemonView.device.c_str()); text += device; } - if (!daemonFooter.configOk) { + if (!daemonView.configOk) { text += " · "; text += _("Configuration error"); } - if (!daemonFooter.lastKey.empty()) { + if (!daemonView.lastKey.empty()) { text += " · "; - text += daemonFooter.lastKey; + 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) { - daemonFooter.reachable = status.reachable; - daemonFooter.configOk = status.configOk; - daemonFooter.device = status.device; + daemonView.reachable = status.reachable; + daemonView.configOk = status.configOk; + daemonView.version = status.version; + daemonView.device = status.device; + daemonView.configPath = status.configPath; if (!status.reachable) { - daemonFooter.lastKey.clear(); // stale once the daemon is gone + 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) { - g_autofree gchar* text = - g_strdup_printf(_("Last key: G%d → %s"), index + 1, action.c_str()); - daemonFooter.lastKey = text; + // 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) { @@ -230,6 +243,22 @@ static GtkWidget* make_icon_image(int 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")); @@ -256,6 +285,8 @@ static void show_about_dialog(GtkWindow* parent) { 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. @@ -339,6 +370,206 @@ static std::string find_asset(const std::string& name) { return local; } +// Size of the pixmap handed to the panel; the panel scales it to its own size. +constexpr int kTrayIconSize = 64; + +// 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; +} + +// 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& 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(width) * static_cast(height) * 4, 0); + for (int y = 0; y < height; ++y) { + const guchar* row = source + static_cast(y) * static_cast(stride); + for (int x = 0; x < width; ++x) { + unsigned char* target = + pixels.data() + (static_cast(y) * static_cast(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 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); +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(); + // 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); + 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) { GtkWidget* window = gtk_application_window_new(app); gtk_window_set_title(GTK_WINDOW(window), _("gKeyMapper – Logitech G910")); @@ -363,10 +594,21 @@ void build_ui(GtkApplication* app) { 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)); @@ -492,9 +734,11 @@ void build_ui(GtkApplication* app) { gtk_label_set_ellipsize(GTK_LABEL(daemonLabel), PANGO_ELLIPSIZE_END); gtk_box_append(GTK_BOX(footer), daemonLabel); + mainWindow = 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); gtk_window_present(GTK_WINDOW(window)); - - daemonClient.init(on_daemon_status, on_daemon_key); } static void on_activate(GtkApplication* app, gpointer /*user_data*/) { @@ -519,11 +763,34 @@ static void on_activate(GtkApplication* app, gpointer /*user_data*/) { 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[] = {"q", NULL}; + gtk_application_set_accels_for_action(app, "app.quit", quitAccels); + build_ui(app); + + // 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); + // 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 @@ -538,6 +805,14 @@ int main(int argc, char** argv) { 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); + // 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 diff --git a/src/mappings.cpp b/src/mappings.cpp index fd87309..c81d596 100644 --- a/src/mappings.cpp +++ b/src/mappings.cpp @@ -9,6 +9,10 @@ namespace gkeymapper { bool write_mappings(const std::string& path, const std::vector& rows) { + return save_mappings_file(path, collect_mappings(rows)); +} + +MappingSet collect_mappings(const std::vector& rows) { MappingSet set; set.keys.assign(kNumKeys, Mapping{}); @@ -28,7 +32,7 @@ bool write_mappings(const std::string& path, const std::vector& rows) { set.keys[row.index] = mapping; } - return save_mappings_file(path, set); + return set; } void read_mappings(const std::string& path, const std::vector& rows) { @@ -38,6 +42,10 @@ void read_mappings(const std::string& path, const std::vector& rows) { return; } + apply_mappings(set, rows); +} + +void apply_mappings(const MappingSet& set, const std::vector& rows) { for (const KeyRow& row : rows) { if (row.index < 0 || row.index >= static_cast(set.keys.size())) { continue; diff --git a/src/mappings.h b/src/mappings.h index 97a4d37..279f18b 100644 --- a/src/mappings.h +++ b/src/mappings.h @@ -6,9 +6,17 @@ #include #include "keyrow.h" +#include "mapping.h" namespace gkeymapper { +// The rows' current state, or a file's content put into the rows. Both are also +// what write/read use, so the widget <-> file mapping lives in one place; and a +// window that is rebuilt (language switch) carries the rows over this way +// without writing a file, so unsaved edits survive. +MappingSet collect_mappings(const std::vector& rows); +void apply_mappings(const MappingSet& set, const std::vector& rows); + // Collects the current widget state and writes it to path. bool write_mappings(const std::string& path, const std::vector& rows); diff --git a/src/optionsdialog.cpp b/src/optionsdialog.cpp new file mode 100644 index 0000000..8ed66bd --- /dev/null +++ b/src/optionsdialog.cpp @@ -0,0 +1,279 @@ +// gKeyMapper - the options window. +// +// Two things in one window: the GUI's own switches (currently the tray +// behaviour) and the daemon in detail - the footer says "running", this says +// what it runs, which keyboard it found and which file it reads. + +#include "optionsdialog.h" + +#include + +#include + +namespace gkeymapper { + +namespace { + +// A status line: dim name on the left, value on the right, both readable and +// copyable (the config path is worth copying). +void add_row(GtkGrid* grid, int row, const char* name, GtkWidget* value) { + GtkWidget* nameLabel = gtk_label_new(name); + gtk_widget_add_css_class(nameLabel, "dim-label"); + gtk_label_set_xalign(GTK_LABEL(nameLabel), 0.0f); + gtk_grid_attach(grid, nameLabel, 0, row, 1, 1); + + gtk_label_set_xalign(GTK_LABEL(value), 0.0f); + gtk_label_set_selectable(GTK_LABEL(value), TRUE); + gtk_label_set_ellipsize(GTK_LABEL(value), PANGO_ELLIPSIZE_MIDDLE); + gtk_label_set_max_width_chars(GTK_LABEL(value), 46); + gtk_grid_attach(grid, value, 1, row, 1, 1); +} + +GtkWidget* value_label() { + return gtk_label_new("-"); +} + +GtkWidget* section_label(const char* text) { + GtkWidget* label = gtk_label_new(nullptr); + g_autofree gchar* markup = g_strdup_printf("%s", text); + gtk_label_set_markup(GTK_LABEL(label), markup); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + return label; +} + +// Keeps a value readable when it is longer than the window (a config path). +void set_value(GtkWidget* label, const std::string& text) { + gtk_label_set_text(GTK_LABEL(label), text.c_str()); + gtk_widget_set_tooltip_text(label, text.c_str()); +} + +GtkWidget* hint_label(const char* text) { + GtkWidget* label = gtk_label_new(text); + gtk_widget_add_css_class(label, "dim-label"); + gtk_label_set_wrap(GTK_LABEL(label), TRUE); + gtk_label_set_max_width_chars(GTK_LABEL(label), 46); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + return label; +} + +// The languages the dropdown offers, in the order of its entries. The first one +// is empty: "let the session decide", which is what a fresh install does. The +// names are endonyms - they read the same in every language - so they are +// deliberately not translated; only the first entry is. +const char* const kLanguageCodes[] = {"", "de", "en", "es", "fr"}; +constexpr guint kNumLanguages = G_N_ELEMENTS(kLanguageCodes); + +} // namespace + +void OptionsDialog::show(GtkWindow* parent, const OptionsState& state, ToggleFn onTray, + ToggleFn onAutostart, LanguageFn onLanguage) { + onToggled_ = std::move(onTray); + onAutostart_ = std::move(onAutostart); + onLanguage_ = std::move(onLanguage); + trayAvailable_ = state.trayAvailable; + minimizeToTray_ = state.settings.minimizeToTray; + autostart_ = state.autostart; + language_ = state.settings.language; + view_ = state.view; + + if (window_ == nullptr) { + window_ = gtk_window_new(); + gtk_window_set_title(GTK_WINDOW(window_), _("Options")); + gtk_window_set_resizable(GTK_WINDOW(window_), FALSE); + gtk_window_set_icon_name(GTK_WINDOW(window_), "gkeymapper"); + if (parent != nullptr) { + gtk_window_set_transient_for(GTK_WINDOW(window_), parent); + } + + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_widget_set_margin_top(box, 18); + gtk_widget_set_margin_bottom(box, 18); + gtk_widget_set_margin_start(box, 18); + gtk_widget_set_margin_end(box, 18); + gtk_window_set_child(GTK_WINDOW(window_), box); + + gtk_box_append(GTK_BOX(box), section_label(_("Window"))); + GtkWidget* switchRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12); + GtkWidget* switchLabel = gtk_label_new(_("Minimize to tray")); + gtk_widget_set_hexpand(switchLabel, TRUE); + gtk_label_set_xalign(GTK_LABEL(switchLabel), 0.0f); + gtk_box_append(GTK_BOX(switchRow), switchLabel); + traySwitch_ = gtk_switch_new(); + gtk_widget_set_valign(traySwitch_, GTK_ALIGN_CENTER); + g_signal_connect(traySwitch_, "notify::active", G_CALLBACK(on_tray_switch), this); + gtk_box_append(GTK_BOX(switchRow), traySwitch_); + gtk_box_append(GTK_BOX(box), switchRow); + trayHint_ = hint_label(""); + gtk_box_append(GTK_BOX(box), trayHint_); + + GtkWidget* autostartRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12); + GtkWidget* autostartLabel = gtk_label_new(_("Start with the session")); + gtk_widget_set_hexpand(autostartLabel, TRUE); + gtk_label_set_xalign(GTK_LABEL(autostartLabel), 0.0f); + gtk_box_append(GTK_BOX(autostartRow), autostartLabel); + autostartSwitch_ = gtk_switch_new(); + gtk_widget_set_valign(autostartSwitch_, GTK_ALIGN_CENTER); + g_signal_connect(autostartSwitch_, "notify::active", G_CALLBACK(on_autostart_switch), + this); + gtk_box_append(GTK_BOX(autostartRow), autostartSwitch_); + gtk_box_append(GTK_BOX(box), autostartRow); + // Static text, so unlike the tray hint it needs no member: the switch is + // what changes. + GtkWidget* autostartHint = hint_label(_("Opens this window when you log in. Not needed " + "for the G keys - the daemon does the work and " + "is the only part that has to run.")); + gtk_box_append(GTK_BOX(box), autostartHint); + + gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(box), section_label(_("Language"))); + // GtkDropDown has no label of its own; the heading above it says what it + // is, and the accessible name says it again for screen readers. + const char* languageOptions[] = { _("System language"), "Deutsch", "English", "Español", + "Français", nullptr }; + languageDrop_ = gtk_drop_down_new_from_strings(languageOptions); + gtk_widget_set_halign(languageDrop_, GTK_ALIGN_START); + gtk_accessible_update_property(GTK_ACCESSIBLE(languageDrop_), + GTK_ACCESSIBLE_PROPERTY_LABEL, _("Language"), -1); + g_signal_connect(languageDrop_, "notify::selected", G_CALLBACK(on_language_changed), this); + gtk_box_append(GTK_BOX(box), languageDrop_); + gtk_box_append( + GTK_BOX(box), + hint_label(_("The interface switches right away, including the tray menu."))); + + gtk_box_append(GTK_BOX(box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + gtk_box_append(GTK_BOX(box), section_label(_("Daemon status"))); + + GtkWidget* grid = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(grid), 6); + gtk_grid_set_column_spacing(GTK_GRID(grid), 18); + gtk_box_append(GTK_BOX(box), grid); + + trayValue_ = value_label(); + stateValue_ = value_label(); + versionValue_ = value_label(); + deviceValue_ = value_label(); + configValue_ = value_label(); + lastKeyValue_ = value_label(); + add_row(GTK_GRID(grid), 0, _("Tray icon"), trayValue_); + add_row(GTK_GRID(grid), 1, _("State"), stateValue_); + add_row(GTK_GRID(grid), 2, _("Version"), versionValue_); + add_row(GTK_GRID(grid), 3, _("Device"), deviceValue_); + add_row(GTK_GRID(grid), 4, _("Configuration file"), configValue_); + add_row(GTK_GRID(grid), 5, _("Last key"), lastKeyValue_); + + GtkWidget* closeButton = gtk_button_new_with_label(_("Close window")); + gtk_widget_set_halign(closeButton, GTK_ALIGN_END); + gtk_widget_set_margin_top(closeButton, 6); + g_signal_connect_swapped(closeButton, "clicked", G_CALLBACK(gtk_window_destroy), window_); + gtk_box_append(GTK_BOX(box), closeButton); + + g_signal_connect(window_, "destroy", G_CALLBACK(on_destroy), this); + } + + apply(); + gtk_window_present(GTK_WINDOW(window_)); +} + +void OptionsDialog::update(const DaemonView& view) { + if (window_ == nullptr) { + return; + } + view_ = view; + apply(); +} + +void OptionsDialog::apply() { + filling_ = true; + gtk_switch_set_active(GTK_SWITCH(traySwitch_), minimizeToTray_); + gtk_widget_set_sensitive(traySwitch_, trayAvailable_); + gtk_switch_set_active(GTK_SWITCH(autostartSwitch_), autostart_); + guint languageIndex = 0; + for (guint index = 0; index < kNumLanguages; ++index) { + if (language_ == kLanguageCodes[index]) { + languageIndex = index; + } + } + gtk_drop_down_set_selected(GTK_DROP_DOWN(languageDrop_), languageIndex); + gtk_label_set_text( + GTK_LABEL(trayHint_), + trayAvailable_ + ? _("Closing or minimizing the window hides it instead of quitting. The tray icon " + "shows it again (Show) and quits the program (Close).") + : _("No tray is available on this desktop, so the window cannot be hidden.")); + filling_ = false; + + set_value(trayValue_, trayAvailable_ ? _("active") : _("not available")); + set_value(stateValue_, view_.reachable ? _("running") : _("not reachable")); + set_value(versionValue_, view_.version.empty() ? "-" : view_.version); + set_value(deviceValue_, view_.device.empty() ? _("not found") : view_.device); + std::string config = view_.configPath.empty() ? "-" : view_.configPath; + if (!view_.configOk) { + config += " · "; + config += _("Configuration error"); + } + set_value(configValue_, config); + set_value(lastKeyValue_, view_.lastKey.empty() ? "-" : view_.lastKey); +} + +void OptionsDialog::on_tray_switch(GObject* object, GParamSpec* /*spec*/, gpointer userData) { + OptionsDialog* self = static_cast(userData); + if (self->filling_) { + return; + } + // Kept here as well: a daemon update refills the window, and that must not + // undo what the user just chose. + self->minimizeToTray_ = gtk_switch_get_active(GTK_SWITCH(object)) != FALSE; + if (self->onToggled_) { + self->onToggled_(self->minimizeToTray_); + } +} + +void OptionsDialog::on_autostart_switch(GObject* object, GParamSpec* /*spec*/, gpointer userData) { + OptionsDialog* self = static_cast(userData); + if (self->filling_) { + return; + } + self->autostart_ = gtk_switch_get_active(GTK_SWITCH(object)) != FALSE; + if (self->onAutostart_) { + self->onAutostart_(self->autostart_); + } +} + +void OptionsDialog::on_language_changed(GObject* object, GParamSpec* /*spec*/, gpointer userData) { + OptionsDialog* self = static_cast(userData); + if (self->filling_) { + return; + } + const guint index = gtk_drop_down_get_selected(GTK_DROP_DOWN(object)); + if (index >= kNumLanguages) { + return; + } + self->language_ = kLanguageCodes[index]; + if (self->onLanguage_) { + self->onLanguage_(self->language_); + } +} + +void OptionsDialog::close() { + if (window_ != nullptr) { + gtk_window_destroy(GTK_WINDOW(window_)); + } +} + +void OptionsDialog::on_destroy(GtkWidget* /*widget*/, gpointer userData) { + OptionsDialog* self = static_cast(userData); + // The widgets go with the window; drop the pointers so show() builds new ones. + self->window_ = nullptr; + self->traySwitch_ = nullptr; + self->trayHint_ = nullptr; + self->autostartSwitch_ = nullptr; + self->languageDrop_ = nullptr; + self->trayValue_ = nullptr; + self->stateValue_ = nullptr; + self->versionValue_ = nullptr; + self->deviceValue_ = nullptr; + self->configValue_ = nullptr; + self->lastKeyValue_ = nullptr; +} + +} // namespace gkeymapper diff --git a/src/optionsdialog.h b/src/optionsdialog.h new file mode 100644 index 0000000..feb3757 --- /dev/null +++ b/src/optionsdialog.h @@ -0,0 +1,83 @@ +// gKeyMapper - the options window. +#ifndef GKEYMAPPER_OPTIONSDIALOG_H +#define GKEYMAPPER_OPTIONSDIALOG_H + +#include + +#include + +#include "daemonclient.h" +#include "settings.h" + +namespace gkeymapper { + +// Everything the window shows when it opens. The switches come from different +// places - the tray is a bus name, autostart is a file, the rest is the GUI's own +// config - so they are collected here instead of being passed one by one. +struct OptionsState { + bool trayAvailable = false; + bool autostart = false; + GuiSettings settings; + DaemonView view; +}; + +// The options window: the GUI's own switches plus the full daemon status. It is +// deliberately not modal - the footer keeps updating while it is open, and a +// switch takes effect immediately. +class OptionsDialog { +public: + using ToggleFn = std::function; + // Called with the chosen language code ("" = follow the session). + using LanguageFn = std::function; + + // Creates the window on first use, afterwards it is re-presented. + void show(GtkWindow* parent, const OptionsState& state, ToggleFn onTray, + ToggleFn onAutostart, LanguageFn onLanguage); + + // While the window is open: keep the status lines in step with the daemon. + void update(const DaemonView& view); + + bool is_open() const { return window_ != nullptr; } + + // Closes it, e.g. before the window is rebuilt for another language. + void close(); + +private: + static void on_tray_switch(GObject* object, GParamSpec* spec, gpointer userData); + static void on_autostart_switch(GObject* object, GParamSpec* spec, gpointer userData); + static void on_language_changed(GObject* object, GParamSpec* spec, gpointer userData); + static void on_destroy(GtkWidget* widget, gpointer userData); + + // Fills the widgets from the state the window was last told about. + void apply(); + + // Everything the window shows, so a status update does not have to carry the + // switches along. + bool trayAvailable_ = false; + bool minimizeToTray_ = false; + bool autostart_ = false; + std::string language_; + DaemonView view_; + + GtkWidget* window_ = nullptr; + GtkWidget* traySwitch_ = nullptr; + GtkWidget* trayHint_ = nullptr; + GtkWidget* autostartSwitch_ = nullptr; + GtkWidget* languageDrop_ = nullptr; + GtkWidget* trayValue_ = nullptr; + GtkWidget* stateValue_ = nullptr; + GtkWidget* versionValue_ = nullptr; + GtkWidget* deviceValue_ = nullptr; + GtkWidget* configValue_ = nullptr; + GtkWidget* lastKeyValue_ = nullptr; + // Set while the window fills its widgets, so programmatic changes do not look + // like the user flipping a switch. + bool filling_ = false; + ToggleFn onToggled_; + ToggleFn onAutostart_; + LanguageFn onLanguage_; +}; + +} // namespace gkeymapper + +#endif // GKEYMAPPER_OPTIONSDIALOG_H diff --git a/src/settings.cpp b/src/settings.cpp new file mode 100644 index 0000000..3190dc8 --- /dev/null +++ b/src/settings.cpp @@ -0,0 +1,95 @@ +// gKeyMapper - GUI options that are not part of the mapping format. + +#include "settings.h" + +#include +#include + +#include + +namespace gkeymapper { + +namespace { +// Same shape as the mapping file next to it: a group and one key per option. +const char* kGroup = "gui"; +const char* kMinimizeToTray = "minimize-to-tray"; +const char* kLanguage = "language"; + +// What the session had in LANGUAGE before this program touched it, so choosing +// "follow the session" can put it back exactly. +std::string sessionLanguage; +bool sessionLanguageTaken = false; +} // namespace + +std::string default_gui_config_path() { + const char* home = std::getenv("HOME"); + return std::string(home ? home : "") + "/.config/gKeyMapper/gui.conf"; +} + +GuiSettings load_gui_settings() { + GuiSettings settings; + GKeyFile* file = g_key_file_new(); + GError* error = nullptr; + // A missing file is the normal first start, not an error. + if (g_key_file_load_from_file(file, default_gui_config_path().c_str(), G_KEY_FILE_NONE, + &error)) { + GError* itemError = nullptr; + const gboolean value = g_key_file_get_boolean(file, kGroup, kMinimizeToTray, &itemError); + if (itemError == nullptr) { + settings.minimizeToTray = value != FALSE; + } + g_clear_error(&itemError); + + g_autofree gchar* language = g_key_file_get_string(file, kGroup, kLanguage, nullptr); + if (language != nullptr) { + settings.language = language; + } + } + g_clear_error(&error); + g_key_file_unref(file); + return settings; +} + +void save_gui_settings(const GuiSettings& settings) { + const std::string path = default_gui_config_path(); + g_autofree gchar* directory = g_path_get_dirname(path.c_str()); + g_mkdir_with_parents(directory, 0700); + + GKeyFile* file = g_key_file_new(); + g_key_file_set_boolean(file, kGroup, kMinimizeToTray, settings.minimizeToTray); + g_key_file_set_string(file, kGroup, kLanguage, settings.language.c_str()); + GError* error = nullptr; + if (!g_key_file_save_to_file(file, path.c_str(), &error)) { + g_printerr("gkeymapper: could not write %s: %s\n", path.c_str(), + error != nullptr ? error->message : "unknown error"); + g_clear_error(&error); + } + g_key_file_unref(file); +} + +void apply_gui_language(const std::string& language) { + if (!sessionLanguageTaken) { + const char* current = g_getenv("LANGUAGE"); + sessionLanguage = current != nullptr ? current : ""; + sessionLanguageTaken = true; + } + + // gettext checks LANGUAGE before the locale, so it is the one knob to turn; + // the shipped catalogs are small and the identity catalog for English means + // an explicit "en" works on a German desktop too. + if (language.empty()) { + if (sessionLanguage.empty()) { + g_unsetenv("LANGUAGE"); + } else { + g_setenv("LANGUAGE", sessionLanguage.c_str(), TRUE); + } + } else { + g_setenv("LANGUAGE", language.c_str(), TRUE); + } + + // setlocale re-reads the environment and bumps glibc's catalog counter, so + // the next translated string comes out of the new catalog. + setlocale(LC_ALL, ""); +} + +} // namespace gkeymapper diff --git a/src/settings.h b/src/settings.h new file mode 100644 index 0000000..f3cb1b8 --- /dev/null +++ b/src/settings.h @@ -0,0 +1,33 @@ +// gKeyMapper - GUI options that are not part of the mapping format. +#ifndef GKEYMAPPER_SETTINGS_H +#define GKEYMAPPER_SETTINGS_H + +#include + +namespace gkeymapper { + +// What the GUI remembers between runs. It lives in `gui.conf` next to +// `mappings.conf`, so a mapping file can be exported, imported or copied around +// without dragging a window preference with it. +struct GuiSettings { + // Closing or minimizing the window hides it to the tray instead of quitting. + bool minimizeToTray = false; + // Language of the interface, as a two-letter code from the shipped catalogs + // ("de", "en", "es", "fr"). Empty means "follow the session" - the usual + // case, where LANG/LC_MESSAGES decide. + std::string language; +}; + +std::string default_gui_config_path(); +GuiSettings load_gui_settings(); +void save_gui_settings(const GuiSettings& settings); + +// Hands the language to gettext and re-reads the locale. Must run before the +// first translated string is fetched; an empty `language` restores what the +// session had. Already built widgets keep their texts, so the caller rebuilds +// them (see the language switch in main.cpp). +void apply_gui_language(const std::string& language); + +} // namespace gkeymapper + +#endif // GKEYMAPPER_SETTINGS_H diff --git a/src/trayicon.cpp b/src/trayicon.cpp new file mode 100644 index 0000000..9e3904f --- /dev/null +++ b/src/trayicon.cpp @@ -0,0 +1,635 @@ +// gKeyMapper - the tray icon, spoken as a StatusNotifierItem. +// +// Two interfaces are exported: the item itself (what the panel shows and where a +// click lands) and its menu (com.canonical.dbusmenu, the shape every tray menu on +// Linux has). Nothing here knows about GTK - it is plain GIO, so the icon works +// the same on any desktop that hosts the protocol. + +#include "trayicon.h" + +#include // getpid + +#include +#include + +namespace gkeymapper { + +namespace { + +const char* kItemPath = "/StatusNotifierItem"; +const char* kMenuPath = "/MenuBar"; +// The two interfaces, spelled out: they are what the panel talks to. +const char* kItemInterface = "org.kde.StatusNotifierItem"; +const char* kMenuInterface = "com.canonical.dbusmenu"; +const char* kWatcherName = "org.kde.StatusNotifierWatcher"; +// Some desktops publish the watcher only under this older alias (libxapp owns +// both names; the interface is the standard one either way). +const char* kWatcherNameX = "org.x.StatusNotifierWatcher"; +const char* kWatcherPath = "/StatusNotifierWatcher"; +const char* kWatcherInterface = "org.kde.StatusNotifierWatcher"; + +// Menu ids. 0 is the root the panel asks for; it never is an entry itself. +constexpr int kRootId = 0; +constexpr int kShowId = 1; +constexpr int kCloseId = 2; + +// Item: what the icon looks like and where the menu is. The properties the panel +// asks for are the ones answered in on_item_get_property(). +const char* kItemXml = + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + ""; + +// Menu: only what a host actually calls. The two entries are added in +// layout(); nothing about them changes unless the language does, and for that +// refresh_labels() bumps the revision and says so. +const char* kMenuXml = + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + ""; + +// What a menu entry says. The panel draws them, so they are translated here. +const char* entry_label(int id) { + if (id == kShowId) { + return _("Show"); + } + if (id == kCloseId) { + return _("Close"); + } + return ""; +} + +gboolean run_later(gpointer data) { + auto* action = static_cast*>(data); + (*action)(); + delete action; + return G_SOURCE_REMOVE; +} + +// Answers a D-Bus method in this loop turn and runs the handler afterwards: a +// quit must not close the connection before the reply is on the wire. +void call_later(TrayIcon::Callback callback) { + if (callback) { + g_idle_add(run_later, new TrayIcon::Callback(std::move(callback))); + } +} + +void reply_empty(GDBusMethodInvocation* invocation) { + g_dbus_method_invocation_return_value(invocation, nullptr); +} + +GVariant* empty_string_array() { + GVariantBuilder builder; + g_variant_builder_init(&builder, G_VARIANT_TYPE("as")); + return g_variant_builder_end(&builder); +} + +GVariant* empty_int_array() { + GVariantBuilder builder; + g_variant_builder_init(&builder, G_VARIANT_TYPE("ai")); + return g_variant_builder_end(&builder); +} + +} // namespace + +TrayIcon::~TrayIcon() { + if (watcherId_ != 0) { + g_bus_unwatch_name(watcherId_); + } + if (watcherIdX_ != 0) { + g_bus_unwatch_name(watcherIdX_); + } + if (connection_ != nullptr) { + if (itemRegistration_ != 0) { + g_dbus_connection_unregister_object(connection_, itemRegistration_); + } + if (menuRegistration_ != 0) { + g_dbus_connection_unregister_object(connection_, menuRegistration_); + } + } + if (ownerId_ != 0) { + g_bus_unown_name(ownerId_); + } +} + +void TrayIcon::start(std::vector icon, int width, int height, Callback onShow, + Callback onQuit) { + icon_ = std::move(icon); + iconWidth_ = width; + iconHeight_ = height; + onShow_ = std::move(onShow); + onQuit_ = std::move(onQuit); + + // The name is what the panel reports to its user, so it says what the icon + // is; the pid keeps two sessions apart. + g_autofree gchar* name = g_strdup_printf("org.kde.StatusNotifierItem-%d-1", getpid()); + serviceName_ = name; + ownerId_ = g_bus_own_name(G_BUS_TYPE_SESSION, serviceName_.c_str(), + G_BUS_NAME_OWNER_FLAGS_NONE, on_bus_acquired, nullptr, on_name_lost, + this, nullptr); + // Watch both watcher names: the panel's own one and the older alias. Each + // callback fires right away when the name is already owned. + watcherId_ = g_bus_watch_name(G_BUS_TYPE_SESSION, kWatcherName, + G_BUS_NAME_WATCHER_FLAGS_NONE, on_watcher_appeared, + on_watcher_vanished, this, nullptr); + watcherIdX_ = g_bus_watch_name(G_BUS_TYPE_SESSION, kWatcherNameX, + G_BUS_NAME_WATCHER_FLAGS_NONE, on_watcher_appeared, + on_watcher_vanished, this, nullptr); +} + +void TrayIcon::on_bus_acquired(GDBusConnection* connection, const gchar* /*name*/, + gpointer userData) { + static_cast(userData)->export_objects(connection); +} + +void TrayIcon::on_name_lost(GDBusConnection* /*connection*/, const gchar* name, + gpointer /*userData*/) { + // Being unique (see singleinstance) this should not happen; if it does, the + // panel cannot reach us and the icon is simply absent. + g_printerr("gkeymapper: tray icon could not take %s\n", name); +} + +void TrayIcon::on_watcher_appeared(GDBusConnection* /*connection*/, const gchar* name, + const gchar* /*nameOwner*/, gpointer userData) { + static_cast(userData)->set_watcher_available(name, true); +} + +void TrayIcon::on_watcher_vanished(GDBusConnection* /*connection*/, const gchar* name, + gpointer userData) { + static_cast(userData)->set_watcher_available(name, false); +} + +void TrayIcon::set_watcher_available(const char* name, bool available) { + if (g_strcmp0(name, kWatcherNameX) == 0) { + watcherAlias_ = available; + } else { + watcher_ = available; + } + + if (!available) { + // The panel (or its watcher) went away: the icon is not on screen any + // more, and hiding the window would strand the user. + if (registeredWith_ == name) { + registered_ = false; + registeredWith_.clear(); + } + return; + } + register_with_watcher(); +} + +void TrayIcon::export_objects(GDBusConnection* connection) { + connection_ = connection; + + GError* error = nullptr; + g_autoptr(GDBusNodeInfo) itemNode = g_dbus_node_info_new_for_xml(kItemXml, &error); + if (itemNode == nullptr) { + g_printerr("gkeymapper: tray item: %s\n", error != nullptr ? error->message : "?"); + g_clear_error(&error); + return; + } + static const GDBusInterfaceVTable itemVtable = { + on_item_method_call, on_item_get_property, nullptr, + {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}}; + itemRegistration_ = g_dbus_connection_register_object( + connection, kItemPath, itemNode->interfaces[0], &itemVtable, this, nullptr, &error); + if (itemRegistration_ == 0) { + g_printerr("gkeymapper: tray item: %s\n", error != nullptr ? error->message : "?"); + g_clear_error(&error); + return; + } + + g_autoptr(GDBusNodeInfo) menuNode = g_dbus_node_info_new_for_xml(kMenuXml, &error); + if (menuNode == nullptr) { + g_printerr("gkeymapper: tray menu: %s\n", error != nullptr ? error->message : "?"); + g_clear_error(&error); + return; + } + static const GDBusInterfaceVTable menuVtable = { + on_menu_method_call, on_menu_get_property, nullptr, + {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}}; + menuRegistration_ = g_dbus_connection_register_object( + connection, kMenuPath, menuNode->interfaces[0], &menuVtable, this, nullptr, &error); + if (menuRegistration_ == 0) { + g_printerr("gkeymapper: tray menu: %s\n", error != nullptr ? error->message : "?"); + g_clear_error(&error); + } +} + +void TrayIcon::register_with_watcher() { + if (connection_ == nullptr || serviceName_.empty() || registering_) { + return; + } + // Both names are normally owned by the same process, so preferring the + // standard one keeps it at exactly one registration (and one icon). + // Already registered there, and the name we used is still on the bus: leave it + // at that, so a second watcher name does not add the item twice. + if (registered_ && ((registeredWith_ == kWatcherName && watcher_) || + (registeredWith_ == kWatcherNameX && watcherAlias_))) { + return; + } + const char* target = nullptr; + if (watcher_) { + target = kWatcherName; + } else if (watcherAlias_) { + target = kWatcherNameX; + } + if (target == nullptr) { + return; + } + + // The watcher is told our bus name and reads the properties from there. The + // item path is the fixed one, so the name is enough. + registering_ = true; + registeringWith_ = target; + g_dbus_connection_call(connection_, target, kWatcherPath, kWatcherInterface, + "RegisterStatusNotifierItem", g_variant_new("(s)", serviceName_.c_str()), + nullptr, G_DBUS_CALL_FLAGS_NONE, -1, nullptr, on_register_reply, this); +} + +void TrayIcon::on_register_reply(GObject* source, GAsyncResult* result, gpointer userData) { + TrayIcon* self = static_cast(userData); + self->registering_ = false; + + GError* error = nullptr; + g_autoptr(GVariant) reply = + g_dbus_connection_call_finish(G_DBUS_CONNECTION(source), result, &error); + if (reply == nullptr) { + // No panel, or it went away while we asked: the icon is simply absent. + g_printerr("gkeymapper: no tray icon: %s\n", error != nullptr ? error->message : "?"); + g_clear_error(&error); + self->registered_ = false; + self->registeredWith_.clear(); + return; + } + self->registered_ = true; + self->registeredWith_ = self->registeringWith_; + + // A watcher may have appeared while the call was in flight (the panel + // restarting, for instance); take the other name into account once more. + self->register_with_watcher(); +} + +GVariant* TrayIcon::icon_pixmaps() const { + // a(iiay): the protocol allows several sizes, one is enough here. The bytes + // are ARGB32 in network byte order, i.e. A first. + GVariantBuilder builder; + g_variant_builder_init(&builder, G_VARIANT_TYPE("a(iiay)")); + if (!icon_.empty() && iconWidth_ > 0 && iconHeight_ > 0) { + GVariant* pixels = + g_variant_new_fixed_array(G_VARIANT_TYPE_BYTE, icon_.data(), icon_.size(), 1); + g_variant_builder_add(&builder, "(ii@ay)", iconWidth_, iconHeight_, pixels); + } + return g_variant_builder_end(&builder); +} + +GVariant* TrayIcon::tooltip() const { + // (icon name, pixmaps, title, text): the panel shows title and text. + return g_variant_new("(s@a(iiay)ss)", "", icon_pixmaps(), _("gKeyMapper"), + _("G-Key mapper for the Logitech G910 (GTK4).")); +} + +GVariant* TrayIcon::layout(int parentId, int depth) const { + return g_variant_new("(u@(ia{sv}av))", menuRevision_, node(parentId, depth)); +} + +void TrayIcon::refresh_labels() { + if (connection_ == nullptr) { + return; + } + ++menuRevision_; + + // The watcher mirrors the layout when the item registers and asks again when + // it hears LayoutUpdated; the tooltip is plain text as well. + GError* error = nullptr; + g_dbus_connection_emit_signal(connection_, nullptr, kMenuPath, kMenuInterface, "LayoutUpdated", + g_variant_new("(ui)", menuRevision_, kRootId), &error); + if (error != nullptr) { + g_printerr("gkeymapper: could not announce the new menu labels: %s\n", error->message); + g_clear_error(&error); + } + g_dbus_connection_emit_signal(connection_, nullptr, kItemPath, kItemInterface, "NewToolTip", + nullptr, &error); + if (error != nullptr) { + g_printerr("gkeymapper: could not announce the new tooltip: %s\n", error->message); + g_clear_error(&error); + } +} + +// One node of the menu tree: (id, properties, children). The children are again +// full nodes - the panel walks them with libdbusmenu, and a bare id in there makes +// that parser abort. `depth` 0 means "no children below this one". +GVariant* TrayIcon::node(int id, int depth) const { + GVariantBuilder properties; + g_variant_builder_init(&properties, G_VARIANT_TYPE("a{sv}")); + GVariantBuilder children; + g_variant_builder_init(&children, G_VARIANT_TYPE("av")); + + if (id == kRootId) { + // The root is not shown itself; "children-display" is what tells the panel + // to pop the entries up as a submenu. + g_variant_builder_add(&properties, "{sv}", "children-display", + g_variant_new_string("submenu")); + if (depth != 0) { + g_variant_builder_add(&children, "v", node(kShowId, depth - 1)); + g_variant_builder_add(&children, "v", node(kCloseId, depth - 1)); + } + } else { + g_variant_builder_add(&properties, "{sv}", "label", + g_variant_new_string(entry_label(id))); + g_variant_builder_add(&properties, "{sv}", "enabled", g_variant_new_boolean(TRUE)); + g_variant_builder_add(&properties, "{sv}", "visible", g_variant_new_boolean(TRUE)); + } + + return g_variant_new("(i@a{sv}@av)", id, g_variant_builder_end(&properties), + g_variant_builder_end(&children)); +} + +GVariant* TrayIcon::group_properties(GVariant* ids) const { + GVariantBuilder result; + g_variant_builder_init(&result, G_VARIANT_TYPE("a(ia{sv})")); + + const gsize count = g_variant_n_children(ids); + const int first = count == 0 ? kRootId : 0; + const int last = count == 0 ? kCloseId : static_cast(count) - 1; + for (int index = first; index <= last; ++index) { + int id = index; + if (count != 0) { + g_variant_get_child(ids, index, "i", &id); + } + GVariantBuilder properties; + g_variant_builder_init(&properties, G_VARIANT_TYPE("a{sv}")); + if (id == kRootId) { + g_variant_builder_add(&properties, "{sv}", "children-display", + g_variant_new_string("submenu")); + } else { + g_variant_builder_add(&properties, "{sv}", "label", + g_variant_new_string(entry_label(id))); + g_variant_builder_add(&properties, "{sv}", "enabled", g_variant_new_boolean(TRUE)); + g_variant_builder_add(&properties, "{sv}", "visible", g_variant_new_boolean(TRUE)); + } + g_variant_builder_add(&result, "(i@a{sv})", id, g_variant_builder_end(&properties)); + } + return g_variant_builder_end(&result); +} + +GVariant* TrayIcon::menu_property(int id, const char* name) const { + const std::string property = name != nullptr ? name : ""; + if (property == "label") { + return g_variant_new_string(entry_label(id)); + } + if (property == "enabled" || property == "visible") { + return g_variant_new_boolean(TRUE); + } + if (property == "children-display") { + return g_variant_new_string(id == kRootId ? "submenu" : ""); + } + return nullptr; +} + +void TrayIcon::on_item_method_call(GDBusConnection* /*connection*/, const gchar* /*sender*/, + const gchar* /*objectPath*/, const gchar* /*interfaceName*/, + const gchar* methodName, GVariant* /*parameters*/, + GDBusMethodInvocation* invocation, gpointer userData) { + TrayIcon* self = static_cast(userData); + const std::string method = methodName; + if (method == "Activate") { + // A click on the icon: bring the window back. + call_later(self->onShow_); + } + // SecondaryActivate, ContextMenu and Scroll need no answer of their own: the + // panel pops the menu itself. + reply_empty(invocation); +} + +GVariant* TrayIcon::on_item_get_property(GDBusConnection* /*connection*/, const gchar* /*sender*/, + const gchar* /*objectPath*/, const gchar* /*interfaceName*/, + const gchar* propertyName, GError** error, + gpointer userData) { + TrayIcon* self = static_cast(userData); + const std::string property = propertyName; + if (property == "Category") { + return g_variant_new_string("ApplicationStatus"); + } + if (property == "Id") { + return g_variant_new_string("gkeymapper"); + } + if (property == "Title") { + return g_variant_new_string(_("gKeyMapper")); + } + if (property == "Status") { + // Always "Active": the icon must not disappear when the window is hidden. + return g_variant_new_string("Active"); + } + if (property == "IconName" || property == "OverlayIconName" || + property == "AttentionIconName") { + return g_variant_new_string(""); + } + if (property == "IconPixmap") { + return self->icon_pixmaps(); + } + if (property == "ToolTip") { + return self->tooltip(); + } + if (property == "ItemIsMenu") { + return g_variant_new_boolean(FALSE); + } + if (property == "Menu") { + return g_variant_new_object_path(kMenuPath); + } + g_set_error(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_PROPERTY, "unknown property %s", + propertyName); + return nullptr; +} + +void TrayIcon::handle_event(int id, const char* eventId) { + if (g_strcmp0(eventId, "clicked") != 0) { + return; + } + if (id == kShowId) { + call_later(onShow_); + } else if (id == kCloseId) { + call_later(onQuit_); + } +} + +void TrayIcon::on_menu_method_call(GDBusConnection* /*connection*/, const gchar* /*sender*/, + const gchar* /*objectPath*/, const gchar* /*interfaceName*/, + const gchar* methodName, GVariant* parameters, + GDBusMethodInvocation* invocation, gpointer userData) { + TrayIcon* self = static_cast(userData); + const std::string method = methodName; + + if (method == "GetLayout") { + gint32 parentId = kRootId; + gint32 depth = -1; // -1: as deep as it goes + g_autoptr(GVariant) propertyNames = nullptr; + g_variant_get(parameters, "(ii@as)", &parentId, &depth, &propertyNames); + g_dbus_method_invocation_return_value(invocation, self->layout(parentId, depth)); + return; + } + if (method == "GetGroupProperties") { + g_autoptr(GVariant) ids = nullptr; + g_autoptr(GVariant) propertyNames = nullptr; + g_variant_get(parameters, "(@ai@as)", &ids, &propertyNames); + GVariant* properties = self->group_properties(ids); + g_dbus_method_invocation_return_value(invocation, g_variant_new("(@a(ia{sv}))", properties)); + return; + } + if (method == "GetProperty") { + gint32 id = kRootId; + const gchar* name = nullptr; + g_variant_get(parameters, "(i&s)", &id, &name); + GVariant* value = self->menu_property(id, name); + if (value == nullptr) { + g_dbus_method_invocation_return_error(invocation, G_DBUS_ERROR, + G_DBUS_ERROR_UNKNOWN_PROPERTY, + "unknown property %s", name != nullptr ? name : ""); + return; + } + g_dbus_method_invocation_return_value(invocation, g_variant_new("(v)", value)); + return; + } + if (method == "Event") { + gint32 id = kRootId; + const gchar* eventId = nullptr; + g_variant_get(parameters, "(i&svu)", &id, &eventId, nullptr, nullptr); + self->handle_event(id, eventId); + reply_empty(invocation); + return; + } + if (method == "EventGroup") { + // A batch of clicks in one call. The panel's watcher (libdbusmenu) sends + // its events this way, so this is the path a real click takes. + g_autoptr(GVariant) events = nullptr; + g_variant_get(parameters, "(@a(isvu))", &events); + const gsize count = g_variant_n_children(events); + for (gsize index = 0; index < count; ++index) { + gint32 id = kRootId; + const gchar* eventId = nullptr; + g_autoptr(GVariant) data = nullptr; + g_variant_get_child(events, index, "(i&s@vu)", &id, &eventId, &data, nullptr); + self->handle_event(id, eventId); + } + g_dbus_method_invocation_return_value(invocation, + g_variant_new("(@ai)", empty_int_array())); + return; + } + if (method == "AboutToShow") { + // Nothing changes between two openings, so nothing needs an update. + g_dbus_method_invocation_return_value(invocation, g_variant_new("(b)", FALSE)); + return; + } + if (method == "AboutToShowGroup") { + g_dbus_method_invocation_return_value( + invocation, g_variant_new("(@ai@ai)", empty_int_array(), empty_int_array())); + return; + } + reply_empty(invocation); +} + +GVariant* TrayIcon::on_menu_get_property(GDBusConnection* /*connection*/, const gchar* /*sender*/, + const gchar* /*objectPath*/, const gchar* /*interfaceName*/, + const gchar* propertyName, GError** error, + gpointer /*userData*/) { + const std::string property = propertyName; + if (property == "Version") { + return g_variant_new_uint32(3); + } + if (property == "TextDirection") { + return g_variant_new_string("ltr"); + } + if (property == "Status") { + return g_variant_new_string("normal"); + } + if (property == "IconThemePath") { + return empty_string_array(); + } + g_set_error(error, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_PROPERTY, "unknown property %s", + propertyName); + return nullptr; +} + +} // namespace gkeymapper diff --git a/src/trayicon.h b/src/trayicon.h new file mode 100644 index 0000000..e617499 --- /dev/null +++ b/src/trayicon.h @@ -0,0 +1,109 @@ +// gKeyMapper - the tray icon, spoken as a StatusNotifierItem. +#ifndef GKEYMAPPER_TRAYICON_H +#define GKEYMAPPER_TRAYICON_H + +#include +#include +#include + +#include + +namespace gkeymapper { + +// The tray icon. GTK4 has no tray widget (GtkStatusIcon was GTK3 and is gone), so +// the icon is exported on the session bus as a StatusNotifierItem plus a +// com.canonical.dbusmenu menu - exactly the protocol libxapp and libappindicator +// wrap, only without their GTK3 dependency (a GTK4 process must not load GTK3). +// The panel hosts it: Cinnamon's xapp-status applet, KDE, Unity, or the GNOME +// AppIndicator extension. +// +// The panel is a different process, so the icon travels as a pixmap: it cannot +// read the icons compiled into this binary. +class TrayIcon { +public: + using Callback = std::function; + + TrayIcon() = default; + ~TrayIcon(); + TrayIcon(const TrayIcon&) = delete; + TrayIcon& operator=(const TrayIcon&) = delete; + + // Owns the item's bus name, exports the item and its menu and registers with + // the panel's watcher - and again whenever the panel restarts (the watcher + // owns the icon list, a restart loses it). `icon` is ARGB32 in network byte + // order, which is the layout the protocol asks for. + void start(std::vector icon, int width, int height, Callback onShow, + Callback onQuit); + + // True once a panel hosts the icon, i.e. the icon is really on screen. Only + // then hiding the window is safe: without a tray there is no way back. + bool ready() const { return registered_; } + + // The panel mirrors the menu once, so it has to be told when the texts in it + // changed - a language switch. Nothing else about the icon changes. + void refresh_labels(); +private: + static void on_bus_acquired(GDBusConnection* connection, const gchar* name, gpointer userData); + static void on_name_lost(GDBusConnection* connection, const gchar* name, gpointer userData); + static void on_watcher_appeared(GDBusConnection* connection, const gchar* name, + const gchar* nameOwner, gpointer userData); + static void on_watcher_vanished(GDBusConnection* connection, const gchar* name, + gpointer userData); + static void on_register_reply(GObject* source, GAsyncResult* result, gpointer userData); + + static void on_item_method_call(GDBusConnection* connection, const gchar* sender, + const gchar* objectPath, const gchar* interfaceName, + const gchar* methodName, GVariant* parameters, + GDBusMethodInvocation* invocation, gpointer userData); + static GVariant* on_item_get_property(GDBusConnection* connection, const gchar* sender, + const gchar* objectPath, const gchar* interfaceName, + const gchar* propertyName, GError** error, + gpointer userData); + static void on_menu_method_call(GDBusConnection* connection, const gchar* sender, + const gchar* objectPath, const gchar* interfaceName, + const gchar* methodName, GVariant* parameters, + GDBusMethodInvocation* invocation, gpointer userData); + static GVariant* on_menu_get_property(GDBusConnection* connection, const gchar* sender, + const gchar* objectPath, const gchar* interfaceName, + const gchar* propertyName, GError** error, + gpointer userData); + + void export_objects(GDBusConnection* connection); + // Runs what a click on a menu entry means ("clicked" on Show or Close). + void handle_event(int id, const char* eventId); + // Notes which of the two watcher names is on the bus and registers there. + void set_watcher_available(const char* name, bool available); + void register_with_watcher(); + GVariant* icon_pixmaps() const; + GVariant* tooltip() const; + GVariant* layout(int parentId, int depth) const; + GVariant* node(int id, int depth) const; + GVariant* group_properties(GVariant* ids) const; + GVariant* menu_property(int id, const char* name) const; + + GDBusConnection* connection_ = nullptr; + guint itemRegistration_ = 0; + guint menuRegistration_ = 0; + guint ownerId_ = 0; + guint watcherId_ = 0; + guint watcherIdX_ = 0; + std::string serviceName_; + std::vector icon_; + int iconWidth_ = 0; + int iconHeight_ = 0; + bool watcher_ = false; + bool watcherAlias_ = false; + bool registering_ = false; + bool registered_ = false; + const char* registeringWith_ = nullptr; + std::string registeredWith_; + // Bumped whenever the menu texts change; the panel compares it to know that + // what it mirrored is stale. + guint menuRevision_ = 1; + Callback onShow_; + Callback onQuit_; +}; + +} // namespace gkeymapper + +#endif // GKEYMAPPER_TRAYICON_H