Autostart option added

This commit is contained in:
2026-10-18 18:26:06 +02:00
parent 74503c27dd
commit 95f101c0c9
22 changed files with 2987 additions and 80 deletions

138
AGENTS.md
View File

@@ -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-<pid>-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-<pid>-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 <id>`) 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).

View File

@@ -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

View File

@@ -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/

176
po/de.po
View File

@@ -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 ""
"<b>The nine rows</b> – one per G key. Choose the type, then enter or capture "
"the target and press <i>Save mappings</i>."
msgstr ""
"<b>Die neun Zeilen</b> – eine pro G-Taste. Typ wählen, Ziel eingeben oder "
"aufnehmen und <i>Mappings speichern</i> drücken."
msgid "<b>Key</b> – presses the captured sequence once, e.g. ctrl+c."
msgstr "<b>Taste</b> – drückt die aufgenommene Sequenz einmal, z. B. ctrl+c."
msgid ""
"<b>Key (hold)</b> – 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 ""
"<b>Taste (halten)</b> – 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 ""
"<b>Command</b> – runs a program or a command line, e.g. /usr/bin/firefox "
"--new-window."
msgstr ""
"<b>Befehl</b> – führt ein Programm oder eine Kommandozeile aus, z. B. "
"/usr/bin/firefox --new-window."
msgid "<b>Disabled</b> – the key does nothing."
msgstr "<b>Deaktiviert</b> – die Taste tut nichts."
msgid ""
"<b>Capture</b> – records the keys you press next: press them, then "
"<i>Done</i>. Modifiers are kept (shift, altgr, …) and the names are layout "
"independent, so ctrl+z means the key that types z on your layout."
msgstr ""
"<b>Aufnehmen</b> – zeichnet die nächsten Tastenanschläge auf: drücken, dann "
"<i>Fertig</i>. Modifikatoren bleiben erhalten (shift, altgr, …) und die "
"Namen sind layoutunabhängig, ctrl+z bedeutet also die Taste, die auf deinem "
"Layout z tippt."
msgid ""
"<b>Save mappings</b> – writes ~/.config/gKeyMapper/mappings.conf and makes "
"the daemon reload it. The menu entries <i>Export configuration...</i> and "
"<i>Import configuration...</i> do the same with a file you choose."
msgstr ""
"<b>Mappings speichern</b> – schreibt ~/.config/gKeyMapper/mappings.conf und "
"lässt den Daemon sie neu laden. Die Menüeinträge <i>Konfiguration "
"exportieren...</i> und <i>Konfiguration importieren...</i> machen dasselbe "
"mit einer Datei deiner Wahl."
msgid ""
"<b>Daemon status</b> – the footer shows whether gkeymapper-daemon is "
"running, which keyboard it found and the last G key that fired. "
"<i>Options</i> shows the same in more detail."
msgstr ""
"<b>Daemon-Status</b> – die Fußzeile zeigt, ob gkeymapper-daemon läuft, "
"welche Tastatur gefunden wurde und welche G-Taste zuletzt ausgelöst hat. "
"<i>Optionen</i> zeigt dasselbe ausführlicher."
msgid ""
"<b>Autostart</b> – <i>Start with the session</i> 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 ""
"<b>Autostart</b> – <i>Beim Anmelden starten</i> ö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 ""
"<b>Tray and window</b> – with <i>Minimize to tray</i> the window is hidden "
"instead of closed when you close or minimize it; the tray icon shows it "
"again (<i>Show</i>) and quits the program (<i>Close</i>). Starting "
"gKeyMapper again closes the old window – rows that were not saved are lost "
"with it."
msgstr ""
"<b>Infobereich und Fenster</b> – mit <i>In den Infobereich minimieren</i> "
"wird das Fenster beim Schließen oder Minimieren versteckt statt geschlossen; "
"das Symbol im Infobereich zeigt es wieder (<i>Anzeigen</i>) und beendet das "
"Programm (<i>Beenden</i>). Ein erneuter Start von gKeyMapper schließt das "
"alte Fenster – nicht gespeicherte Zeilen gehen damit verloren."

171
po/en.po
View File

@@ -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 ""
"<b>The nine rows</b> – one per G key. Choose the type, then enter or capture "
"the target and press <i>Save mappings</i>."
msgstr ""
"<b>The nine rows</b> – one per G key. Choose the type, then enter or capture "
"the target and press <i>Save mappings</i>."
msgid "<b>Key</b> – presses the captured sequence once, e.g. ctrl+c."
msgstr "<b>Key</b> – presses the captured sequence once, e.g. ctrl+c."
msgid ""
"<b>Key (hold)</b> – 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 ""
"<b>Key (hold)</b> – 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 ""
"<b>Command</b> – runs a program or a command line, e.g. /usr/bin/firefox "
"--new-window."
msgstr ""
"<b>Command</b> – runs a program or a command line, e.g. /usr/bin/firefox "
"--new-window."
msgid "<b>Disabled</b> – the key does nothing."
msgstr "<b>Disabled</b> – the key does nothing."
msgid ""
"<b>Capture</b> – records the keys you press next: press them, then "
"<i>Done</i>. Modifiers are kept (shift, altgr, …) and the names are layout "
"independent, so ctrl+z means the key that types z on your layout."
msgstr ""
"<b>Capture</b> – records the keys you press next: press them, then "
"<i>Done</i>. Modifiers are kept (shift, altgr, …) and the names are layout "
"independent, so ctrl+z means the key that types z on your layout."
msgid ""
"<b>Save mappings</b> – writes ~/.config/gKeyMapper/mappings.conf and makes "
"the daemon reload it. The menu entries <i>Export configuration...</i> and "
"<i>Import configuration...</i> do the same with a file you choose."
msgstr ""
"<b>Save mappings</b> – writes ~/.config/gKeyMapper/mappings.conf and makes "
"the daemon reload it. The menu entries <i>Export configuration...</i> and "
"<i>Import configuration...</i> do the same with a file you choose."
msgid ""
"<b>Daemon status</b> – the footer shows whether gkeymapper-daemon is "
"running, which keyboard it found and the last G key that fired. "
"<i>Options</i> shows the same in more detail."
msgstr ""
"<b>Daemon status</b> – the footer shows whether gkeymapper-daemon is "
"running, which keyboard it found and the last G key that fired. "
"<i>Options</i> shows the same in more detail."
msgid ""
"<b>Autostart</b> – <i>Start with the session</i> 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 ""
"<b>Autostart</b> – <i>Start with the session</i> 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 ""
"<b>Tray and window</b> – with <i>Minimize to tray</i> the window is hidden "
"instead of closed when you close or minimize it; the tray icon shows it "
"again (<i>Show</i>) and quits the program (<i>Close</i>). Starting "
"gKeyMapper again closes the old window – rows that were not saved are lost "
"with it."
msgstr ""
"<b>Tray and window</b> – with <i>Minimize to tray</i> the window is hidden "
"instead of closed when you close or minimize it; the tray icon shows it "
"again (<i>Show</i>) and quits the program (<i>Close</i>). Starting "
"gKeyMapper again closes the old window – rows that were not saved are lost "
"with it."

174
po/es.po
View File

@@ -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 ""
"<b>The nine rows</b> – one per G key. Choose the type, then enter or capture "
"the target and press <i>Save mappings</i>."
msgstr ""
"<b>Las nueve filas</b> – una por tecla G. Elige el tipo, introduce o captura "
"el destino y pulsa <i>Guardar asignaciones</i>."
msgid "<b>Key</b> – presses the captured sequence once, e.g. ctrl+c."
msgstr "<b>Tecla</b> – pulsa la secuencia capturada una vez, p. ej. ctrl+c."
msgid ""
"<b>Key (hold)</b> – 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 ""
"<b>Tecla (mantener)</b> – la pulsa y la mantiene mientras se mantiene la "
"tecla G, p. ej. para pulsar para hablar. Solo se puede mantener un paso."
msgid ""
"<b>Command</b> – runs a program or a command line, e.g. /usr/bin/firefox "
"--new-window."
msgstr ""
"<b>Comando</b> – ejecuta un programa o una línea de comandos, p. ej. "
"/usr/bin/firefox --new-window."
msgid "<b>Disabled</b> – the key does nothing."
msgstr "<b>Desactivado</b> – la tecla no hace nada."
msgid ""
"<b>Capture</b> – records the keys you press next: press them, then "
"<i>Done</i>. Modifiers are kept (shift, altgr, …) and the names are layout "
"independent, so ctrl+z means the key that types z on your layout."
msgstr ""
"<b>Capturar</b> – graba las siguientes teclas que pulses: púlsalas y luego "
"<i>Aceptar</i>. 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 ""
"<b>Save mappings</b> – writes ~/.config/gKeyMapper/mappings.conf and makes "
"the daemon reload it. The menu entries <i>Export configuration...</i> and "
"<i>Import configuration...</i> do the same with a file you choose."
msgstr ""
"<b>Guardar asignaciones</b> – escribe ~/.config/gKeyMapper/mappings.conf y "
"hace que el daemon la recargue. Las entradas de menú <i>Exportar "
"configuración...</i> e <i>Importar configuración...</i> hacen lo mismo con "
"el archivo que elijas."
msgid ""
"<b>Daemon status</b> – the footer shows whether gkeymapper-daemon is "
"running, which keyboard it found and the last G key that fired. "
"<i>Options</i> shows the same in more detail."
msgstr ""
"<b>Estado del daemon</b> – 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. <i>Opciones</i> muestra lo mismo con más detalle."
msgid ""
"<b>Autostart</b> – <i>Start with the session</i> 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 ""
"<b>Inicio automático</b> – <i>Iniciar con la sesión</i> 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 ""
"<b>Tray and window</b> – with <i>Minimize to tray</i> the window is hidden "
"instead of closed when you close or minimize it; the tray icon shows it "
"again (<i>Show</i>) and quits the program (<i>Close</i>). Starting "
"gKeyMapper again closes the old window – rows that were not saved are lost "
"with it."
msgstr ""
"<b>Bandeja y ventana</b> – con <i>Minimizar a la bandeja</i>, al cerrar o "
"minimizar la ventana se oculta en lugar de cerrarse; el icono de la bandeja "
"la vuelve a mostrar (<i>Mostrar</i>) y cierra el programa (<i>Cerrar</i>). "
"Volver a iniciar gKeyMapper cierra la ventana antigua: las filas no "
"guardadas se pierden con ella."

180
po/fr.po
View File

@@ -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 ""
"<b>The nine rows</b> – one per G key. Choose the type, then enter or capture "
"the target and press <i>Save mappings</i>."
msgstr ""
"<b>Les neuf lignes</b> – une par touche G. Choisissez le type, saisissez ou "
"capturez la cible puis cliquez sur <i>Enregistrer les attributions</i>."
msgid "<b>Key</b> – presses the captured sequence once, e.g. ctrl+c."
msgstr "<b>Touche</b> – presse une fois la séquence capturée, par ex. ctrl+c."
msgid ""
"<b>Key (hold)</b> – 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 ""
"<b>Touche (maintenue)</b> – 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 ""
"<b>Command</b> – runs a program or a command line, e.g. /usr/bin/firefox "
"--new-window."
msgstr ""
"<b>Commande</b> – lance un programme ou une ligne de commande, par ex. "
"/usr/bin/firefox --new-window."
msgid "<b>Disabled</b> – the key does nothing."
msgstr "<b>Désactivé</b> – la touche ne fait rien."
msgid ""
"<b>Capture</b> – records the keys you press next: press them, then "
"<i>Done</i>. Modifiers are kept (shift, altgr, …) and the names are layout "
"independent, so ctrl+z means the key that types z on your layout."
msgstr ""
"<b>Capturer</b> – enregistre les touches que vous pressez ensuite : "
"pressez-les puis <i>Terminer</i>. 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 ""
"<b>Save mappings</b> – writes ~/.config/gKeyMapper/mappings.conf and makes "
"the daemon reload it. The menu entries <i>Export configuration...</i> and "
"<i>Import configuration...</i> do the same with a file you choose."
msgstr ""
"<b>Enregistrer les attributions</b> – écrit "
"~/.config/gKeyMapper/mappings.conf et fait recharger le fichier au démon. "
"Les entrées de menu <i>Exporter la configuration...</i> et <i>Importer la "
"configuration...</i> font la même chose avec le fichier de votre choix."
msgid ""
"<b>Daemon status</b> – the footer shows whether gkeymapper-daemon is "
"running, which keyboard it found and the last G key that fired. "
"<i>Options</i> shows the same in more detail."
msgstr ""
"<b>État du démon</b> – 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. <i>Options</i> affiche la même chose plus en détail."
msgid ""
"<b>Autostart</b> – <i>Start with the session</i> 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 ""
"<b>Démarrage automatique</b> – <i>Démarrer avec la session</i> 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 ""
"<b>Tray and window</b> – with <i>Minimize to tray</i> the window is hidden "
"instead of closed when you close or minimize it; the tray icon shows it "
"again (<i>Show</i>) and quits the program (<i>Close</i>). Starting "
"gKeyMapper again closes the old window – rows that were not saved are lost "
"with it."
msgstr ""
"<b>Zone de notification et fenêtre</b> – avec <i>Réduire dans la zone de "
"notification</i>, 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 "
"(<i>Afficher</i>) et quitte le programme (<i>Fermer</i>). Relancer "
"gKeyMapper ferme l'ancienne fenêtre : les lignes non enregistrées sont "
"perdues avec elle."

View File

@@ -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 ""
"<b>The nine rows</b> – one per G key. Choose the type, then enter or capture "
"the target and press <i>Save mappings</i>."
msgstr ""
#: src/help.cpp:36
msgid "<b>Key</b> – presses the captured sequence once, e.g. ctrl+c."
msgstr ""
#: src/help.cpp:37
msgid ""
"<b>Key (hold)</b> – 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 ""
"<b>Command</b> – runs a program or a command line, e.g. /usr/bin/firefox "
"--new-window."
msgstr ""
#: src/help.cpp:40
msgid "<b>Disabled</b> – the key does nothing."
msgstr ""
#: src/help.cpp:41
msgid ""
"<b>Capture</b> – records the keys you press next: press them, then "
"<i>Done</i>. 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 ""
"<b>Save mappings</b> – writes ~/.config/gKeyMapper/mappings.conf and makes "
"the daemon reload it. The menu entries <i>Export configuration...</i> and "
"<i>Import configuration...</i> do the same with a file you choose."
msgstr ""
#: src/help.cpp:47
msgid ""
"<b>Daemon status</b> – the footer shows whether gkeymapper-daemon is "
"running, which keyboard it found and the last G key that fired. "
"<i>Options</i> shows the same in more detail."
msgstr ""
#: src/help.cpp:50
msgid ""
"<b>Autostart</b> – <i>Start with the session</i> 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 ""
"<b>Tray and window</b> – with <i>Minimize to tray</i> the window is hidden "
"instead of closed when you close or minimize it; the tray icon shows it "
"again (<i>Show</i>) and quits the program (<i>Close</i>). Starting "
"gKeyMapper again closes the old window – rows that were not saved are lost "
"with it."
msgstr ""

117
src/autostart.cpp Normal file
View File

@@ -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 <glib.h>
#include <glib/gstdio.h>
#include <glib/gi18n.h>
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

25
src/autostart.h Normal file
View File

@@ -0,0 +1,25 @@
// gKeyMapper - the autostart entry that opens the window at login.
#ifndef GKEYMAPPER_AUTOSTART_H
#define GKEYMAPPER_AUTOSTART_H
#include <string>
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

View File

@@ -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

115
src/help.cpp Normal file
View File

@@ -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 <glib/gi18n.h>
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_("<b>The nine rows</b> – one per G key. Choose the type, then enter or capture the "
"target and press <i>Save mappings</i>."),
N_("<b>Key</b> – presses the captured sequence once, e.g. ctrl+c."),
N_("<b>Key (hold)</b> – 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_("<b>Command</b> – runs a program or a command line, e.g. /usr/bin/firefox --new-window."),
N_("<b>Disabled</b> – the key does nothing."),
N_("<b>Capture</b> – records the keys you press next: press them, then <i>Done</i>. "
"Modifiers are kept (shift, altgr, …) and the names are layout independent, so ctrl+z "
"means the key that types z on your layout."),
N_("<b>Save mappings</b> – writes ~/.config/gKeyMapper/mappings.conf and makes the daemon "
"reload it. The menu entries <i>Export configuration...</i> and <i>Import "
"configuration...</i> do the same with a file you choose."),
N_("<b>Daemon status</b> – the footer shows whether gkeymapper-daemon is running, which "
"keyboard it found and the last G key that fired. <i>Options</i> shows the same in "
"more detail."),
N_("<b>Autostart</b> – <i>Start with the session</i> 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_("<b>Tray and window</b> – with <i>Minimize to tray</i> the window is hidden instead of "
"closed when you close or minimize it; the tray icon shows it again (<i>Show</i>) and "
"quits the program (<i>Close</i>). 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("<big><b>%s</b></big>", _("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

21
src/help.h Normal file
View File

@@ -0,0 +1,21 @@
// gKeyMapper - the help window.
#ifndef GKEYMAPPER_HELP_H
#define GKEYMAPPER_HELP_H
#include <gtk/gtk.h>
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

View File

@@ -9,13 +9,18 @@
#include <string>
#include <vector>
#include "autostart.h"
#include "capturedialog.h"
#include "daemonclient.h"
#include "help.h"
#include "ipc_ids.h"
#include "keyrow.h"
#include "mapping.h"
#include "mappings.h"
#include "optionsdialog.h"
#include "settings.h"
#include "singleinstance.h"
#include "trayicon.h"
#ifndef PACKAGE
#define PACKAGE "gkeymapper"
@@ -32,19 +37,21 @@ using gkeymapper::kNumKeys;
std::vector<KeyRow> rows;
// Footer view of the daemon. It is filled by the asynchronous callbacks of
// DaemonClient, which arrive on the GTK main loop, so nothing here ever waits
// for the daemon.
struct DaemonFooter {
bool reachable = false;
bool configOk = true;
std::string device;
std::string lastKey;
} daemonFooter;
// 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<unsigned char>& pixels, int& width, int& height) {
GError* error = nullptr;
GdkPixbuf* pixbuf = have_embedded_icon()
? gdk_pixbuf_new_from_resource(kIconResource, &error)
: gdk_pixbuf_new_from_file(find_asset("icon.png").c_str(), &error);
if (pixbuf == nullptr) {
g_printerr("gkeymapper: no tray icon: %s\n",
error != nullptr ? error->message : "unknown error");
g_clear_error(&error);
return false;
}
if (gdk_pixbuf_get_width(pixbuf) > kTrayIconSize ||
gdk_pixbuf_get_height(pixbuf) > kTrayIconSize) {
GdkPixbuf* scaled =
gdk_pixbuf_scale_simple(pixbuf, kTrayIconSize, kTrayIconSize, GDK_INTERP_BILINEAR);
g_object_unref(pixbuf);
pixbuf = scaled;
}
if (!gdk_pixbuf_get_has_alpha(pixbuf)) {
GdkPixbuf* withAlpha = gdk_pixbuf_add_alpha(pixbuf, FALSE, 0, 0, 0);
g_object_unref(pixbuf);
pixbuf = withAlpha;
}
width = gdk_pixbuf_get_width(pixbuf);
height = gdk_pixbuf_get_height(pixbuf);
const int stride = gdk_pixbuf_get_rowstride(pixbuf);
const guchar* source = gdk_pixbuf_get_pixels(pixbuf);
pixels.assign(static_cast<size_t>(width) * static_cast<size_t>(height) * 4, 0);
for (int y = 0; y < height; ++y) {
const guchar* row = source + static_cast<size_t>(y) * static_cast<size_t>(stride);
for (int x = 0; x < width; ++x) {
unsigned char* target =
pixels.data() + (static_cast<size_t>(y) * static_cast<size_t>(width) + x) * 4;
target[0] = row[x * 4 + 3]; // A
target[1] = row[x * 4 + 0]; // R
target[2] = row[x * 4 + 1]; // G
target[3] = row[x * 4 + 2]; // B
}
}
g_object_unref(pixbuf);
return true;
}
static void start_tray(GtkApplication* app) {
std::vector<unsigned char> pixels;
int width = 0;
int height = 0;
if (!load_tray_icon(pixels, width, height)) {
return;
}
tray.start(std::move(pixels), width, height, show_main_window,
[app]() { g_application_quit(G_APPLICATION(app)); });
}
static void build_ui(GtkApplication* app);
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[] = {"<Control>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

View File

@@ -9,6 +9,10 @@
namespace gkeymapper {
bool write_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
return save_mappings_file(path, collect_mappings(rows));
}
MappingSet collect_mappings(const std::vector<KeyRow>& rows) {
MappingSet set;
set.keys.assign(kNumKeys, Mapping{});
@@ -28,7 +32,7 @@ bool write_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
set.keys[row.index] = mapping;
}
return save_mappings_file(path, set);
return set;
}
void read_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
@@ -38,6 +42,10 @@ void read_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
return;
}
apply_mappings(set, rows);
}
void apply_mappings(const MappingSet& set, const std::vector<KeyRow>& rows) {
for (const KeyRow& row : rows) {
if (row.index < 0 || row.index >= static_cast<int>(set.keys.size())) {
continue;

View File

@@ -6,9 +6,17 @@
#include <vector>
#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<KeyRow>& rows);
void apply_mappings(const MappingSet& set, const std::vector<KeyRow>& rows);
// Collects the current widget state and writes it to path.
bool write_mappings(const std::string& path, const std::vector<KeyRow>& rows);

279
src/optionsdialog.cpp Normal file
View File

@@ -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 <string>
#include <glib/gi18n.h>
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("<b>%s</b>", 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<OptionsDialog*>(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<OptionsDialog*>(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<OptionsDialog*>(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<OptionsDialog*>(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

83
src/optionsdialog.h Normal file
View File

@@ -0,0 +1,83 @@
// gKeyMapper - the options window.
#ifndef GKEYMAPPER_OPTIONSDIALOG_H
#define GKEYMAPPER_OPTIONSDIALOG_H
#include <functional>
#include <gtk/gtk.h>
#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<void(bool)>;
// Called with the chosen language code ("" = follow the session).
using LanguageFn = std::function<void(const std::string&)>;
// 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

95
src/settings.cpp Normal file
View File

@@ -0,0 +1,95 @@
// gKeyMapper - GUI options that are not part of the mapping format.
#include "settings.h"
#include <clocale>
#include <cstdlib>
#include <glib.h>
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

33
src/settings.h Normal file
View File

@@ -0,0 +1,33 @@
// gKeyMapper - GUI options that are not part of the mapping format.
#ifndef GKEYMAPPER_SETTINGS_H
#define GKEYMAPPER_SETTINGS_H
#include <string>
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

635
src/trayicon.cpp Normal file
View File

@@ -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 <unistd.h> // getpid
#include <glib/gi18n.h>
#include <glib.h>
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 =
"<node>"
" <interface name='org.kde.StatusNotifierItem'>"
" <property name='Category' type='s' access='read'/>"
" <property name='Id' type='s' access='read'/>"
" <property name='Title' type='s' access='read'/>"
" <property name='Status' type='s' access='read'/>"
" <property name='IconName' type='s' access='read'/>"
" <property name='IconPixmap' type='a(iiay)' access='read'/>"
" <property name='OverlayIconName' type='s' access='read'/>"
" <property name='AttentionIconName' type='s' access='read'/>"
" <property name='ToolTip' type='(sa(iiay)ss)' access='read'/>"
" <property name='ItemIsMenu' type='b' access='read'/>"
" <property name='Menu' type='o' access='read'/>"
" <method name='Activate'>"
" <arg name='x' type='i' direction='in'/>"
" <arg name='y' type='i' direction='in'/>"
" </method>"
" <method name='SecondaryActivate'>"
" <arg name='x' type='i' direction='in'/>"
" <arg name='y' type='i' direction='in'/>"
" </method>"
" <method name='ContextMenu'>"
" <arg name='x' type='i' direction='in'/>"
" <arg name='y' type='i' direction='in'/>"
" </method>"
" <method name='Scroll'>"
" <arg name='delta' type='i' direction='in'/>"
" <arg name='orientation' type='s' direction='in'/>"
" </method>"
" <signal name='NewIcon'/>"
" <signal name='NewStatus'>"
" <arg name='status' type='s'/>"
" </signal>"
" <signal name='NewToolTip'/>"
" </interface>"
"</node>";
// 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 =
"<node>"
" <interface name='com.canonical.dbusmenu'>"
" <method name='GetLayout'>"
" <arg name='parentId' type='i' direction='in'/>"
" <arg name='recursionDepth' type='i' direction='in'/>"
" <arg name='propertyNames' type='as' direction='in'/>"
" <arg name='revision' type='u' direction='out'/>"
" <arg name='layout' type='(ia{sv}av)' direction='out'/>"
" </method>"
" <method name='GetGroupProperties'>"
" <arg name='ids' type='ai' direction='in'/>"
" <arg name='propertyNames' type='as' direction='in'/>"
" <arg name='properties' type='a(ia{sv})' direction='out'/>"
" </method>"
" <method name='GetProperty'>"
" <arg name='id' type='i' direction='in'/>"
" <arg name='name' type='s' direction='in'/>"
" <arg name='value' type='v' direction='out'/>"
" </method>"
" <method name='Event'>"
" <arg name='id' type='i' direction='in'/>"
" <arg name='eventId' type='s' direction='in'/>"
" <arg name='data' type='v' direction='in'/>"
" <arg name='timestamp' type='u' direction='in'/>"
" </method>"
" <method name='EventGroup'>"
" <arg name='events' type='a(isvu)' direction='in'/>"
" <arg name='idErrors' type='ai' direction='out'/>"
" </method>"
" <method name='AboutToShow'>"
" <arg name='id' type='i' direction='in'/>"
" <arg name='needUpdate' type='b' direction='out'/>"
" </method>"
" <method name='AboutToShowGroup'>"
" <arg name='ids' type='ai' direction='in'/>"
" <arg name='updatesNeeded' type='ai' direction='out'/>"
" <arg name='idErrors' type='ai' direction='out'/>"
" </method>"
" <signal name='ItemsPropertiesUpdated'>"
" <arg name='updatedProps' type='a(ia{sv})'/>"
" <arg name='removedProps' type='a(ias)'/>"
" </signal>"
" <signal name='LayoutUpdated'>"
" <arg name='revision' type='u'/>"
" <arg name='parent' type='i'/>"
" </signal>"
" <property name='Version' type='u' access='read'/>"
" <property name='TextDirection' type='s' access='read'/>"
" <property name='Status' type='s' access='read'/>"
" <property name='IconThemePath' type='as' access='read'/>"
" </interface>"
"</node>";
// 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<std::function<void()>*>(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<unsigned char> 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<TrayIcon*>(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<TrayIcon*>(userData)->set_watcher_available(name, true);
}
void TrayIcon::on_watcher_vanished(GDBusConnection* /*connection*/, const gchar* name,
gpointer userData) {
static_cast<TrayIcon*>(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<TrayIcon*>(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<int>(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<TrayIcon*>(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<TrayIcon*>(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<TrayIcon*>(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

109
src/trayicon.h Normal file
View File

@@ -0,0 +1,109 @@
// gKeyMapper - the tray icon, spoken as a StatusNotifierItem.
#ifndef GKEYMAPPER_TRAYICON_H
#define GKEYMAPPER_TRAYICON_H
#include <functional>
#include <string>
#include <vector>
#include <gio/gio.h>
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<void()>;
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<unsigned char> 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<unsigned char> 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