Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c750fe0449 | |||
| 5410ed2ae5 | |||
| f45596a7e3 | |||
| da60aef44e | |||
| 87fa8128a5 | |||
| ff020d0eb9 | |||
| 673af1ae43 | |||
| 95f101c0c9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ gkeymapper
|
||||
gkeymapper-daemon
|
||||
*.o
|
||||
*.so
|
||||
*.deb
|
||||
|
||||
6
.vscode/tasks.json
vendored
6
.vscode/tasks.json
vendored
@@ -44,6 +44,12 @@
|
||||
"type": "shell",
|
||||
"command": "make clean && make",
|
||||
"problemMatcher": "$gcc"
|
||||
},
|
||||
{
|
||||
"label": "make: deb",
|
||||
"type": "shell",
|
||||
"command": "make deb",
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
275
AGENTS.md
275
AGENTS.md
@@ -10,10 +10,10 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
|
||||
## Build / run / verify
|
||||
- `make` — builds the `gkeymapper` GUI, the `gkeymapper-daemon` and the `.mo`
|
||||
catalogs. Needs `g++` (C++17), `make`, `gettext`, `libgtk-4-dev`,
|
||||
`libusb-1.0-0-dev`, `libxkbcommon-dev` and `libglib2.0-dev` (see README for
|
||||
`libxkbcommon-dev` and `libglib2.0-dev` (see README for
|
||||
`apt install` on a fresh machine). `libglib2.0-dev` also provides
|
||||
`glib-compile-resources`, which generates the icons embedded into the GUI.
|
||||
- `make daemon` — builds only `gkeymapper-daemon` (needs libusb, xkbcommon and
|
||||
- `make daemon` — builds only `gkeymapper-daemon` (needs xkbcommon and
|
||||
gio-2.0, not GTK).
|
||||
- `make translations` — only runs `msgfmt` on the existing `.po` files.
|
||||
- `make run` — builds, then runs with `GKEYMAPPER_LOCALEDIR=$PWD/locale` and
|
||||
@@ -22,16 +22,35 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
|
||||
icon itself is compiled into the binary, so it never depends on these.
|
||||
- `make clean` — deletes the binaries and the generated `locale/` and `build/`
|
||||
trees.
|
||||
- `make deb` — builds a `.deb` package (`gkeymapper_<version>_<arch>.deb`) via
|
||||
`scripts/make-deb.sh`; on install it runs `systemctl --user daemon-reload` and
|
||||
`systemctl --user enable --now gkeymapper-daemon` for the installing user.
|
||||
- No tests and no CI. Verify changes by building and running the app.
|
||||
|
||||
## i18n — easy to get wrong
|
||||
- English is the source language (the `msgid`) **and** also has an explicit
|
||||
identity catalog `po/en.po`. Languages: `de`, `en`, `es`, `fr`.
|
||||
- `make translations` does **not** regenerate the `.pot`. When you add or change
|
||||
a UI string, edit `po/gkeymapper.pot` and `po/*.po` by hand, then run
|
||||
`make translations`; validate with `msgfmt --check -o /dev/null po/<lang>.po`.
|
||||
- Never edit the generated `.mo` files under `locale/` (gitignored). Delete any
|
||||
`po/*.po~` backups before committing.
|
||||
identity catalog `po/en.po`. Languages: `de`, `en`, `es`, `fr`. The workflow,
|
||||
the rules behind it and adding a language are documented in `po/README.md`; the
|
||||
commands are `scripts/i18n.sh` (`make pot`, `make i18n-check`,
|
||||
`make i18n-status`).
|
||||
- `make translations` does **not** regenerate the `.pot` (a pot written by a build
|
||||
is a pot nobody reviews). When you add or change a UI string: `make pot`, then
|
||||
`scripts/i18n.sh update` to merge it into the catalogs, then fill in the msgstrs
|
||||
in `po/*.po` — and in `po/en.po`, whose msgstr repeats the msgid. `_()` is
|
||||
translated where it is used, `N_()` is only collected (the help paragraphs in
|
||||
`src/help.cpp`); the script always passes both keywords to `xgettext`.
|
||||
- The catalogs carry **no** `#: file:line` comments; the locations live in the pot
|
||||
only (`update` merges with `msgmerge --no-location`) and
|
||||
`scripts/i18n.sh todo <lang>` shows them from there. `make i18n-check` fails
|
||||
when the pot is behind the sources, when a catalog misses a message or a
|
||||
translation, or when `po/*.po` and `kLanguageCodes[]` (`src/optionsdialog.cpp`)
|
||||
disagree — a catalog nobody can select is dead weight.
|
||||
- The daemon is **not** translated (no `setlocale`/`bindtextdomain`, only the GUI
|
||||
sets gettext up in `main()`); `i18n-check` reports a `_()` marker under
|
||||
`daemon/`.
|
||||
- Never edit the generated `.mo` files under `locale/` (gitignored). A manual
|
||||
`msgmerge --update` leaves `po/*.po~` backups — the script deletes them, by hand
|
||||
delete them before committing.
|
||||
- All documentation and source comments are English. German appears only inside
|
||||
`po/de.po`.
|
||||
|
||||
@@ -40,14 +59,16 @@ 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`,
|
||||
`optionsdialog` and `popovers` (the popup input grab, see below).
|
||||
- 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`,
|
||||
`libxkbcommon-dev` and `libglib2.0-dev`). `make daemon` builds only the daemon.
|
||||
(`daemon/*.cpp` + `shared/*.cpp`, needs `libxkbcommon-dev` and `libglib2.0-dev`). `make daemon` builds only the daemon.
|
||||
- The daemon is split into: `device_tables` (raw HID report tables per model plus
|
||||
`decode_gkey_state`, the report → pressed-bitmap decoder), `g910_device` (libusb
|
||||
open/detach/claim/read), `uinput_keyboard` (virtual keyboard), `action`
|
||||
`decode_gkey_state`, the report → pressed-bitmap decoder), `g910_device` (hidraw open/read/close), `uinput_keyboard` (virtual keyboard), `action`
|
||||
(dispatch `key`/`command`/`disabled`/`keyhold`), `config_watch` (inotify auto-reload of
|
||||
the mappings file) and `ipc_dbus` (the GDBus service). `shared/keymap` does the
|
||||
layout lookups for both sides. The hot path never touches the GUI: D-Bus is
|
||||
@@ -77,7 +98,7 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
|
||||
2 s while D-Bus and the config watch keep running. Never make it exit on a
|
||||
missing device — that would drop the bus name and make the GUI look like the
|
||||
daemon died. `GKeyDevice::open(quiet)` exists so those retries stay out of the
|
||||
journal (libusb-level errors always log).
|
||||
journal (hidraw-level errors always log).
|
||||
- `make install` (PREFIX default `/usr/local`) also installs the packaging
|
||||
files from `data/`: the udev rule, the `modules-load.d` fragment, the
|
||||
systemd **user** unit and the D-Bus activation file. Both contain an
|
||||
@@ -95,6 +116,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
|
||||
@@ -110,9 +137,18 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
|
||||
with `SIGTERM` after checking that the bus name really belongs to a
|
||||
`gkeymapper` process. Only then does the new process become the primary,
|
||||
because otherwise `GtkApplication` would hand over to the old process and its
|
||||
window — possibly one from an older build — would stay on screen. Row edits
|
||||
that were not saved are gone with the replaced window; there is no implicit
|
||||
save.
|
||||
window — possibly one from an older build — would stay on screen.
|
||||
- The takeover also carries the rows over, so an edit that was typed but never
|
||||
saved is not lost with the replaced window: before the quit,
|
||||
`replace_running_instance()` asks for them (`Window.GetRows` at
|
||||
`ipc::kWindowObjectPath`) and `build_ui()` starts the new window from that text
|
||||
instead of the file. The answering side is `serve_rows_handover()` — called from
|
||||
`on_activate` **after** `build_ui()`, since that is what fills the rows — with
|
||||
`mappings_text(rows)`, i.e. the mappings file format, so both ends use
|
||||
`src/mappings`. An instance that does not answer (an older build) leaves the new
|
||||
window with the file; asking and serving are best effort and never fatal. The
|
||||
file itself is untouched until the user presses `Save mappings` — the handover
|
||||
carries row state, it does not save.
|
||||
- `on_activate` presents the window that is already there
|
||||
(`gtk_application_get_windows()`), it does not build a second one: a remote
|
||||
activation — an older build starting next to a new one — would otherwise add a
|
||||
@@ -121,6 +157,79 @@ 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). That
|
||||
`Exec` line also carries `--start-hidden` (`kStartHiddenArg`, taken off the
|
||||
command line in `main()`), so the window is built but stays in the tray: a login
|
||||
does not put it in front of the user, while a start by hand (no flag) always
|
||||
shows it. Keeping it hidden is only safe while the panel really hosts the icon,
|
||||
and only the panel can answer that, asynchronously — so
|
||||
`on_start_hidden_timeout()` shows the window after `kStartHiddenGraceMs` if the
|
||||
icon never turned up. A rebuild (language switch) comes up the way the window
|
||||
was left: `build_ui()` takes the visibility as an argument. 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.
|
||||
@@ -147,6 +256,30 @@ live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
|
||||
`GtkScrolledWindow` (one collapsed the rows earlier). Keep the fixed widths
|
||||
(Type dropdown 150 px, target entry 440 px min, action button 180 px) so rows
|
||||
do not jump when the language changes.
|
||||
- Both dropdowns (Type in the main window, Language in the options window) give
|
||||
every entry a **trailing space**, added where the model is built — the msgids
|
||||
stay the bare words. `GtkDropDown` appends the check mark of the selected entry
|
||||
directly behind the text (`setup_item()` in `gtkdropdown.c`), and without that
|
||||
space it touches the last letter. The selection is read back by index, never by
|
||||
the string, so the space is harmless everywhere else.
|
||||
- `src/popovers.{h,cpp}` takes the input grab off the popovers of a window (the
|
||||
dropdowns' popup lists and the header bar menu) and closes them itself: on a
|
||||
click in the window outside the popup, on Escape, and when the window loses the
|
||||
focus. GTK 4.6 grabs the pointer for an open popover, an X11 grab swallows the
|
||||
clicks of **every** other client, and this GTK does not close the popup on a
|
||||
click outside the application either — with one dropdown open nothing else
|
||||
could be clicked any more, not another program and not the desktop, and the
|
||||
popup stayed open until Escape (measured with a plain GTK4 probe: the click on
|
||||
the bare desktop never reached it and `_NET_ACTIVE_WINDOW` did not change; a
|
||||
click inside the app's own window did close the popup). GTK3 replayed such a
|
||||
click to the window under the pointer, GTK4 dropped that. `autohide` is what
|
||||
installs the grab, so switching it off hands the clicks back to the desktop;
|
||||
the dismissal is then done by hand, which is the rest of the module. It runs
|
||||
once per toplevel window (`build_ui`, `OptionsDialog::show`), notices popovers
|
||||
GTK creates later on the press that opens them, and leaves the press on the
|
||||
popup itself alone — that one is an entry choice and must reach the entry
|
||||
(recognised by its own surface, not by its position, because GTK reports it in
|
||||
the popup's coordinates).
|
||||
- Because it is pure GTK4 it follows the system theme (Mint-Y ships a
|
||||
`gtk-4.0` theme). Do **not** add the `suggested-action` CSS class — Mint-Y
|
||||
tints it green and it clashes with the rest of the layout.
|
||||
@@ -254,9 +387,9 @@ over any installed file; the file lookup remains only as a safety net.
|
||||
families, which must *not* decode as media keys (see the bit table above);
|
||||
- build the daemon with a **fake** `GKeyDevice`: keep the header, script
|
||||
`open()`/`read_report()`, link `daemon/*.cpp` **without** `g910_device.cpp`
|
||||
and without libusb. That runs the real loop, so it covers "no keyboard at
|
||||
and without g910_device.cpp. That runs the real loop, so it covers "no keyboard at
|
||||
start", "device lost" and the reopen, emits real D-Bus signals and never
|
||||
claims the real device — nothing touches the keyboard and no key is injected
|
||||
opens the real device — nothing touches the keyboard and no key is injected
|
||||
into the focused window. Use a config with `command:` mappings only (a helper
|
||||
script that dumps its environment is also how you check that a `command`
|
||||
action sees `DISPLAY`); with `key:` mappings the fake would inject keys into
|
||||
@@ -266,7 +399,7 @@ over any installed file; the file lookup remains only as a safety net.
|
||||
the fake `g910_device.h` in an include directory. `#include "g910_device.h"`
|
||||
searches the *including file's* directory first, so compiling the real
|
||||
`daemon/main.cpp` would always pick up the real header (its inline
|
||||
`is_open()` needs libusb).
|
||||
`is_open()` needs hidraw).
|
||||
- **Give the fake its own session bus** (`dbus-daemon --session --address=
|
||||
unix:path=/tmp/…/fake_bus --fork --print-pid=1`, then
|
||||
`DBUS_SESSION_BUS_ADDRESS=unix:path=…` for the fake). Otherwise it refuses
|
||||
@@ -340,7 +473,11 @@ replaced process exits `0` when it was closed through the D-Bus `quit` action, o
|
||||
instance from an older commit (`git worktree add /tmp/legacy <commit>`), start it
|
||||
and then start the current build next to it. Starting the GUI twice also covers
|
||||
the `activate` guard: without it the second start adds a second window to the
|
||||
running process.
|
||||
running process. The rows come along (see the takeover notes above), which is
|
||||
checkable the same way: type into a row of the first instance without saving,
|
||||
start the second one and read its rows back (next bullet) — they show the edit,
|
||||
while the mappings file still has the old content. An instance from an older
|
||||
commit cannot hand rows over, so there the new window shows the file.
|
||||
|
||||
The footer's daemon line is a plain `GtkLabel`, so its text can be read back over
|
||||
AT-SPI: `<app> → frame → panel → label` whose name starts with the translated
|
||||
@@ -358,6 +495,100 @@ is up. Three notes for tests:
|
||||
`import -window $(wmctrl -l | grep 'gKeyMapper – Logitech' | awk '{print $1}')
|
||||
/tmp/gui.png`. The footer label works over AT-SPI (its accessible *name* is the
|
||||
text), but a screenshot of the cropped footer is easier and shows what the user
|
||||
sees.
|
||||
sees. Easier than a screenshot for the rows themselves: the running instance
|
||||
answers `gdbus call --session --dest dev.rager.gkeymapper --object-path
|
||||
/dev/rager/gkeymapper/Window --method dev.rager.gkeymapper.Window.GetRows` with
|
||||
exactly the mappings text its rows hold (`G1=command:/bin/true…`). It is the
|
||||
same channel the takeover uses, so it also tells whether an edit is still
|
||||
unsaved: compare it with the file.
|
||||
- `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 (the `--` keeps gdbus from reading the `-1` as
|
||||
an option, and the empty array is the property-name list `GetLayout` expects).
|
||||
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 (the
|
||||
empty string is the variant `v` of the event; `<''>` is the only spelling gdbus
|
||||
accepts). A single `Event` call works too but is not what the watcher sends.
|
||||
- A start with `--start-hidden` (what the autostart entry passes) is checkable the
|
||||
same way: no window in `wmctrl -lp` while the item above is registered and the
|
||||
process stays, and the menu's `Show` brings the window up. Without a tray watcher
|
||||
the window has to appear by itself after ~5 s — on a private bus that is awkward
|
||||
to see, because a fresh `dbus-daemon` makes GTK's startup wait on gvfs/portal
|
||||
services (`GIO_USE_VFS=local` unblocks it, and it can still take ~20 s), so
|
||||
`on_activate` runs much later than on the session bus. Do not read "no window" as
|
||||
the flag working until the tray item or the window object
|
||||
(`Window.GetRows`) proves the app got that far.
|
||||
- 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 popups (`src/popovers.cpp`) are checkable without hardware, and a click
|
||||
test has to know two things: a popup is an override-redirect window that
|
||||
disappears from `xwininfo`'s map state (or a `viewable.py`-style walk) but never
|
||||
appears in `wmctrl -l`, and a click on the dropdown that is *already* open
|
||||
closes it again — so "click the dropdown" does not mean "the popup is open",
|
||||
always read the map state. What to check: with a type dropdown open, a click on
|
||||
a *different* window must arrive there (`_NET_ACTIVE_WINDOW` changes to that
|
||||
client, which proves the press was delivered and not swallowed) and the popup
|
||||
must be gone; the same for a click on the bare desktop; a click in the window
|
||||
outside the popup closes it without changing the focus; Escape closes it; a
|
||||
click on an entry closes it and changes the row (`Window.GetRows`, e.g.
|
||||
`G1=key:…` becomes `G1=command:…`); a click on the header bar's menu button
|
||||
opens the menu and behaves like the dropdown. Every synthetic click must be
|
||||
guarded by the id of the window that is expected to be topmost — the popup is
|
||||
not in `_NET_CLIENT_LIST_STACKING`, so a click aimed at the popup has to be
|
||||
checked against the window *below* it.
|
||||
- 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).
|
||||
|
||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Thomas Rager
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
37
Makefile
37
Makefile
@@ -1,6 +1,7 @@
|
||||
# gKeyMapper - Build
|
||||
# Dependencies: g++, pkg-config, msgfmt, libgtk-4-dev, libusb-1.0-0-dev, libxkbcommon-dev
|
||||
# Installation: sudo apt install build-essential gettext libgtk-4-dev libusb-1.0-0-dev libxkbcommon-dev
|
||||
# Dependencies: g++, pkg-config, gettext (msgfmt, for the translations),
|
||||
# libgtk-4-dev, libxkbcommon-dev
|
||||
# Installation: sudo apt install build-essential gettext libgtk-4-dev libxkbcommon-dev
|
||||
|
||||
CXX ?= g++
|
||||
CXXFLAGS ?= -std=c++17 -O2 -Wall -Wextra -Wpedantic
|
||||
@@ -10,8 +11,6 @@ GLIB_COMPILE_RESOURCES ?= glib-compile-resources
|
||||
|
||||
GTK_CFLAGS := $(shell $(PKG_CONFIG) --cflags gtk4)
|
||||
GTK_LIBS := $(shell $(PKG_CONFIG) --libs gtk4)
|
||||
USB_CFLAGS := $(shell $(PKG_CONFIG) --cflags libusb-1.0)
|
||||
USB_LIBS := $(shell $(PKG_CONFIG) --libs libusb-1.0)
|
||||
XKB_CFLAGS := $(shell $(PKG_CONFIG) --cflags xkbcommon)
|
||||
XKB_LIBS := $(shell $(PKG_CONFIG) --libs xkbcommon)
|
||||
GIO_CFLAGS := $(shell $(PKG_CONFIG) --cflags gio-2.0)
|
||||
@@ -31,6 +30,8 @@ DAEMON_HEADERS := $(wildcard $(DAEMON_DIR)/*.h) $(wildcard $(SHARED_DIR)/*.h)
|
||||
GUI_RES := build/gresources.c
|
||||
GUI_RES_SRC := data/icons.gresource.xml
|
||||
PREFIX ?= /usr/local
|
||||
DEB_VERSION ?= 0.1.2
|
||||
DEB_ARCH := $(shell dpkg --print-architecture 2>/dev/null || echo amd64)
|
||||
BINDIR := $(PREFIX)/bin
|
||||
LOCALEDIR := $(PREFIX)/share/locale
|
||||
ASSETS_DIR := $(PREFIX)/share/$(PACKAGE)/assets
|
||||
@@ -42,12 +43,15 @@ DBUS_DIR := $(PREFIX)/share/dbus-1/services
|
||||
CXXFLAGS += -DPACKAGE=\"$(PACKAGE)\" -DLOCALEDIR=\"$(LOCALEDIR)\" -DASSETS_DIR=\"$(ASSETS_DIR)\"
|
||||
CXXFLAGS += -I$(SHARED_DIR)
|
||||
|
||||
# Translations
|
||||
# Translations. The .pot is generated from the sources (make pot) and the
|
||||
# catalogs are merged against it (scripts/i18n.sh update); the rules are in
|
||||
# po/README.md.
|
||||
PO_FILES := $(wildcard po/*.po)
|
||||
LANGS := $(patsubst po/%.po,%,$(PO_FILES))
|
||||
MO_FILES := $(patsubst %,locale/%/LC_MESSAGES/$(PACKAGE).mo,$(LANGS))
|
||||
I18N := $(CURDIR)/scripts/i18n.sh
|
||||
|
||||
.PHONY: all clean run install translations daemon
|
||||
.PHONY: all clean run install translations daemon pot i18n-check i18n-status deb
|
||||
|
||||
all: $(TARGET) translations $(DAEMON)
|
||||
|
||||
@@ -64,7 +68,7 @@ $(TARGET): $(GUI_SOURCES) $(GUI_HEADERS) $(GUI_RES)
|
||||
daemon: $(DAEMON)
|
||||
|
||||
$(DAEMON): $(DAEMON_SOURCES) $(DAEMON_HEADERS)
|
||||
$(CXX) $(CXXFLAGS) $(USB_CFLAGS) $(XKB_CFLAGS) $(GIO_CFLAGS) -o $@ $(DAEMON_SOURCES) $(USB_LIBS) $(XKB_LIBS) $(GIO_LIBS)
|
||||
$(CXX) $(CXXFLAGS) $(XKB_CFLAGS) $(GIO_CFLAGS) -o $@ $(DAEMON_SOURCES) $(XKB_LIBS) $(GIO_LIBS)
|
||||
|
||||
translations: $(MO_FILES)
|
||||
|
||||
@@ -72,9 +76,28 @@ locale/%/LC_MESSAGES/$(PACKAGE).mo: po/%.po
|
||||
@mkdir -p $(@D)
|
||||
$(MSGFMT) -o $@ $<
|
||||
|
||||
# Regenerate po/gkeymapper.pot from the sources. "make translations" does not: a
|
||||
# pot that is written by a build is a pot that nobody has looked at, and the
|
||||
# strings are reviewed by hand (po/README.md, "Adding or changing a string").
|
||||
pot:
|
||||
$(I18N) extract
|
||||
|
||||
# Everything that can be wrong with the translations at once: the pot against the
|
||||
# sources, every catalog against the pot, and the language list of the GUI against
|
||||
# po/*.po. Run it before committing a string change.
|
||||
i18n-check:
|
||||
$(I18N) check
|
||||
|
||||
i18n-status:
|
||||
$(I18N) status
|
||||
|
||||
run: $(TARGET) translations
|
||||
GKEYMAPPER_LOCALEDIR=$(CURDIR)/locale GKEYMAPPER_ASSETS_DIR=$(CURDIR)/assets ./$(TARGET)
|
||||
|
||||
deb:
|
||||
$(CURDIR)/scripts/make-deb.sh $(DEB_VERSION) $(DEB_ARCH)
|
||||
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET) $(DAEMON)
|
||||
rm -rf locale build
|
||||
|
||||
121
README.md
121
README.md
@@ -1,5 +1,13 @@
|
||||
# gKeyMapper
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
GTK4 application (C++) for remapping the G keys **G1–G9** of a Logitech **G910**
|
||||
to other keys or commands (Linux Mint / Ubuntu).
|
||||
|
||||
@@ -8,6 +16,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
|
||||
@@ -17,7 +31,6 @@ Documentation and source comments are written in English.
|
||||
- pkg-config
|
||||
- gettext (for translations, `msgfmt`)
|
||||
- GTK 4 development files
|
||||
- libusb 1.0 development files (for the daemon)
|
||||
- libxkbcommon development files (for the daemon's layout handling)
|
||||
- GLib/GIO development files (for the GUI ↔ daemon D-Bus channel; `libgtk-4-dev`
|
||||
already pulls them in, but they are needed for `make daemon` on its own)
|
||||
@@ -26,21 +39,21 @@ Documentation and source comments are written in English.
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential gettext libgtk-4-dev libusb-1.0-0-dev libxkbcommon-dev libglib2.0-dev
|
||||
sudo apt install -y build-essential gettext libgtk-4-dev libxkbcommon-dev libglib2.0-dev
|
||||
```
|
||||
|
||||
Afterwards `pkg-config --modversion gtk4`, `pkg-config --modversion libusb-1.0`
|
||||
and `pkg-config --modversion gio-2.0` should print versions.
|
||||
Afterwards `pkg-config --modversion gtk4` and `pkg-config --modversion xkbcommon`
|
||||
should print versions.
|
||||
|
||||
## Setting up the project
|
||||
|
||||
Change to the project directory:
|
||||
|
||||
```bash
|
||||
cd /home/trager/Nextcloud/Projekte/C++/gKeyMapper
|
||||
cd gKeyMapper
|
||||
```
|
||||
|
||||
(Alternatively clone the repository or copy the project folder to your own location.)
|
||||
(Use the path where you cloned or copied the repository.)
|
||||
|
||||
## Build
|
||||
|
||||
@@ -54,12 +67,32 @@ with `make daemon`. The GUI has its icons (`assets/icon.png`, `assets/icon.svg`)
|
||||
compiled in (`data/icons.gresource.xml` → generated `build/gresources.c`), so the
|
||||
window icon works without an installed `assets/` directory.
|
||||
|
||||
A `.deb` package can be built with
|
||||
|
||||
```bash
|
||||
make deb
|
||||
```
|
||||
|
||||
which produces `gkeymapper_<version>_<arch>.deb`. Installing it runs
|
||||
`systemctl --user daemon-reload` and `systemctl --user enable --now gkeymapper-daemon`
|
||||
for the installing user.
|
||||
|
||||
## Rebuild translations
|
||||
|
||||
```bash
|
||||
make translations
|
||||
```
|
||||
|
||||
Adding or changing a translation, adding a language, and the checks around them
|
||||
are described in [po/README.md](po/README.md). `scripts/i18n.sh` does the work and
|
||||
is wired into the Makefile:
|
||||
|
||||
```bash
|
||||
make pot # regenerate po/gkeymapper.pot from the sources
|
||||
make i18n-check # pot, catalogs and the language list of the GUI
|
||||
make i18n-status # per language: translated, fuzzy, untranslated
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
Normal:
|
||||
@@ -90,8 +123,51 @@ window rewrites the same content instead of emptying the file.
|
||||
Only one window is open at a time, and it is the one you just started: a new
|
||||
start closes the instance that is already running — including one from an older
|
||||
build — and then shows its own window, so a stale window can never stay next to a
|
||||
fresh one. Row edits that were not saved are lost with the replaced window,
|
||||
because saving is always explicit (`Save mappings`).
|
||||
fresh one. The rows of the window it replaced come along, so an edit that was
|
||||
typed but never saved is still there; an instance from an older build cannot hand
|
||||
them over, and then the window shows the saved mappings. Saving itself stays
|
||||
explicit either way: press `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` starts the window minimized in the tray when you log
|
||||
in: it writes the usual autostart entry
|
||||
(`~/.config/autostart/gkeymapper.desktop`, starting the binary that is running
|
||||
with the `--start-hidden` argument) and removes it again when you switch it off;
|
||||
an entry that the desktop's own startup tool has disabled counts as off, too. The
|
||||
window then waits in the tray until its `Show`, and only a desktop that hosts that
|
||||
icon can leave it hidden — one without a tray gets the window a few seconds after
|
||||
login. 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
|
||||
|
||||
@@ -103,7 +179,8 @@ The daemon reads G1–G9 from the keyboard and replays the configured action
|
||||
./gkeymapper-daemon /path/to.conf # or an explicit file
|
||||
```
|
||||
|
||||
- It reaches the keyboard through libusb (raw USB HID, vendor interface 1) and
|
||||
- It reads the keyboard's vendor interface through the kernel's hidraw
|
||||
interface (interface 1) and
|
||||
creates a virtual keyboard via `/dev/uinput`. Both need access for your user;
|
||||
`make install` ships a udev rule (`TAG+="uaccess"`) for that. On Linux Mint the
|
||||
device already has such an ACL, otherwise install the rule or add your user to
|
||||
@@ -228,6 +305,20 @@ so that a `command` action inherits `DISPLAY`/`WAYLAND_DISPLAY`. The unit does
|
||||
not run as root. `make install` reloads the udev rules for you; on a fresh
|
||||
machine replug the keyboard (or reboot) once so the ACL applies.
|
||||
|
||||
## After installation
|
||||
|
||||
If the daemon is not running (the GUI footer says "Daemon: not reachable"):
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now gkeymapper-daemon
|
||||
```
|
||||
|
||||
The daemon needs the user's session for `/dev/uinput` and for the `DISPLAY` a
|
||||
`command` action inherits, so it runs as a **user** service, not a system
|
||||
service. If the commands above fail, check that your user session has systemd
|
||||
running (`systemctl --user status`).
|
||||
|
||||
## Clean
|
||||
|
||||
```bash
|
||||
@@ -287,14 +378,19 @@ src/keyrow.h # KeyRow (one per G key)
|
||||
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/singleinstance.{h,cpp} # one GUI only: a new start replaces a running one,
|
||||
# taking the rows of its window along (unsaved edits)
|
||||
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
|
||||
shared/ipc_ids.h # app id + D-Bus names shared by GUI and daemon
|
||||
daemon/main.cpp # event loop: read G-keys, run the action
|
||||
daemon/device_tables.{h,cpp} # per-model raw HID report tables (G910)
|
||||
daemon/g910_device.{h,cpp} # libusb access to the vendor interface
|
||||
daemon/g910_device.{h,cpp} # hidraw access to the vendor interface
|
||||
daemon/uinput_keyboard.{h,cpp}# virtual keyboard for "key" mappings
|
||||
daemon/action.{h,cpp} # dispatch "key" / "command" / "disabled" / "keyhold"
|
||||
daemon/config_watch.{h,cpp} # inotify watch on mappings.conf (auto-reload)
|
||||
@@ -304,7 +400,8 @@ data/gkeymapper-uinput.conf # modules-load.d fragment for uinput
|
||||
data/gkeymapper-daemon.service# systemd user unit (installed by make install)
|
||||
data/dev.rager.gkeymapper.Daemon.service # D-Bus activation (session bus)
|
||||
docs/daemon-plan.md # daemon plan (reference: g910-gkey-macro-support)
|
||||
po/ # Translations (pot/po/mo targets)
|
||||
po/ # Translations (pot/po/mo targets, po/README.md)
|
||||
scripts/i18n.sh # extract/update/check/status/todo/new for the catalogs
|
||||
Makefile # Build via pkg-config + gettext
|
||||
.vscode/tasks.json # VS Code tasks for make
|
||||
README.md
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
// gKeyMapper daemon - raw USB HID access to the keyboard's vendor interface.
|
||||
// gKeyMapper daemon - raw HID access to the keyboard's vendor interface.
|
||||
//
|
||||
// Uses the kernel's hidraw interface instead of libusb. hidraw provides
|
||||
// non-exclusive access: the kernel driver (usbhid) stays attached, and other
|
||||
// tools (OpenRGB, etc.) can open the same device at the same time. This
|
||||
// avoids the exclusive claim that blocked OpenRGB from detecting the keyboard.
|
||||
|
||||
#include "g910_device.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
// libusb.h uses zero-length arrays, which -Wpedantic flags.
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wpedantic"
|
||||
#include <libusb.h>
|
||||
#pragma GCC diagnostic pop
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <cerrno>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <poll.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <linux/hidraw.h>
|
||||
|
||||
namespace gkeymapper::daemon {
|
||||
|
||||
@@ -16,108 +23,106 @@ GKeyDevice::~GKeyDevice() {
|
||||
close();
|
||||
}
|
||||
|
||||
// Scan /sys/class/hidraw for a node whose HID_ID matches vendor:product and
|
||||
// whose sysfs path points at interface 1 (the vendor-specific one). Returns
|
||||
// the minor number (>= 0) or -1.
|
||||
int GKeyDevice::find_hidraw_node(uint16_t vendor, uint16_t product) {
|
||||
char id_str[64];
|
||||
std::snprintf(id_str, sizeof(id_str), "0003:%08X:%08X", (unsigned)vendor, (unsigned)product);
|
||||
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
char path[512];
|
||||
std::snprintf(path, sizeof(path), "/sys/class/hidraw/hidraw%d/device/uevent", i);
|
||||
FILE* f = std::fopen(path, "r");
|
||||
if (!f) {
|
||||
continue;
|
||||
}
|
||||
char buf[2048];
|
||||
size_t n = std::fread(buf, 1, sizeof(buf) - 1, f);
|
||||
std::fclose(f);
|
||||
buf[n] = '\0';
|
||||
|
||||
// Check HID_ID matches our vendor:product.
|
||||
if (std::strstr(buf, id_str) == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check the sysfs path contains ":1." (interface 1, the vendor one).
|
||||
// The readlink gives us the full device path.
|
||||
char linkpath[512];
|
||||
std::snprintf(linkpath, sizeof(linkpath), "/sys/class/hidraw/hidraw%d", i);
|
||||
char resolved[1024];
|
||||
ssize_t rlen = readlink(linkpath, resolved, sizeof(resolved) - 1);
|
||||
if (rlen < 0) {
|
||||
continue;
|
||||
}
|
||||
resolved[rlen] = '\0';
|
||||
|
||||
// The resolved path contains the interface number in the form
|
||||
// <device>:<config>.<interface>. Interface 1 (vendor-specific) has
|
||||
// config 1, so the path contains ":1.1" (altsetting 1) or ":1.0".
|
||||
// We check the uevent's HID_PHYS instead, which contains "input1"
|
||||
// for interface 1 and "input0" for interface 0.
|
||||
if (std::strstr(buf, "input1") == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool GKeyDevice::open(bool quiet) {
|
||||
if (handle_) {
|
||||
if (fd_ >= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (libusb_init(&context_) != 0) {
|
||||
std::fprintf(stderr, "gkeymapper-daemon: libusb_init failed\n");
|
||||
return false;
|
||||
}
|
||||
// Find a supported profile first (by vendor/product).
|
||||
// We iterate all profiles and try to find a matching hidraw node.
|
||||
const auto& profs = profiles();
|
||||
const DeviceProfile* matched = nullptr;
|
||||
int node = -1;
|
||||
|
||||
libusb_device** devices = nullptr;
|
||||
ssize_t count = libusb_get_device_list(context_, &devices);
|
||||
if (count < 0) {
|
||||
std::fprintf(stderr, "gkeymapper-daemon: no USB devices\n");
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
libusb_device* target = nullptr;
|
||||
for (ssize_t i = 0; i < count && !target; ++i) {
|
||||
libusb_device_descriptor desc{};
|
||||
if (libusb_get_device_descriptor(devices[i], &desc) != 0) {
|
||||
continue;
|
||||
}
|
||||
if (find_profile(desc.idVendor, desc.idProduct) != nullptr) {
|
||||
target = devices[i];
|
||||
profile_ = find_profile(desc.idVendor, desc.idProduct);
|
||||
for (const DeviceProfile& p : profs) {
|
||||
node = find_hidraw_node(p.vendor, p.product);
|
||||
if (node >= 0) {
|
||||
matched = &p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
if (!matched || node < 0) {
|
||||
if (!quiet) {
|
||||
std::fprintf(stderr, "gkeymapper-daemon: no supported keyboard found\n");
|
||||
}
|
||||
libusb_free_device_list(devices, 1);
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
profile_ = matched;
|
||||
|
||||
if (libusb_open(target, &handle_) != 0) {
|
||||
char devpath[64];
|
||||
std::snprintf(devpath, sizeof(devpath), "/dev/hidraw%d", node);
|
||||
|
||||
fd_ = ::open(devpath, O_RDWR | O_NONBLOCK);
|
||||
if (fd_ < 0) {
|
||||
if (!quiet) {
|
||||
std::fprintf(stderr, "gkeymapper-daemon: could not open %s (permissions?)\n",
|
||||
profile_->name);
|
||||
std::fprintf(stderr, "gkeymapper-daemon: could not open %s (%s)\n",
|
||||
devpath, std::strerror(errno));
|
||||
}
|
||||
libusb_free_device_list(devices, 1);
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
libusb_free_device_list(devices, 1);
|
||||
|
||||
if (!find_endpoint()) {
|
||||
close();
|
||||
profile_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detach and claim the vendor interface, like the reference driver.
|
||||
if (libusb_kernel_driver_active(handle_, profile_->interfaceNumber) == 1) {
|
||||
if (libusb_detach_kernel_driver(handle_, profile_->interfaceNumber) == 0) {
|
||||
detached_kernel_driver_ = true;
|
||||
}
|
||||
}
|
||||
if (libusb_claim_interface(handle_, profile_->interfaceNumber) != 0) {
|
||||
if (!quiet) {
|
||||
std::fprintf(stderr, "gkeymapper-daemon: could not claim interface %d\n",
|
||||
profile_->interfaceNumber);
|
||||
}
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
claimed_interface_ = profile_->interfaceNumber;
|
||||
// Disable the default G-key binding (G-keys mapped to F-keys) if needed.
|
||||
// Done after open so disable_binding() can use the fd.
|
||||
// (disable_binding is called separately by main.cpp)
|
||||
|
||||
std::fprintf(stderr, "gkeymapper-daemon: %s found (endpoint 0x%02x)\n",
|
||||
profile_->name, endpoint_);
|
||||
std::fprintf(stderr, "gkeymapper-daemon: %s found (hidraw%d)\n",
|
||||
profile_->name, node);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GKeyDevice::find_endpoint() {
|
||||
libusb_device* device = libusb_get_device(handle_);
|
||||
libusb_config_descriptor* config = nullptr;
|
||||
if (libusb_get_config_descriptor(device, profile_->configuration, &config) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool found = false;
|
||||
for (int i = 0; i < config->bNumInterfaces && !found; ++i) {
|
||||
const libusb_interface& iface = config->interface[i];
|
||||
for (int a = 0; a < iface.num_altsetting && !found; ++a) {
|
||||
const libusb_interface_descriptor& desc = iface.altsetting[a];
|
||||
if (desc.bInterfaceNumber == profile_->interfaceNumber &&
|
||||
desc.bAlternateSetting == profile_->altSetting &&
|
||||
desc.bNumEndpoints > 0) {
|
||||
endpoint_ = desc.endpoint[0].bEndpointAddress;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
libusb_free_config_descriptor(config);
|
||||
return found;
|
||||
}
|
||||
|
||||
bool GKeyDevice::disable_binding() {
|
||||
if (!handle_ || !profile_) {
|
||||
if (fd_ < 0 || !profile_) {
|
||||
return false;
|
||||
}
|
||||
if (!profile_->disableBinding) {
|
||||
@@ -125,19 +130,20 @@ bool GKeyDevice::disable_binding() {
|
||||
}
|
||||
|
||||
for (const Report& command : profile_->disableCommands) {
|
||||
int written = libusb_control_transfer(handle_, 0x21, 0x09, 0x0212,
|
||||
profile_->interfaceNumber,
|
||||
const_cast<unsigned char*>(command.data()),
|
||||
static_cast<uint16_t>(command.size()),
|
||||
1000);
|
||||
if (written < 0) {
|
||||
std::fprintf(stderr, "gkeymapper-daemon: disable-binding write failed (%d)\n",
|
||||
written);
|
||||
// HID SET_REPORT (feature report). The first byte of the command
|
||||
// is the report ID.
|
||||
int len = static_cast<int>(command.size());
|
||||
// HIDIOCSFEATURE takes a buffer where byte 0 is the report ID.
|
||||
std::vector<uint8_t> buf(command.begin(), command.end());
|
||||
int rc = ioctl(fd_, HIDIOCSFEATURE(len), buf.data());
|
||||
if (rc < 0) {
|
||||
std::fprintf(stderr, "gkeymapper-daemon: disable-binding ioctl failed (%s)\n",
|
||||
std::strerror(errno));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the confirmation reports so they do not show up as key events.
|
||||
// Drain confirmation reports.
|
||||
uint8_t scratch[64];
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
if (read_report(scratch, sizeof(scratch), 200) <= 0) {
|
||||
@@ -148,38 +154,44 @@ bool GKeyDevice::disable_binding() {
|
||||
}
|
||||
|
||||
int GKeyDevice::read_report(uint8_t* buffer, int length, int timeoutMs) {
|
||||
if (!handle_) {
|
||||
if (fd_ < 0) {
|
||||
return -1;
|
||||
}
|
||||
int transferred = 0;
|
||||
int rc = libusb_interrupt_transfer(handle_, endpoint_, buffer, length,
|
||||
&transferred, timeoutMs);
|
||||
if (rc == 0) {
|
||||
return transferred;
|
||||
}
|
||||
if (rc == LIBUSB_ERROR_TIMEOUT) {
|
||||
|
||||
struct pollfd pfd;
|
||||
pfd.fd = fd_;
|
||||
pfd.events = POLLIN;
|
||||
pfd.revents = 0;
|
||||
|
||||
int rc = poll(&pfd, 1, timeoutMs);
|
||||
if (rc < 0) {
|
||||
if (errno == EINTR) {
|
||||
return 0;
|
||||
}
|
||||
return rc; // negative
|
||||
return -1;
|
||||
}
|
||||
if (rc == 0) {
|
||||
return 0; // timeout
|
||||
}
|
||||
if (!(pfd.revents & POLLIN)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
ssize_t n = ::read(fd_, buffer, length);
|
||||
if (n < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
return static_cast<int>(n);
|
||||
}
|
||||
|
||||
void GKeyDevice::close() {
|
||||
if (handle_) {
|
||||
if (claimed_interface_ >= 0) {
|
||||
libusb_release_interface(handle_, claimed_interface_);
|
||||
if (fd_ >= 0) {
|
||||
::close(fd_);
|
||||
}
|
||||
if (detached_kernel_driver_) {
|
||||
libusb_attach_kernel_driver(handle_, profile_->interfaceNumber);
|
||||
}
|
||||
libusb_close(handle_);
|
||||
}
|
||||
handle_ = nullptr;
|
||||
if (context_) {
|
||||
libusb_exit(context_);
|
||||
context_ = nullptr;
|
||||
}
|
||||
claimed_interface_ = -1;
|
||||
detached_kernel_driver_ = false;
|
||||
fd_ = -1;
|
||||
profile_ = nullptr;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// gKeyMapper daemon - raw USB HID access to the keyboard's vendor interface.
|
||||
// gKeyMapper daemon - raw HID access to the keyboard's vendor interface.
|
||||
//
|
||||
// Uses the kernel's hidraw interface instead of libusb: hidraw provides
|
||||
// non-exclusive access, so other tools (OpenRGB, etc.) can talk to the same
|
||||
// device at the same time. The kernel driver (usbhid) stays attached.
|
||||
#ifndef GKEYMAPPER_DAEMON_GKEY_DEVICE_H
|
||||
#define GKEYMAPPER_DAEMON_GKEY_DEVICE_H
|
||||
|
||||
@@ -6,9 +10,6 @@
|
||||
|
||||
#include "device_tables.h"
|
||||
|
||||
struct libusb_context;
|
||||
struct libusb_device_handle;
|
||||
|
||||
namespace gkeymapper::daemon {
|
||||
|
||||
class GKeyDevice {
|
||||
@@ -19,15 +20,15 @@ public:
|
||||
GKeyDevice(const GKeyDevice&) = delete;
|
||||
GKeyDevice& operator=(const GKeyDevice&) = delete;
|
||||
|
||||
// Finds a supported keyboard, opens it and detaches the kernel driver from
|
||||
// the vendor interface. Returns false (and logs) if none is found.
|
||||
// Finds a supported keyboard and opens its hidraw node. Returns false (and
|
||||
// logs) if none is found.
|
||||
//
|
||||
// `quiet` silences the "cannot use the keyboard" lines only, which the
|
||||
// hot-plug retry uses so an unplugged keyboard does not flood the journal
|
||||
// with one line every retry; libusb-level failures always log.
|
||||
// with one line every retry.
|
||||
bool open(bool quiet = false);
|
||||
void close();
|
||||
bool is_open() const { return handle_ != nullptr; }
|
||||
bool is_open() const { return fd_ >= 0; }
|
||||
|
||||
// Sends the "stop mapping G-keys to F-keys" command if the model needs it.
|
||||
bool disable_binding();
|
||||
@@ -39,14 +40,10 @@ public:
|
||||
const DeviceProfile* profile() const { return profile_; }
|
||||
|
||||
private:
|
||||
bool find_endpoint();
|
||||
int find_hidraw_node(uint16_t vendor, uint16_t product);
|
||||
|
||||
libusb_context* context_ = nullptr;
|
||||
libusb_device_handle* handle_ = nullptr;
|
||||
int fd_ = -1;
|
||||
const DeviceProfile* profile_ = nullptr;
|
||||
uint8_t endpoint_ = 0;
|
||||
int claimed_interface_ = -1;
|
||||
bool detached_kernel_driver_ = false;
|
||||
};
|
||||
|
||||
} // namespace gkeymapper::daemon
|
||||
|
||||
@@ -361,6 +361,45 @@ 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.
|
||||
Closing the old window would drop row edits that were never saved, so the
|
||||
takeover asks it for its rows first (`GetRows` at `/dev/rager/gkeymapper/Window`,
|
||||
see `shared/ipc_ids.h`) and the new `build_ui()` starts from that mappings text
|
||||
instead of from `mappings.conf`. An instance that does not answer — an older
|
||||
build — leaves the new window with the file, and the file stays untouched until
|
||||
the user presses `Save mappings`.
|
||||
- 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 +407,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/
|
||||
|
||||
178
po/README.md
Normal file
178
po/README.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Translations (i18n)
|
||||
|
||||
Everything about the catalogs, in one place: which file is which, how a string gets
|
||||
from the source code into a translated window, and how to add a language. The
|
||||
commands live in `scripts/i18n.sh`, wired into the Makefile as `pot`,
|
||||
`i18n-check` and `i18n-status`.
|
||||
|
||||
## The pieces
|
||||
|
||||
| file | what it is |
|
||||
| --- | --- |
|
||||
| `po/gkeymapper.pot` | the template: every translatable string in the sources, with the file and line it comes from. **Generated** by `make pot` — never translated, and its msgids are never edited by hand. |
|
||||
| `po/<lang>.po` | one catalog per language. This is what a translator edits. |
|
||||
| `locale/<lang>/LC_MESSAGES/gkeymapper.mo` | the compiled catalog the program reads at runtime. Generated by `make translations`, gitignored. **Never edit** — it is overwritten on every build. |
|
||||
| `scripts/i18n.sh` | `extract`, `update`, `check`, `status`, `todo`, `new` (see below). |
|
||||
|
||||
Languages: `de`, `en`, `es`, `fr`. The set is `po/*.po` — the Makefile picks up a
|
||||
new catalog by itself (`LANGS`), nothing has to be registered.
|
||||
|
||||
`en.po` is special: English is the source language, and its catalog repeats every
|
||||
msgid as its msgstr (an *identity* catalog). It exists so that choosing
|
||||
"English" works on a German desktop, where the session locale would otherwise
|
||||
select the German catalog. `i18n-check` enforces the identity — a translation that
|
||||
drifts in is reported.
|
||||
|
||||
## How a string reaches the window
|
||||
|
||||
1. `_("...")` marks it in `src/` (or `shared/`) — the two directories the GUI is
|
||||
built from. `N_("...")` is for strings that are only *collected* when they are
|
||||
not translated yet: `src/help.cpp` keeps its paragraphs in a static array and
|
||||
translates them when the window is built (`_(text)`). Both keywords are in the
|
||||
pot; a string marked with neither is invisible to translators.
|
||||
2. `make pot` writes `po/gkeymapper.pot` from the sources.
|
||||
3. `scripts/i18n.sh update` merges the pot into every catalog. New strings come in
|
||||
with an empty msgstr (= untranslated), existing translations are kept.
|
||||
4. Translating is editing `po/<lang>.po`: replace the msgstr, leave the msgid
|
||||
alone. Any text editor, or `poedit` if you prefer one.
|
||||
5. `make translations` compiles `locale/<lang>/LC_MESSAGES/gkeymapper.mo`.
|
||||
6. The program reads the catalog whose language it was started with. The GUI picks
|
||||
it up automatically once gettext is set up in `src/main.cpp`; *which* language is
|
||||
used comes from the options window (stored in `~/.config/gKeyMapper/gui.conf`,
|
||||
`language=de`), or from the session when that setting is empty. Internally
|
||||
`apply_gui_language()` (`src/settings.cpp`) sets `LANGUAGE` and re-runs
|
||||
`setlocale` — `LANGUAGE` wins over `LANG`/`LC_ALL` in gettext, which is what
|
||||
makes switching without a restart and an explicit `en` on a German desktop work.
|
||||
The catalogs are searched under `$GKEYMAPPER_LOCALEDIR`, falling back to the
|
||||
compiled-in `LOCALEDIR`.
|
||||
|
||||
## The common case: a string is added or changed
|
||||
|
||||
```bash
|
||||
# 1. in the code
|
||||
_("New text") # or N_("...") for a deferred one
|
||||
|
||||
# 2. the template
|
||||
make pot # prints what is new and what is gone
|
||||
|
||||
# 3. what is missing where
|
||||
make i18n-check # names every catalog that lacks it
|
||||
|
||||
# 4. every catalog takes it in (empty msgstr)
|
||||
scripts/i18n.sh update # or: scripts/i18n.sh update de
|
||||
|
||||
# 5. translate
|
||||
scripts/i18n.sh todo de # untranslated + fuzzy, with the source location
|
||||
$EDITOR po/de.po # and es, fr - and en, where the msgstr is the msgid
|
||||
|
||||
# 6. check and compile
|
||||
make i18n-check # must print "everything in sync"
|
||||
make translations
|
||||
|
||||
# 7. see it
|
||||
GKEYMAPPER_LOCALEDIR=$PWD/locale LANGUAGE=de_DE.UTF-8 ./gkeymapper
|
||||
```
|
||||
|
||||
Commit the source change, `po/gkeymapper.pot` and the catalogs **together**: a pot
|
||||
without the catalogs is a half-done change, and `make i18n-check` says so.
|
||||
|
||||
A new string in one place and the same text in another is *one* message for
|
||||
gettext — the pot lists both locations, and one msgstr covers both.
|
||||
|
||||
## Adding a language
|
||||
|
||||
```bash
|
||||
scripts/i18n.sh new it Italiano # po/it.po, header in the project's shape,
|
||||
# every msgstr empty (the second word is
|
||||
# Language-Team; it defaults to the code)
|
||||
scripts/i18n.sh todo it # work through it
|
||||
$EDITOR po/it.po
|
||||
```
|
||||
|
||||
Then offer it in the GUI, or nobody can select it — `src/optionsdialog.cpp`:
|
||||
|
||||
* `kLanguageCodes[]` — the codes, in the same order as the labels ("", the empty
|
||||
one, means "follow the session" and has to stay first);
|
||||
* `languageOptions[]` — the labels. Use the **endonym** ("Italiano", not
|
||||
"Italian"): the list reads the same in every language, so a user who lands in
|
||||
the wrong language can still find their own.
|
||||
|
||||
`make i18n-check` checks exactly that pairing (a catalog that cannot be selected, a
|
||||
code without a catalog, two arrays of different length). `make translations`
|
||||
compiles the new catalog; `make run` and the test command above use the working
|
||||
tree's `locale/`.
|
||||
|
||||
## Commands
|
||||
|
||||
| command | what it does |
|
||||
| --- | --- |
|
||||
| `scripts/i18n.sh extract` (`make pot`) | Regenerate `po/gkeymapper.pot` from the sources. Prints the messages that appeared and disappeared. Refuses to write an empty pot, so a broken gettext cannot empty the good one. |
|
||||
| `scripts/i18n.sh update [lang...]` | Merge the pot into the catalogs, without source locations (see Conventions). Keeps translations, marks new strings untranslated. |
|
||||
| `scripts/i18n.sh check` (`make i18n-check`) | Everything below. Exit code 1 when something is wrong, so it also works as a pre-commit step. |
|
||||
| `scripts/i18n.sh status` (`make i18n-status`) | Per language: messages, translated, fuzzy, untranslated, and whether the compiled catalog is older than the po. |
|
||||
| `scripts/i18n.sh todo <lang>` | The untranslated and fuzzy messages, with the source locations taken from the pot. The output is a valid po file, if you want to keep it. |
|
||||
| `scripts/i18n.sh new <lang> [team]` | Create `po/<lang>.po` from the pot. `new en` writes the identity catalog. |
|
||||
|
||||
The gettext tools are taken from `PATH` and can be overridden like the Makefile's
|
||||
variables, e.g. `XGETTEXT=/usr/bin/xgettext scripts/i18n.sh extract`.
|
||||
|
||||
## What `check` looks at
|
||||
|
||||
* the pot against the sources: same messages? A string added or changed without
|
||||
`make pot` is the mistake this catches (a `#:` line number that moved is only
|
||||
reported as a note — refresh it whenever it suits you);
|
||||
* every catalog: it parses (`msgfmt -c`, which also compares the format
|
||||
specifiers of msgid and msgstr), it defines the same messages as the pot, nothing
|
||||
is untranslated or fuzzy, and its `Language:` header matches its file name;
|
||||
* `po/en.po` is an identity catalog;
|
||||
* `po/*.po` and the language list of the options window (`src/optionsdialog.cpp`)
|
||||
agree;
|
||||
* no strings are marked in `daemon/`: the daemon has no gettext setup, so `_()`
|
||||
there would be translated into a catalog that is never read.
|
||||
|
||||
Notes that are *not* failures: catalogs that were not merged since the last
|
||||
extract, and obsolete entries (`#~`, strings that are gone from the sources —
|
||||
`msgattrib --no-obsolete po/<lang>.po > tmp && mv tmp po/<lang>.po` drops them).
|
||||
|
||||
## Conventions
|
||||
|
||||
* **No `#: file:line` comments in the catalogs.** The locations are noise for a
|
||||
translator and churn whenever a line moves, so they live only in the pot
|
||||
(`update` therefore uses `msgmerge --no-location`) and `todo` shows them from
|
||||
there when they are needed. This is also why the catalogs are merged with the
|
||||
script instead of a bare `msgmerge --update`, which would copy the locations in.
|
||||
* **The build does not regenerate the pot** (`make translations` only runs
|
||||
`msgfmt`). A template that is rewritten by every build is a template nobody
|
||||
looks at; instead `make pot` is a deliberate step and `make i18n-check` fails
|
||||
when it was forgotten.
|
||||
* **Mnemonics stay mnemonics.** `_Cancel` uses the `_` to underline a letter;
|
||||
`_Abbrechen` moves it to a letter that exists in that language. A translation
|
||||
without the underscore loses the keyboard shortcut silently.
|
||||
* **Format specifiers** (`%s`, `%d`) keep their number and order — `msgfmt -c`
|
||||
catches a mismatch.
|
||||
* **No plurals yet**: the program only uses `_()`, so no catalog carries a
|
||||
`Plural-Forms` header. With the first `ngettext()` that header has to go into
|
||||
every catalog (and `scripts/i18n.sh new` should write it).
|
||||
* **HTML-ish markup** in a msgid (`<b>`, `<i>`) has to survive in the msgstr; the
|
||||
help window renders it.
|
||||
* German belongs in `po/de.po` only — sources, comments and documentation are
|
||||
English.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
* *A window stays English while another window is translated* — the string is
|
||||
missing from the catalog (or the catalog was not recompiled: `make translations`).
|
||||
`scripts/i18n.sh todo <lang>` lists what is open.
|
||||
* *Shortcut without an underline* — the mnemonic `_` is missing in the msgstr.
|
||||
* *`po/*.po~` files appear* — that is `msgmerge`'s backup from a manual
|
||||
`msgmerge --update`; the script deletes it. Never commit them.
|
||||
* *A fuzzy entry is shown in English* — on purpose: fuzzy means "the text was
|
||||
changed, this translation needs a look", and gettext falls back to the msgid
|
||||
until it is confirmed (remove the `#, fuzzy` line).
|
||||
* *"not merged against the current pot" in `check`* — run `scripts/i18n.sh update`;
|
||||
until then the catalog header carries an older date than the pot.
|
||||
* *Nothing is extracted at all* — `xgettext`'s built-in keyword list is not the
|
||||
same everywhere, so the script always passes `--keyword=_ --keyword=N_`. If the
|
||||
pot comes out empty, the script stops instead of replacing the pot.
|
||||
|
||||
The rest of the project's conventions (GUI, daemon, mappings) are in `AGENTS.md`.
|
||||
262
po/de.po
262
po/de.po
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: gkeymapper 0.1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-18 03:08+0200\n"
|
||||
"POT-Creation-Date: 2026-10-18 22:01+0200\n"
|
||||
"PO-Revision-Date: 2026-10-18 03:08+0200\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
@@ -15,14 +15,8 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
msgid "Choose file"
|
||||
msgstr "Datei auswählen"
|
||||
|
||||
msgid "_Cancel"
|
||||
msgstr "_Abbrechen"
|
||||
|
||||
msgid "_Open"
|
||||
msgstr "_Öffnen"
|
||||
msgid "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
msgstr "G-Key-Mapper für die Logitech G910 (GTK4)."
|
||||
|
||||
msgid "No keys recorded yet"
|
||||
msgstr "Noch keine Tasten aufgezeichnet"
|
||||
@@ -30,6 +24,14 @@ msgstr "Noch keine Tasten aufgezeichnet"
|
||||
msgid "Capture key sequence"
|
||||
msgstr "Tastensequenz aufnehmen"
|
||||
|
||||
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 ""
|
||||
"Tasten der Reihe nach drücken (Modifikatoren wie Strg, Alt, Shift, Super und "
|
||||
"AltGr werden mit aufgezeichnet). Mit Fertig übernehmen oder mit Abbrechen "
|
||||
"verwerfen."
|
||||
|
||||
msgid "Clear"
|
||||
msgstr "Leeren"
|
||||
|
||||
@@ -40,12 +42,123 @@ msgid "Done"
|
||||
msgstr "Fertig"
|
||||
|
||||
msgid ""
|
||||
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
|
||||
"recorded too). Click Done to apply or Cancel to discard."
|
||||
"<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 ""
|
||||
"Tasten der Reihe nach drücken (Modifikatoren wie Strg, Alt, Shift, Super und "
|
||||
"AltGr werden mit aufgezeichnet). Mit Fertig übernehmen oder mit Abbrechen "
|
||||
"verwerfen."
|
||||
"<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> opens this window minimized "
|
||||
"to the tray 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> startet dieses Fenster "
|
||||
"minimiert im Infobereich. 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 yet come "
|
||||
"along."
|
||||
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 – noch nicht gespeicherte Zeilen kommen dabei mit."
|
||||
|
||||
msgid "Help"
|
||||
msgstr "Hilfe"
|
||||
|
||||
msgid "Close window"
|
||||
msgstr "Fenster schließen"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Daemon: nicht erreichbar"
|
||||
|
||||
msgid "Run: systemctl --user enable --now gkeymapper-daemon"
|
||||
msgstr "Ausführen: systemctl --user enable --now gkeymapper-daemon"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Daemon: läuft"
|
||||
|
||||
msgid "Device: not found"
|
||||
msgstr "Gerät: nicht gefunden"
|
||||
|
||||
#, c-format
|
||||
msgid "Device: %s"
|
||||
msgstr "Gerät: %s"
|
||||
|
||||
msgid "Configuration error"
|
||||
msgstr "Konfigurationsfehler"
|
||||
|
||||
#, c-format
|
||||
msgid "Last key: %s"
|
||||
msgstr "Letzte Taste: %s"
|
||||
|
||||
msgid "Choose file"
|
||||
msgstr "Datei auswählen"
|
||||
|
||||
msgid "_Cancel"
|
||||
msgstr "_Abbrechen"
|
||||
|
||||
msgid "_Open"
|
||||
msgstr "_Öffnen"
|
||||
|
||||
msgid "Choose file..."
|
||||
msgstr "Datei auswählen..."
|
||||
@@ -56,12 +169,12 @@ msgstr "z. B. /usr/bin/firefox oder eigener Befehl"
|
||||
msgid "Capture"
|
||||
msgstr "Aufnehmen"
|
||||
|
||||
msgid "e.g. ctrl+c, ctrl+v"
|
||||
msgstr "z. B. Strg+C, Strg+V"
|
||||
|
||||
msgid "e.g. ctrl+w (held while the G key is held)"
|
||||
msgstr "z. B. Strg+W (gehalten, solange die G-Taste gedrückt ist)"
|
||||
|
||||
msgid "e.g. ctrl+c, ctrl+v"
|
||||
msgstr "z. B. Strg+C, Strg+V"
|
||||
|
||||
#, c-format
|
||||
msgid "Could not save %s.\n"
|
||||
msgstr "Konnte %s nicht speichern.\n"
|
||||
@@ -69,19 +182,12 @@ msgstr "Konnte %s nicht speichern.\n"
|
||||
msgid "Mappings saved to "
|
||||
msgstr "Mappings gespeichert nach "
|
||||
|
||||
#, c-format
|
||||
msgid "Could not open %s.\n"
|
||||
msgstr "Konnte %s nicht öffnen.\n"
|
||||
|
||||
msgid "Could not export mappings.\n"
|
||||
msgstr "Mappings konnten nicht exportiert werden.\n"
|
||||
|
||||
msgid "gKeyMapper"
|
||||
msgstr "gKeyMapper"
|
||||
|
||||
msgid "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
msgstr "G-Key-Mapper für die Logitech G910 (GTK4)."
|
||||
|
||||
msgid "Export configuration"
|
||||
msgstr "Konfiguration exportieren"
|
||||
|
||||
@@ -100,9 +206,15 @@ msgstr "Konfiguration exportieren..."
|
||||
msgid "Import configuration..."
|
||||
msgstr "Konfiguration importieren..."
|
||||
|
||||
msgid "Options"
|
||||
msgstr "Optionen"
|
||||
|
||||
msgid "About"
|
||||
msgstr "Über"
|
||||
|
||||
msgid "Quit"
|
||||
msgstr "Beenden"
|
||||
|
||||
msgid "G1 to G9 of the Logitech G910 remapped to keys or commands"
|
||||
msgstr "G1 bis G9 der Logitech G910 auf Tasten oder Befehle ummappen"
|
||||
|
||||
@@ -136,23 +248,89 @@ msgstr "Mappings speichern"
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
msgstr "Gespeichert in ~/.config/gKeyMapper/mappings.conf"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Daemon: nicht erreichbar"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Daemon: läuft"
|
||||
|
||||
msgid "Device: %s"
|
||||
msgstr "Gerät: %s"
|
||||
|
||||
msgid "Device: not found"
|
||||
msgstr "Gerät: nicht gefunden"
|
||||
|
||||
msgid "Configuration error"
|
||||
msgstr "Konfigurationsfehler"
|
||||
|
||||
msgid "Last key: G%d → %s"
|
||||
msgstr "Letzte Taste: G%d → %s"
|
||||
|
||||
msgid "Daemon: checking..."
|
||||
msgstr "Daemon: wird geprüft ..."
|
||||
|
||||
#, c-format
|
||||
msgid "Could not open %s.\n"
|
||||
msgstr "Konnte %s nicht öffnen.\n"
|
||||
|
||||
msgid "Window"
|
||||
msgstr "Fenster"
|
||||
|
||||
msgid "Minimize to tray"
|
||||
msgstr "In den Infobereich minimieren"
|
||||
|
||||
msgid "Start with the session"
|
||||
msgstr "Beim Anmelden starten"
|
||||
|
||||
msgid ""
|
||||
"Opens this window minimized to the tray 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 minimiert im Infobereich. Für die G-"
|
||||
"Tasten nicht nötig – die Arbeit macht der Daemon, und nur der muss laufen."
|
||||
|
||||
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 "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 ""
|
||||
"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 "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 "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 "Show"
|
||||
msgstr "Anzeigen"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Beenden"
|
||||
|
||||
254
po/en.po
254
po/en.po
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: gkeymapper 0.1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-18 03:08+0200\n"
|
||||
"POT-Creation-Date: 2026-10-18 22:01+0200\n"
|
||||
"PO-Revision-Date: 2026-10-18 03:08+0200\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -16,14 +16,8 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
msgid "Choose file"
|
||||
msgstr "Choose file"
|
||||
|
||||
msgid "_Cancel"
|
||||
msgstr "_Cancel"
|
||||
|
||||
msgid "_Open"
|
||||
msgstr "_Open"
|
||||
msgid "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
msgstr "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
|
||||
msgid "No keys recorded yet"
|
||||
msgstr "No keys recorded yet"
|
||||
@@ -31,6 +25,13 @@ msgstr "No keys recorded yet"
|
||||
msgid "Capture key sequence"
|
||||
msgstr "Capture key sequence"
|
||||
|
||||
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 ""
|
||||
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
|
||||
"recorded too). Click Done to apply or Cancel to discard."
|
||||
|
||||
msgid "Clear"
|
||||
msgstr "Clear"
|
||||
|
||||
@@ -41,11 +42,120 @@ msgid "Done"
|
||||
msgstr "Done"
|
||||
|
||||
msgid ""
|
||||
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
|
||||
"recorded too). Click Done to apply or Cancel to discard."
|
||||
"<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 ""
|
||||
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
|
||||
"recorded too). Click Done to apply or Cancel to discard."
|
||||
"<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> opens this window minimized "
|
||||
"to the tray 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> opens this window minimized "
|
||||
"to the tray 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 yet come "
|
||||
"along."
|
||||
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 yet come "
|
||||
"along."
|
||||
|
||||
msgid "Help"
|
||||
msgstr "Help"
|
||||
|
||||
msgid "Close window"
|
||||
msgstr "Close window"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Daemon: not reachable"
|
||||
|
||||
msgid "Run: systemctl --user enable --now gkeymapper-daemon"
|
||||
msgstr "Run: systemctl --user enable --now gkeymapper-daemon"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Daemon: running"
|
||||
|
||||
msgid "Device: not found"
|
||||
msgstr "Device: not found"
|
||||
|
||||
#, c-format
|
||||
msgid "Device: %s"
|
||||
msgstr "Device: %s"
|
||||
|
||||
msgid "Configuration error"
|
||||
msgstr "Configuration error"
|
||||
|
||||
#, c-format
|
||||
msgid "Last key: %s"
|
||||
msgstr "Last key: %s"
|
||||
|
||||
msgid "Choose file"
|
||||
msgstr "Choose file"
|
||||
|
||||
msgid "_Cancel"
|
||||
msgstr "_Cancel"
|
||||
|
||||
msgid "_Open"
|
||||
msgstr "_Open"
|
||||
|
||||
msgid "Choose file..."
|
||||
msgstr "Choose file..."
|
||||
@@ -56,12 +166,12 @@ msgstr "e.g. /usr/bin/firefox or custom command"
|
||||
msgid "Capture"
|
||||
msgstr "Capture"
|
||||
|
||||
msgid "e.g. ctrl+c, ctrl+v"
|
||||
msgstr "e.g. ctrl+c, ctrl+v"
|
||||
|
||||
msgid "e.g. ctrl+w (held while the G key is held)"
|
||||
msgstr "e.g. ctrl+w (held while the G key is held)"
|
||||
|
||||
msgid "e.g. ctrl+c, ctrl+v"
|
||||
msgstr "e.g. ctrl+c, ctrl+v"
|
||||
|
||||
#, c-format
|
||||
msgid "Could not save %s.\n"
|
||||
msgstr "Could not save %s.\n"
|
||||
@@ -69,19 +179,12 @@ msgstr "Could not save %s.\n"
|
||||
msgid "Mappings saved to "
|
||||
msgstr "Mappings saved to "
|
||||
|
||||
#, c-format
|
||||
msgid "Could not open %s.\n"
|
||||
msgstr "Could not open %s.\n"
|
||||
|
||||
msgid "Could not export mappings.\n"
|
||||
msgstr "Could not export mappings.\n"
|
||||
|
||||
msgid "gKeyMapper"
|
||||
msgstr "gKeyMapper"
|
||||
|
||||
msgid "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
msgstr "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
|
||||
msgid "Export configuration"
|
||||
msgstr "Export configuration"
|
||||
|
||||
@@ -100,9 +203,15 @@ msgstr "Export configuration..."
|
||||
msgid "Import configuration..."
|
||||
msgstr "Import configuration..."
|
||||
|
||||
msgid "Options"
|
||||
msgstr "Options"
|
||||
|
||||
msgid "About"
|
||||
msgstr "About"
|
||||
|
||||
msgid "Quit"
|
||||
msgstr "Quit"
|
||||
|
||||
msgid "G1 to G9 of the Logitech G910 remapped to keys or commands"
|
||||
msgstr "G1 to G9 of the Logitech G910 remapped to keys or commands"
|
||||
|
||||
@@ -136,23 +245,86 @@ msgstr "Save mappings"
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
msgstr "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Daemon: not reachable"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Daemon: running"
|
||||
|
||||
msgid "Device: %s"
|
||||
msgstr "Device: %s"
|
||||
|
||||
msgid "Device: not found"
|
||||
msgstr "Device: not found"
|
||||
|
||||
msgid "Configuration error"
|
||||
msgstr "Configuration error"
|
||||
|
||||
msgid "Last key: G%d → %s"
|
||||
msgstr "Last key: G%d → %s"
|
||||
|
||||
msgid "Daemon: checking..."
|
||||
msgstr "Daemon: checking..."
|
||||
|
||||
#, c-format
|
||||
msgid "Could not open %s.\n"
|
||||
msgstr "Could not open %s.\n"
|
||||
|
||||
msgid "Window"
|
||||
msgstr "Window"
|
||||
|
||||
msgid "Minimize to tray"
|
||||
msgstr "Minimize to tray"
|
||||
|
||||
msgid "Start with the session"
|
||||
msgstr "Start with the session"
|
||||
|
||||
msgid ""
|
||||
"Opens this window minimized to the tray 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 minimized to the tray 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 "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 "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 ""
|
||||
"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 "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 "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 "Show"
|
||||
msgstr "Show"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
260
po/es.po
260
po/es.po
@@ -4,7 +4,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: gkeymapper 0.1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-18 03:08+0200\n"
|
||||
"POT-Creation-Date: 2026-10-18 22:01+0200\n"
|
||||
"PO-Revision-Date: 2026-10-18 03:08+0200\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
@@ -13,14 +13,8 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
msgid "Choose file"
|
||||
msgstr "Elegir archivo"
|
||||
|
||||
msgid "_Cancel"
|
||||
msgstr "_Cancelar"
|
||||
|
||||
msgid "_Open"
|
||||
msgstr "_Abrir"
|
||||
msgid "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
msgstr "Asignador de teclas G para el Logitech G910 (GTK4)."
|
||||
|
||||
msgid "No keys recorded yet"
|
||||
msgstr "Aún no se ha registrado ninguna tecla"
|
||||
@@ -28,6 +22,14 @@ msgstr "Aún no se ha registrado ninguna tecla"
|
||||
msgid "Capture key sequence"
|
||||
msgstr "Capturar secuencia de teclas"
|
||||
|
||||
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 ""
|
||||
"Pulsa las teclas en orden (los modificadores como Ctrl, Alt, Mayús, Super y "
|
||||
"AltGr también se registran). Haz clic en Aceptar para aplicar o en Cancelar "
|
||||
"para descartar."
|
||||
|
||||
msgid "Clear"
|
||||
msgstr "Borrar"
|
||||
|
||||
@@ -38,12 +40,122 @@ msgid "Done"
|
||||
msgstr "Aceptar"
|
||||
|
||||
msgid ""
|
||||
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
|
||||
"recorded too). Click Done to apply or Cancel to discard."
|
||||
"<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 ""
|
||||
"Pulsa las teclas en orden (los modificadores como Ctrl, Alt, Mayús, Super y "
|
||||
"AltGr también se registran). Haz clic en Aceptar para aplicar o en Cancelar "
|
||||
"para descartar."
|
||||
"<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> opens this window minimized "
|
||||
"to the tray 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> abre esta ventana "
|
||||
"minimizada en la bandeja al iniciar la sesión. 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 yet come "
|
||||
"along."
|
||||
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 aún sin "
|
||||
"guardar se conservan."
|
||||
|
||||
msgid "Help"
|
||||
msgstr "Ayuda"
|
||||
|
||||
msgid "Close window"
|
||||
msgstr "Cerrar ventana"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Demonio: no accesible"
|
||||
|
||||
msgid "Run: systemctl --user enable --now gkeymapper-daemon"
|
||||
msgstr "Ejecutar: systemctl --user enable --now gkeymapper-daemon"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Demonio: en ejecución"
|
||||
|
||||
msgid "Device: not found"
|
||||
msgstr "Dispositivo: no encontrado"
|
||||
|
||||
#, c-format
|
||||
msgid "Device: %s"
|
||||
msgstr "Dispositivo: %s"
|
||||
|
||||
msgid "Configuration error"
|
||||
msgstr "Error de configuración"
|
||||
|
||||
#, c-format
|
||||
msgid "Last key: %s"
|
||||
msgstr "Última tecla: %s"
|
||||
|
||||
msgid "Choose file"
|
||||
msgstr "Elegir archivo"
|
||||
|
||||
msgid "_Cancel"
|
||||
msgstr "_Cancelar"
|
||||
|
||||
msgid "_Open"
|
||||
msgstr "_Abrir"
|
||||
|
||||
msgid "Choose file..."
|
||||
msgstr "Elegir archivo..."
|
||||
@@ -54,12 +166,12 @@ msgstr "p. ej. /usr/bin/firefox o comando personalizado"
|
||||
msgid "Capture"
|
||||
msgstr "Capturar"
|
||||
|
||||
msgid "e.g. ctrl+c, ctrl+v"
|
||||
msgstr "p. ej. ctrl+c, ctrl+v"
|
||||
|
||||
msgid "e.g. ctrl+w (held while the G key is held)"
|
||||
msgstr "p. ej. ctrl+w (mantenida mientras se mantiene pulsada la tecla G)"
|
||||
|
||||
msgid "e.g. ctrl+c, ctrl+v"
|
||||
msgstr "p. ej. ctrl+c, ctrl+v"
|
||||
|
||||
#, c-format
|
||||
msgid "Could not save %s.\n"
|
||||
msgstr "No se pudo guardar %s.\n"
|
||||
@@ -67,19 +179,12 @@ msgstr "No se pudo guardar %s.\n"
|
||||
msgid "Mappings saved to "
|
||||
msgstr "Asignaciones guardadas en "
|
||||
|
||||
#, c-format
|
||||
msgid "Could not open %s.\n"
|
||||
msgstr "No se pudo abrir %s.\n"
|
||||
|
||||
msgid "Could not export mappings.\n"
|
||||
msgstr "No se pudieron exportar las asignaciones.\n"
|
||||
|
||||
msgid "gKeyMapper"
|
||||
msgstr "gKeyMapper"
|
||||
|
||||
msgid "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
msgstr "Asignador de teclas G para el Logitech G910 (GTK4)."
|
||||
|
||||
msgid "Export configuration"
|
||||
msgstr "Exportar configuración"
|
||||
|
||||
@@ -98,9 +203,15 @@ msgstr "Exportar configuración..."
|
||||
msgid "Import configuration..."
|
||||
msgstr "Importar configuración..."
|
||||
|
||||
msgid "Options"
|
||||
msgstr "Opciones"
|
||||
|
||||
msgid "About"
|
||||
msgstr "Acerca de"
|
||||
|
||||
msgid "Quit"
|
||||
msgstr "Salir"
|
||||
|
||||
msgid "G1 to G9 of the Logitech G910 remapped to keys or commands"
|
||||
msgstr "Reasigna las G1 a G9 del Logitech G910 a teclas o comandos"
|
||||
|
||||
@@ -134,23 +245,88 @@ msgstr "Guardar asignaciones"
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
msgstr "Guardado en ~/.config/gKeyMapper/mappings.conf"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Demonio: no accesible"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Demonio: en ejecución"
|
||||
|
||||
msgid "Device: %s"
|
||||
msgstr "Dispositivo: %s"
|
||||
|
||||
msgid "Device: not found"
|
||||
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 "Daemon: checking..."
|
||||
msgstr "Demonio: comprobando..."
|
||||
|
||||
#, c-format
|
||||
msgid "Could not open %s.\n"
|
||||
msgstr "No se pudo abrir %s.\n"
|
||||
|
||||
msgid "Window"
|
||||
msgstr "Ventana"
|
||||
|
||||
msgid "Minimize to tray"
|
||||
msgstr "Minimizar a la bandeja"
|
||||
|
||||
msgid "Start with the session"
|
||||
msgstr "Iniciar con la sesión"
|
||||
|
||||
msgid ""
|
||||
"Opens this window minimized to the tray 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 minimizada en la bandeja 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 "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 "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 ""
|
||||
"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 "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 "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 "Show"
|
||||
msgstr "Mostrar"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Cerrar"
|
||||
|
||||
271
po/fr.po
271
po/fr.po
@@ -4,7 +4,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: gkeymapper 0.1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-18 03:08+0200\n"
|
||||
"POT-Creation-Date: 2026-10-18 22:01+0200\n"
|
||||
"PO-Revision-Date: 2026-10-18 03:08+0200\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
@@ -13,14 +13,8 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
msgid "Choose file"
|
||||
msgstr "Choisir un fichier"
|
||||
|
||||
msgid "_Cancel"
|
||||
msgstr "_Annuler"
|
||||
|
||||
msgid "_Open"
|
||||
msgstr "_Ouvrir"
|
||||
msgid "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
msgstr "Assignateur de touches G pour le Logitech G910 (GTK4)."
|
||||
|
||||
msgid "No keys recorded yet"
|
||||
msgstr "Aucune touche enregistrée pour le moment"
|
||||
@@ -28,6 +22,14 @@ msgstr "Aucune touche enregistrée pour le moment"
|
||||
msgid "Capture key sequence"
|
||||
msgstr "Capturer une séquence de touches"
|
||||
|
||||
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 ""
|
||||
"Appuyez sur les touches dans l'ordre (les modificateurs comme Ctrl, Alt, "
|
||||
"Maj, Super et AltGr sont également enregistrés). Cliquez sur Terminer pour "
|
||||
"appliquer ou sur Annuler pour abandonner."
|
||||
|
||||
msgid "Clear"
|
||||
msgstr "Effacer"
|
||||
|
||||
@@ -38,12 +40,125 @@ msgid "Done"
|
||||
msgstr "Terminer"
|
||||
|
||||
msgid ""
|
||||
"Press keys in order (modifiers like Ctrl, Alt, Shift, Super and AltGr are "
|
||||
"recorded too). Click Done to apply or Cancel to discard."
|
||||
"<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 ""
|
||||
"Appuyez sur les touches dans l'ordre (les modificateurs comme Ctrl, Alt, "
|
||||
"Maj, Super et AltGr sont également enregistrés). Cliquez sur Terminer pour "
|
||||
"appliquer ou sur Annuler pour abandonner."
|
||||
"<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> opens this window minimized "
|
||||
"to the tray 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 cette "
|
||||
"fenêtre réduite dans la zone de notification à l'ouverture de la session. "
|
||||
"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 yet come "
|
||||
"along."
|
||||
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 pas encore enregistrées "
|
||||
"sont conservées."
|
||||
|
||||
msgid "Help"
|
||||
msgstr "Aide"
|
||||
|
||||
msgid "Close window"
|
||||
msgstr "Fermer la fenêtre"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Démon : injoignable"
|
||||
|
||||
msgid "Run: systemctl --user enable --now gkeymapper-daemon"
|
||||
msgstr "Exécuter : systemctl --user enable --now gkeymapper-daemon"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Démon : en cours d'exécution"
|
||||
|
||||
msgid "Device: not found"
|
||||
msgstr "Appareil : introuvable"
|
||||
|
||||
#, c-format
|
||||
msgid "Device: %s"
|
||||
msgstr "Appareil : %s"
|
||||
|
||||
msgid "Configuration error"
|
||||
msgstr "Erreur de configuration"
|
||||
|
||||
#, c-format
|
||||
msgid "Last key: %s"
|
||||
msgstr "Dernière touche : %s"
|
||||
|
||||
msgid "Choose file"
|
||||
msgstr "Choisir un fichier"
|
||||
|
||||
msgid "_Cancel"
|
||||
msgstr "_Annuler"
|
||||
|
||||
msgid "_Open"
|
||||
msgstr "_Ouvrir"
|
||||
|
||||
msgid "Choose file..."
|
||||
msgstr "Choisir un fichier..."
|
||||
@@ -54,12 +169,12 @@ msgstr "ex. /usr/bin/firefox ou commande personnalisée"
|
||||
msgid "Capture"
|
||||
msgstr "Capturer"
|
||||
|
||||
msgid "e.g. ctrl+c, ctrl+v"
|
||||
msgstr "ex. ctrl+c, ctrl+v"
|
||||
|
||||
msgid "e.g. ctrl+w (held while the G key is held)"
|
||||
msgstr "ex. ctrl+w (maintenue tant que la touche G est enfoncée)"
|
||||
|
||||
msgid "e.g. ctrl+c, ctrl+v"
|
||||
msgstr "ex. ctrl+c, ctrl+v"
|
||||
|
||||
#, c-format
|
||||
msgid "Could not save %s.\n"
|
||||
msgstr "Impossible d'enregistrer %s.\n"
|
||||
@@ -67,19 +182,12 @@ msgstr "Impossible d'enregistrer %s.\n"
|
||||
msgid "Mappings saved to "
|
||||
msgstr "Attributions enregistrées dans "
|
||||
|
||||
#, c-format
|
||||
msgid "Could not open %s.\n"
|
||||
msgstr "Impossible d'ouvrir %s.\n"
|
||||
|
||||
msgid "Could not export mappings.\n"
|
||||
msgstr "Impossible d'exporter les attributions.\n"
|
||||
|
||||
msgid "gKeyMapper"
|
||||
msgstr "gKeyMapper"
|
||||
|
||||
msgid "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
msgstr "Assignateur de touches G pour le Logitech G910 (GTK4)."
|
||||
|
||||
msgid "Export configuration"
|
||||
msgstr "Exporter la configuration"
|
||||
|
||||
@@ -98,11 +206,19 @@ msgstr "Exporter la configuration..."
|
||||
msgid "Import configuration..."
|
||||
msgstr "Importer la configuration..."
|
||||
|
||||
msgid "Options"
|
||||
msgstr "Options"
|
||||
|
||||
msgid "About"
|
||||
msgstr "À propos"
|
||||
|
||||
msgid "Quit"
|
||||
msgstr "Quitter"
|
||||
|
||||
msgid "G1 to G9 of the Logitech G910 remapped to keys or commands"
|
||||
msgstr "Réassignation des touches G1 à G9 du Logitech G910 vers des touches ou des commandes"
|
||||
msgstr ""
|
||||
"Réassignation des touches G1 à G9 du Logitech G910 vers des touches ou des "
|
||||
"commandes"
|
||||
|
||||
msgid "G-Key"
|
||||
msgstr "Touche G"
|
||||
@@ -134,23 +250,92 @@ msgstr "Enregistrer les attributions"
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
msgstr "Enregistré dans ~/.config/gKeyMapper/mappings.conf"
|
||||
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr "Démon : injoignable"
|
||||
|
||||
msgid "Daemon: running"
|
||||
msgstr "Démon : en cours d'exécution"
|
||||
|
||||
msgid "Device: %s"
|
||||
msgstr "Appareil : %s"
|
||||
|
||||
msgid "Device: not found"
|
||||
msgstr "Appareil : introuvable"
|
||||
|
||||
msgid "Configuration error"
|
||||
msgstr "Erreur de configuration"
|
||||
|
||||
msgid "Last key: G%d → %s"
|
||||
msgstr "Dernière touche : G%d → %s"
|
||||
|
||||
msgid "Daemon: checking..."
|
||||
msgstr "Démon : vérification..."
|
||||
|
||||
#, c-format
|
||||
msgid "Could not open %s.\n"
|
||||
msgstr "Impossible d'ouvrir %s.\n"
|
||||
|
||||
msgid "Window"
|
||||
msgstr "Fenêtre"
|
||||
|
||||
msgid "Minimize to tray"
|
||||
msgstr "Réduire dans la zone de notification"
|
||||
|
||||
msgid "Start with the session"
|
||||
msgstr "Démarrer avec la session"
|
||||
|
||||
msgid ""
|
||||
"Opens this window minimized to the tray 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 réduite dans la zone de notification à 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 "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 "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 ""
|
||||
"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 "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 "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 "Show"
|
||||
msgstr "Afficher"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fermer"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-10-18 03:08+0200\n"
|
||||
"POT-Creation-Date: 2026-10-18 22:01+0200\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,186 +17,368 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: src/main.cpp:55
|
||||
msgid "Choose file"
|
||||
#: src/autostart.cpp:97 src/main.cpp:285 src/trayicon.cpp:371
|
||||
msgid "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:58 src/main.cpp:419 src/main.cpp:431
|
||||
msgid "_Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:59 src/main.cpp:432
|
||||
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
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:192
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:193
|
||||
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/capturedialog.cpp:250
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
#: src/capturedialog.cpp:262
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: src/capturedialog.cpp:267
|
||||
msgid "Done"
|
||||
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> opens this window minimized "
|
||||
"to the tray 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 yet come "
|
||||
"along."
|
||||
msgstr ""
|
||||
|
||||
#: src/help.cpp:67 src/help.cpp:86 src/main.cpp:640
|
||||
msgid "Help"
|
||||
msgstr ""
|
||||
|
||||
#: src/help.cpp:95 src/optionsdialog.cpp:178
|
||||
msgid "Close window"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:76
|
||||
msgid "Daemon: not reachable"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:80
|
||||
msgid "Run: systemctl --user enable --now gkeymapper-daemon"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:83
|
||||
msgid "Daemon: running"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:88
|
||||
msgid "Device: not found"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:91
|
||||
#, c-format
|
||||
msgid "Device: %s"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:96 src/optionsdialog.cpp:229
|
||||
msgid "Configuration error"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:101
|
||||
#, c-format
|
||||
msgid "Last key: %s"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:144
|
||||
msgid "Choose file"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:147 src/main.cpp:318 src/main.cpp:330
|
||||
msgid "_Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:148 src/main.cpp:331
|
||||
msgid "_Open"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:178
|
||||
msgid "Choose file..."
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:148
|
||||
#: src/main.cpp:180
|
||||
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:182 src/main.cpp:186 src/main.cpp:733
|
||||
msgid "Capture"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:156 src/main.cpp:429
|
||||
msgid "e.g. ctrl+c, ctrl+v"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:152
|
||||
#: src/main.cpp:184
|
||||
msgid "e.g. ctrl+w (held while the G key is held)"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:306
|
||||
#: src/main.cpp:188 src/main.cpp:726
|
||||
msgid "e.g. ctrl+c, ctrl+v"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:203
|
||||
#, c-format
|
||||
msgid "Could not save %s.\n"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:310
|
||||
#: src/main.cpp:211
|
||||
msgid "Mappings saved to "
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:316
|
||||
#: src/main.cpp:221
|
||||
msgid "Could not export mappings.\n"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:282 src/main.cpp:628 src/trayicon.cpp:370 src/trayicon.cpp:500
|
||||
msgid "gKeyMapper"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:315
|
||||
msgid "Export configuration"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:319
|
||||
msgid "_Save"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:327
|
||||
msgid "Import configuration"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:613
|
||||
msgid "gKeyMapper – Logitech G910"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:632
|
||||
msgid "Export configuration..."
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:633
|
||||
msgid "Import configuration..."
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:636 src/optionsdialog.cpp:83
|
||||
msgid "Options"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:641
|
||||
msgid "About"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:647
|
||||
msgid "Quit"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:661
|
||||
msgid "G1 to G9 of the Logitech G910 remapped to keys or commands"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:682
|
||||
msgid "G-Key"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:682
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:682
|
||||
msgid "Target (Key/Command)"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:682
|
||||
msgid "Action"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:696
|
||||
msgid "Key"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:696
|
||||
msgid "Command"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:696
|
||||
msgid "Disabled"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:697
|
||||
msgid "Key (hold)"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:769
|
||||
msgid "Save mappings"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:776
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:787
|
||||
msgid "Daemon: checking..."
|
||||
msgstr ""
|
||||
|
||||
#: src/mappings.cpp:41
|
||||
#, c-format
|
||||
msgid "Could not open %s.\n"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:368
|
||||
msgid "Could not export mappings.\n"
|
||||
#: src/optionsdialog.cpp:97
|
||||
msgid "Window"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:393 src/main.cpp:499
|
||||
msgid "gKeyMapper"
|
||||
#: src/optionsdialog.cpp:99
|
||||
msgid "Minimize to tray"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:396
|
||||
msgid "G-Key mapper for the Logitech G910 (GTK4)."
|
||||
#: src/optionsdialog.cpp:112
|
||||
msgid "Start with the session"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:416
|
||||
msgid "Export configuration"
|
||||
#: src/optionsdialog.cpp:124
|
||||
msgid ""
|
||||
"Opens this window minimized to the tray 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/main.cpp:420
|
||||
msgid "_Save"
|
||||
#: src/optionsdialog.cpp:131 src/optionsdialog.cpp:150
|
||||
msgid "Language"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:428
|
||||
msgid "Import configuration"
|
||||
#: src/optionsdialog.cpp:137
|
||||
msgid "System language"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:488
|
||||
msgid "gKeyMapper – Logitech G910"
|
||||
#: src/optionsdialog.cpp:155
|
||||
msgid "The interface switches right away, including the tray menu."
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:503
|
||||
msgid "Export configuration..."
|
||||
#: src/optionsdialog.cpp:158
|
||||
msgid "Daemon status"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:504
|
||||
msgid "Import configuration..."
|
||||
#: src/optionsdialog.cpp:171
|
||||
msgid "Tray icon"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:507
|
||||
msgid "About"
|
||||
#: src/optionsdialog.cpp:172
|
||||
msgid "State"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:531
|
||||
msgid "G1 to G9 of the Logitech G910 remapped to keys or commands"
|
||||
#: src/optionsdialog.cpp:173
|
||||
msgid "Version"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:548
|
||||
msgid "G-Key"
|
||||
#: src/optionsdialog.cpp:174
|
||||
msgid "Device"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:548
|
||||
msgid "Type"
|
||||
#: src/optionsdialog.cpp:175
|
||||
msgid "Configuration file"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:548
|
||||
msgid "Target (Key/Command)"
|
||||
#: src/optionsdialog.cpp:176
|
||||
msgid "Last key"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:548
|
||||
msgid "Action"
|
||||
#: src/optionsdialog.cpp:217
|
||||
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/main.cpp:404
|
||||
msgid "Key"
|
||||
#: src/optionsdialog.cpp:219
|
||||
msgid "No tray is available on this desktop, so the window cannot be hidden."
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:404
|
||||
msgid "Command"
|
||||
#: src/optionsdialog.cpp:222
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:404
|
||||
msgid "Disabled"
|
||||
#: src/optionsdialog.cpp:222
|
||||
msgid "not available"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:405
|
||||
msgid "Key (hold)"
|
||||
#: src/optionsdialog.cpp:223
|
||||
msgid "running"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:603
|
||||
msgid "Save mappings"
|
||||
#: src/optionsdialog.cpp:223
|
||||
msgid "not reachable"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:608
|
||||
msgid "Saved to ~/.config/gKeyMapper/mappings.conf"
|
||||
#: src/optionsdialog.cpp:225
|
||||
msgid "not found"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:55
|
||||
msgid "Daemon: not reachable"
|
||||
#: src/trayicon.cpp:136
|
||||
msgid "Show"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:57
|
||||
msgid "Daemon: running"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:62
|
||||
msgid "Device: not found"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:65
|
||||
msgid "Device: %s"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:70
|
||||
msgid "Configuration error"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:92
|
||||
msgid "Last key: G%d → %s"
|
||||
msgstr ""
|
||||
|
||||
#: src/main.cpp:475
|
||||
msgid "Daemon: checking..."
|
||||
#: src/trayicon.cpp:139
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
535
scripts/i18n.sh
Executable file
535
scripts/i18n.sh
Executable file
@@ -0,0 +1,535 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# gKeyMapper - everything that touches po/*.po, po/gkeymapper.pot and the compiled
|
||||
# catalogs, in one place, so a translation change is a command instead of a list of
|
||||
# steps to remember. The rules behind it are written down in po/README.md.
|
||||
#
|
||||
# Usage: scripts/i18n.sh <command> [arguments]
|
||||
# extract regenerate po/gkeymapper.pot from the sources
|
||||
# update [lang...] merge the pot into the catalogs: new strings come in
|
||||
# untranslated, existing translations are kept
|
||||
# check validate the pot, the catalogs and the language list
|
||||
# status per-language overview (counts + compiled catalog)
|
||||
# todo <lang> what is still untranslated or fuzzy, with the source
|
||||
# locations from the pot
|
||||
# new <lang> [name] create po/<lang>.po from the pot (name = Language-Team,
|
||||
# e.g. "Italiano"); "new en" writes the identity catalog
|
||||
# help this text
|
||||
#
|
||||
# Only gettext tools and coreutils are used. The tools can be overridden from the
|
||||
# environment, like the Makefile's variables: XGETTEXT=... scripts/i18n.sh extract
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
# Everything is addressed relative to the repository root and run from there: the
|
||||
# source locations xgettext writes into the pot ("src/main.cpp:582") and the file
|
||||
# names the gettext tools print are relative to the current directory, and absolute
|
||||
# paths would end up in the pot and in every message this script shows.
|
||||
cd -- "$ROOT" || exit 1
|
||||
PO_DIR="po"
|
||||
POT="$PO_DIR/gkeymapper.pot"
|
||||
LOCALE_DIR="locale"
|
||||
PACKAGE="gkeymapper"
|
||||
# What the catalogs carry in Project-Id-Version (see po/*.po).
|
||||
PO_VERSION="0.1"
|
||||
|
||||
XGETTEXT=${XGETTEXT:-xgettext}
|
||||
MSGFMT=${MSGFMT:-msgfmt}
|
||||
MSGMERGE=${MSGMERGE:-msgmerge}
|
||||
MSGATTRIB=${MSGATTRIB:-msgattrib}
|
||||
MSGCAT=${MSGCAT:-msgcat}
|
||||
|
||||
# The counts and message lists below are parsed, so the tools have to answer in
|
||||
# English, whatever the session's language is.
|
||||
export LC_ALL=C
|
||||
|
||||
# A temp file for a pot, removed on every exit - also on the error paths, where it
|
||||
# is the half-written file that must not stay behind.
|
||||
POT_TMP=""
|
||||
trap 'rm -f "${POT_TMP:-}"' EXIT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
die() {
|
||||
printf 'i18n: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
note() {
|
||||
printf '%s\n' "$*"
|
||||
}
|
||||
|
||||
require_tools() {
|
||||
local tool
|
||||
for tool in "$@"; do
|
||||
command -v "$tool" >/dev/null 2>&1 ||
|
||||
die "$tool not found - install gettext (see README, Requirements)"
|
||||
done
|
||||
}
|
||||
|
||||
# Every source that may hold translatable strings: the GUI, which is the only
|
||||
# program with gettext set up (src/main.cpp) - the same two directories the Makefile
|
||||
# compiles into it. The daemon is English on purpose (see `check`), so its sources
|
||||
# are not scanned. The pot is generated from this list, in this order, so `extract`
|
||||
# is reproducible: a hand-ordered file list once made the pot's message order depend
|
||||
# on the machine it ran on.
|
||||
sources() {
|
||||
local file
|
||||
for file in "$ROOT"/src/*.cpp "$ROOT"/shared/*.cpp; do
|
||||
if [ -e "$file" ]; then printf '%s\n' "${file#"$ROOT"/}"; fi
|
||||
done
|
||||
}
|
||||
|
||||
# The languages that have a catalog, i.e. po/*.po - the same list the Makefile
|
||||
# compiles and installs.
|
||||
languages() {
|
||||
local file
|
||||
for file in "$PO_DIR"/*.po; do
|
||||
if [ -e "$file" ]; then basename "$file" .po; fi
|
||||
done | sort
|
||||
}
|
||||
|
||||
# The messages of a po/pot, one per line and multi-line strings joined: msgcat does
|
||||
# the joining, so `_("a" "b")` in the sources and the pot entry are the same string.
|
||||
# The header (empty msgid) is left out.
|
||||
msgids() {
|
||||
"$MSGCAT" --stringtable-output "$1" 2>/dev/null |
|
||||
sed -n 's/^"\(.*\)" = .*$/\1/p' | sed '/^$/d'
|
||||
}
|
||||
|
||||
# translated/fuzzy/untranslated of one catalog, into three globals. msgfmt prints
|
||||
# them as a comma separated list ("81 translated messages, 1 untranslated
|
||||
# message.") and leaves the parts out that are zero, so the list is split first -
|
||||
# a pattern like ".*\([0-9]\+\) untranslated" would stop at the last digit of a
|
||||
# count and read 82 as 2.
|
||||
stats() {
|
||||
local out parts
|
||||
out=$("$MSGFMT" --statistics -o /dev/null "$1" 2>&1) || die "$MSGFMT rejected $1"
|
||||
parts=$(printf '%s\n' "$out" | tr ',' '\n')
|
||||
translated=$(printf '%s\n' "$parts" | sed -n 's/^ *\([0-9]\+\) translated.*/\1/p'); : "${translated:=0}"
|
||||
fuzzy=$(printf '%s\n' "$parts" | sed -n 's/^ *\([0-9]\+\) fuzzy.*/\1/p'); : "${fuzzy:=0}"
|
||||
untranslated=$(printf '%s\n' "$parts" | sed -n 's/^ *\([0-9]\+\) untranslated.*/\1/p'); : "${untranslated:=0}"
|
||||
}
|
||||
|
||||
# The `Language:` header of a catalog, so a copy-pasted file is noticed.
|
||||
po_language() {
|
||||
sed -n 's/^"Language: \(.*\)\\n"$/\1/p' "$1"
|
||||
}
|
||||
|
||||
# The POT-Creation-Date of a po/pot. It does not decide whether a file is up to
|
||||
# date (the message set does); it tells whether a merge happened after the last
|
||||
# extract.
|
||||
pot_date() {
|
||||
sed -n 's/^"POT-Creation-Date: \(.*\)\\n"$/\1/p' "$1"
|
||||
}
|
||||
|
||||
# One line per entry whose msgstr is not the msgid again - the rule for po/en.po,
|
||||
# which is an identity catalog because English is the source language. Compared as
|
||||
# raw (still escaped) text: both sides come from the same file, so the escaping
|
||||
# matches. Plurals need more than this and are not used anywhere (no ngettext).
|
||||
identity_mismatches() {
|
||||
awk '
|
||||
function quoted(line) { sub(/^[^"]*"/, "", line); sub(/"$/, "", line); return line }
|
||||
function check() { if (id != "" && str != id) { print id; mismatches++ } }
|
||||
/^msgid_plural|^msgstr\[/ { print "plural entries are not supported"; exit 2 }
|
||||
/^msgid / { check(); id = quoted($0); str = ""; in_id = 1; next }
|
||||
/^msgstr / { str = quoted($0); in_id = 0; next }
|
||||
/^"/ { if (in_id) id = id quoted($0); else str = str quoted($0); next }
|
||||
/^#/ { next }
|
||||
/^[[:space:]]*$/ { next }
|
||||
END { check(); exit (mismatches > 0 ? 1 : 0) }
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# The pot as xgettext writes it right now, into the given file. `_()` marks
|
||||
# strings that are translated when they are used, `N_()` the ones that are only
|
||||
# collected (the help text is assembled later on); both are named explicitly,
|
||||
# because a gettext that does not know `_` by itself is not unheard of.
|
||||
extract_to() {
|
||||
local out="$1" file
|
||||
require_tools "$XGETTEXT"
|
||||
local -a files=()
|
||||
while IFS= read -r file; do files+=("$file"); done < <(sources)
|
||||
[ "${#files[@]}" -gt 0 ] || die "no sources found under src/ or shared/"
|
||||
|
||||
"$XGETTEXT" --from-code=UTF-8 --language=C++ --keyword=_ --keyword=N_ \
|
||||
--output="$out" "${files[@]}" || die "$XGETTEXT failed"
|
||||
|
||||
# Nothing or header-only means the extraction is broken, never that the program
|
||||
# has no strings. Checked before the pot is replaced, so a broken gettext
|
||||
# cannot empty a good pot.
|
||||
[ -s "$out" ] || die "$XGETTEXT produced no output (is gettext working? $XGETTEXT --version)"
|
||||
[ "$(grep -c '^msgid "' "$out")" -gt 1 ] ||
|
||||
die "$XGETTEXT extracted no message - are the keywords right?"
|
||||
}
|
||||
|
||||
# The pot without its header entry and without source locations: the entries of a
|
||||
# fresh catalog. With identity=1 every msgstr repeats its msgid (po/en.po).
|
||||
pot_body() {
|
||||
awk -v identity="$2" '
|
||||
NR == 1 { skipping = 1 }
|
||||
skipping { if ($0 ~ /^$/) skipping = 0; next }
|
||||
/^#:/ { next }
|
||||
/^msgid / {
|
||||
if (identity) { buffer = $0; sub(/^msgid/, "msgstr", buffer); collecting = 1 }
|
||||
print; next
|
||||
}
|
||||
/^"/ { if (collecting) buffer = buffer "\n" $0; print; next }
|
||||
/^msgstr ""$/ {
|
||||
if (collecting) { print buffer; collecting = 0; next }
|
||||
print; next
|
||||
}
|
||||
{ print }
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# The language codes the GUI offers (src/optionsdialog.cpp, without the leading
|
||||
# "" for "follow the session"). A catalog that is not in this list cannot be
|
||||
# picked; a code without a catalog is a window that stays English.
|
||||
gui_language_codes() {
|
||||
sed -n 's/.*kLanguageCodes\[\] *= *{\(.*\)};.*/\1/p' "src/optionsdialog.cpp" |
|
||||
tr ',' '\n' | tr -d ' "' | sed '/^$/d'
|
||||
}
|
||||
|
||||
# The labels of that dropdown: one more than the codes, the first being
|
||||
# "System language".
|
||||
gui_language_labels() {
|
||||
sed -n '/languageOptions\[\] *= *{/,/nullptr/p' "src/optionsdialog.cpp" |
|
||||
sed 's/.*{//; s/nullptr.*//' | tr ',' '\n' | grep -c '[^[:space:]]'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cmd_extract() {
|
||||
POT_TMP=$(mktemp "${TMPDIR:-/tmp}/$PACKAGE-pot.XXXXXX")
|
||||
extract_to "$POT_TMP"
|
||||
|
||||
if [ -f "$POT" ]; then
|
||||
local added gone
|
||||
added=$(comm -13 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort))
|
||||
gone=$(comm -23 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort))
|
||||
note "po/gkeymapper.pot: $(msgids "$POT_TMP" | wc -l) messages"
|
||||
[ -z "$added" ] || { note "new:"; printf '%s\n' "$added" | sed 's/^/ /'; }
|
||||
[ -z "$gone" ] || { note "gone:"; printf '%s\n' "$gone" | sed 's/^/ /'; }
|
||||
else
|
||||
note "po/gkeymapper.pot: $(msgids "$POT_TMP" | wc -l) messages"
|
||||
fi
|
||||
|
||||
mv "$POT_TMP" "$POT"
|
||||
POT_TMP="" # moved, nothing to clean up
|
||||
note "wrote po/gkeymapper.pot - now: scripts/i18n.sh update"
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
local lang po
|
||||
require_tools "$MSGMERGE" "$MSGFMT"
|
||||
[ -f "$POT" ] || die "$POT is missing - run: scripts/i18n.sh extract"
|
||||
|
||||
local -a targets
|
||||
if [ "$#" -gt 0 ]; then targets=("$@"); else mapfile -t targets < <(languages); fi
|
||||
[ "${#targets[@]}" -gt 0 ] || die "no po/*.po to update"
|
||||
|
||||
for lang in "${targets[@]}"; do
|
||||
po="$PO_DIR/$lang.po"
|
||||
[ -f "$po" ] || die "$po does not exist (a new language: scripts/i18n.sh new $lang)"
|
||||
# --no-location: the catalogs carry no "#: file:line" comments on purpose -
|
||||
# they are noise for translators and churn whenever a line moves. The
|
||||
# locations live in the pot, and `todo` shows them from there.
|
||||
"$MSGMERGE" --quiet --update --no-location "$po" "$POT"
|
||||
# msgmerge keeps the previous file as <po>~; a backup in git is noise.
|
||||
rm -f "$po~"
|
||||
stats "$po"
|
||||
note "po/$lang.po: $translated translated, $fuzzy fuzzy, $untranslated untranslated"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_check() {
|
||||
local lang po code out failures=0 stale_dates=""
|
||||
require_tools "$MSGFMT" "$MSGATTRIB" "$MSGCAT" "$MSGMERGE" "$XGETTEXT"
|
||||
[ -f "$POT" ] || die "$POT is missing"
|
||||
|
||||
local langs
|
||||
langs=$(languages)
|
||||
[ -n "$langs" ] || die "no po/*.po found"
|
||||
|
||||
# 1. Is the pot what the sources say? A message that was added or changed
|
||||
# without touching the pot is what this catches - the source locations only
|
||||
# drift when a line moves, which is worth a note, not a failure.
|
||||
POT_TMP=$(mktemp "${TMPDIR:-/tmp}/$PACKAGE-pot.XXXXXX")
|
||||
extract_to "$POT_TMP"
|
||||
local added gone
|
||||
added=$(comm -13 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort))
|
||||
gone=$(comm -23 <(msgids "$POT" | sort) <(msgids "$POT_TMP" | sort))
|
||||
if [ -n "$added" ] || [ -n "$gone" ]; then
|
||||
note "po/gkeymapper.pot is out of date:"
|
||||
[ -z "$added" ] || { note " in the sources, not in the pot:"; printf '%s\n' "$added" | sed 's/^/ /'; }
|
||||
[ -z "$gone" ] || { note " in the pot, not in the sources:"; printf '%s\n' "$gone" | sed 's/^/ /'; }
|
||||
note " fix: scripts/i18n.sh extract && scripts/i18n.sh update"
|
||||
failures=$((failures + 1))
|
||||
elif ! diff -q <(grep -v '^"POT-Creation-Date:' "$POT_TMP") \
|
||||
<(grep -v '^"POT-Creation-Date:' "$POT") >/dev/null; then
|
||||
note "po/gkeymapper.pot has the same messages, but an older extract (line numbers, order)"
|
||||
note " only the locations in 'todo' are affected - scripts/i18n.sh extract refreshes them"
|
||||
fi
|
||||
|
||||
# 2. Every catalog: parsable, complete, translated, and named what it says.
|
||||
for lang in $langs; do
|
||||
po="$PO_DIR/$lang.po"
|
||||
|
||||
# -c checks the format strings (%s, %d, ...) and the entry structure.
|
||||
if ! out=$("$MSGFMT" -c -o /dev/null "$po" 2>&1); then
|
||||
note "$lang: $MSGFMT -c failed:"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Does the catalog define the same messages as the pot? Compared as two
|
||||
# lists rather than with msgcmp, which would also report every untranslated
|
||||
# entry and drown the real problem (the counts below say what is missing).
|
||||
local missing extra
|
||||
missing=$(comm -23 <(msgids "$POT" | sort) <(msgids "$po" | sort))
|
||||
extra=$(comm -13 <(msgids "$POT" | sort) <(msgids "$po" | sort))
|
||||
if [ -n "$missing" ]; then
|
||||
note "$lang: $po does not define:"
|
||||
printf '%s\n' "$missing" | sed 's/^/ /'
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [ -n "$extra" ]; then
|
||||
note "$lang: $po defines messages the pot does not know:"
|
||||
printf '%s\n' "$extra" | sed 's/^/ /'
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
stats "$po"
|
||||
if [ "$untranslated" -ne 0 ]; then
|
||||
note "$lang: $untranslated untranslated message(s) - scripts/i18n.sh todo $lang"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [ "$fuzzy" -ne 0 ]; then
|
||||
note "$lang: $fuzzy fuzzy message(s) - those are shown in English"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Strings that vanished from the program: msgmerge keeps them as "#~"
|
||||
# entries. Not an error, but stale food for the next translator.
|
||||
local obsolete=0
|
||||
obsolete=$(grep -c '^#~' "$po" || true)
|
||||
[ "$obsolete" -eq 0 ] ||
|
||||
note "$lang: $obsolete obsolete entries (gone from the sources; msgattrib --no-obsolete drops them)"
|
||||
|
||||
if [ "$(po_language "$po")" != "$lang" ]; then
|
||||
note "$lang: the header says Language: $(po_language "$po")"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# English has a catalog of its own (an explicit language has to work on a
|
||||
# German desktop) and it is an identity one.
|
||||
if [ "$lang" = "en" ]; then
|
||||
if ! out=$(identity_mismatches "$po"); then
|
||||
note "en: po/en.po is not an identity catalog:"
|
||||
printf '%s\n' "$out" | head -20 | sed 's/^/ /'
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# A note, not a failure: it only means the file was not merged after the
|
||||
# last extract, so its header carries an older date than the pot. Collected
|
||||
# into one line - it says nothing about a single language.
|
||||
if [ "$(pot_date "$po")" != "$(pot_date "$POT")" ]; then
|
||||
stale_dates="$stale_dates $lang"
|
||||
fi
|
||||
done
|
||||
[ -z "$stale_dates" ] ||
|
||||
note "not merged against the current pot:$stale_dates (scripts/i18n.sh update)"
|
||||
|
||||
# 3. The languages the GUI offers (src/optionsdialog.cpp): a catalog that cannot
|
||||
# be selected is dead weight, a code without a catalog is an English window
|
||||
# with a German label.
|
||||
local codes
|
||||
codes=$(gui_language_codes)
|
||||
[ -n "$codes" ] || die "could not read kLanguageCodes[] from src/optionsdialog.cpp"
|
||||
for lang in $langs; do
|
||||
if ! grep -qx "$lang" <<<"$codes"; then
|
||||
note "po/$lang.po exists, but $lang is not offered in the options window"
|
||||
note " add it to kLanguageCodes[] and languageOptions[] (src/optionsdialog.cpp)"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done
|
||||
for code in $codes; do
|
||||
if [ ! -f "$PO_DIR/$code.po" ]; then
|
||||
note "the options window offers $code, but po/$code.po is missing"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$(gui_language_labels)" -ne "$(( $(printf '%s\n' "$codes" | wc -l) + 1 ))" ]; then
|
||||
note "kLanguageCodes[] and languageOptions[] differ in length (src/optionsdialog.cpp)"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# 4. Strings marked in the daemon. It has no gettext setup (no setlocale, no
|
||||
# bindtextdomain - see daemon/main.cpp), so a `_()` there would end up in the
|
||||
# pot, get translated, and still show English forever. The GUI is the
|
||||
# translated program.
|
||||
local daemon_marked
|
||||
daemon_marked=$(grep -lE '(^|[^[:alnum:]_])_\("|N_\("' daemon/*.cpp 2>/dev/null || true)
|
||||
if [ -n "$daemon_marked" ]; then
|
||||
note "the daemon is not translated, but these files mark strings:"
|
||||
printf '%s\n' "$daemon_marked" | sed 's/^/ /'
|
||||
note " either drop the marker, or give the daemon its own gettext setup and"
|
||||
note " add daemon/ to sources() in scripts/i18n.sh"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [ "$failures" -gt 0 ]; then
|
||||
note "i18n: $failures problem(s)"
|
||||
return 1
|
||||
fi
|
||||
note "i18n: $(printf '%s\n' "$langs" | wc -l) languages, $(msgids "$POT" | wc -l) messages, everything in sync"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
local lang po mo mo_state
|
||||
require_tools "$MSGFMT" "$MSGCAT"
|
||||
[ -f "$POT" ] || die "$POT is missing"
|
||||
printf '%-4s %-8s %10s %6s %11s %-9s %s\n' \
|
||||
lang messages translated fuzzy untranslated catalog file
|
||||
for lang in $(languages); do
|
||||
po="$PO_DIR/$lang.po"
|
||||
stats "$po"
|
||||
mo="$LOCALE_DIR/$lang/LC_MESSAGES/$PACKAGE.mo"
|
||||
mo_state="not built"
|
||||
if [ -f "$mo" ]; then
|
||||
# `make translations` rebuilds a catalog that is older than its po.
|
||||
if [ "$mo" -nt "$po" ]; then mo_state="current"; else mo_state="stale"; fi
|
||||
fi
|
||||
printf '%-4s %-8s %10s %6s %11s %-9s %s\n' "$lang" \
|
||||
"$((translated + fuzzy + untranslated))" "$translated" "$fuzzy" "$untranslated" \
|
||||
"$mo_state" "po/$lang.po"
|
||||
done
|
||||
printf '\npot: %s messages, %s\n' "$(msgids "$POT" | wc -l)" "$(pot_date "$POT")"
|
||||
}
|
||||
|
||||
cmd_todo() {
|
||||
local lang="${1:-}"
|
||||
[ -n "$lang" ] || die "usage: scripts/i18n.sh todo <lang>"
|
||||
local po="$PO_DIR/$lang.po"
|
||||
[ -f "$po" ] || die "$po does not exist"
|
||||
require_tools "$MSGMERGE" "$MSGATTRIB"
|
||||
|
||||
# Merging the pot into the catalog is what puts the source locations next to
|
||||
# the messages - the catalogs themselves have none (see cmd_update).
|
||||
local open
|
||||
open=$("$MSGMERGE" --quiet "$po" "$POT" | "$MSGATTRIB" --untranslated --no-obsolete)
|
||||
if [ -n "$open" ]; then
|
||||
# A valid po comment, so the output can be kept as a file and translated.
|
||||
note "# --- untranslated in po/$lang.po ---"
|
||||
printf '%s\n' "$open"
|
||||
else
|
||||
note "po/$lang.po: nothing untranslated"
|
||||
fi
|
||||
|
||||
local fuzzy
|
||||
fuzzy=$("$MSGMERGE" --quiet "$po" "$POT" | "$MSGATTRIB" --only-fuzzy --no-obsolete)
|
||||
if [ -n "$fuzzy" ]; then
|
||||
note
|
||||
note "# --- fuzzy: shown in English until reviewed, the old text is kept as a comment ---"
|
||||
printf '%s\n' "$fuzzy"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_new() {
|
||||
local lang="${1:-}" team="${2:-}"
|
||||
[ -n "$lang" ] || die "usage: scripts/i18n.sh new <lang> [Language-Team]"
|
||||
case "$lang" in
|
||||
*[!a-zA-Z_@.-]*) die "unexpected language code: $lang" ;;
|
||||
esac
|
||||
[ -f "$POT" ] || die "$POT is missing - run: scripts/i18n.sh extract"
|
||||
local po="$PO_DIR/$lang.po"
|
||||
[ -e "$po" ] && die "$po already exists"
|
||||
if [ -z "$team" ]; then
|
||||
# The two catalogs for a language that has been here from the start carry
|
||||
# the English name of the language; anything else falls back to its code.
|
||||
case "$lang" in
|
||||
en) team="English" ;;
|
||||
*) team="$lang" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
{
|
||||
if [ "$lang" = "en" ]; then
|
||||
printf '# English translation for %s.\n' "$PACKAGE"
|
||||
printf '# English is the source language; this catalog keeps the strings explicit.\n'
|
||||
else
|
||||
printf '# %s translation for %s.\n' "$team" "$PACKAGE"
|
||||
printf '# Copyright (C) %s\n' "$(date '+%Y')"
|
||||
printf '# This file is distributed under the same license as the %s package.\n' "$PACKAGE"
|
||||
fi
|
||||
printf '#\n'
|
||||
# The header in the shape the other catalogs use, so a new language does not
|
||||
# start out looking different.
|
||||
cat <<EOF
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: $PACKAGE $PO_VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: $(pot_date "$POT")\n"
|
||||
"PO-Revision-Date: $(date '+%Y-%m-%d %H:%M%z')\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: $team\n"
|
||||
"Language: $lang\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
EOF
|
||||
printf '\n'
|
||||
# The entries come from the pot: it defines what has to be translated, and
|
||||
# every msgstr in it is already empty.
|
||||
pot_body "$POT" "$([ "$lang" = "en" ] && echo 1 || echo 0)"
|
||||
} >"$po"
|
||||
|
||||
note "wrote po/$lang.po"
|
||||
if [ "$lang" != "en" ]; then
|
||||
note "next:"
|
||||
note " translate it, then: scripts/i18n.sh check && make translations"
|
||||
note " offer it in the options window, or nobody can pick it:"
|
||||
note " src/optionsdialog.cpp: kLanguageCodes[] and languageOptions[]"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
scripts/i18n.sh <command> [arguments]
|
||||
|
||||
extract regenerate po/gkeymapper.pot from the sources
|
||||
update [lang...] merge the pot into the catalogs: new strings come in
|
||||
untranslated, existing translations are kept
|
||||
check validate the pot, the catalogs and the language list
|
||||
status per-language overview (counts + compiled catalog)
|
||||
todo <lang> what is still untranslated or fuzzy, with the source
|
||||
locations from the pot
|
||||
new <lang> [name] create po/<lang>.po from the pot (name = Language-Team,
|
||||
e.g. "Italiano"); "new en" writes the identity catalog
|
||||
help this text
|
||||
|
||||
The rules behind the commands are in po/README.md. gettext tools are used from
|
||||
PATH; override them like the Makefile's variables, e.g. XGETTEXT=/usr/bin/xgettext
|
||||
EOF
|
||||
}
|
||||
|
||||
command="${1:-help}"
|
||||
shift || true
|
||||
case "$command" in
|
||||
extract|pot) cmd_extract "$@" ;;
|
||||
update) cmd_update "$@" ;;
|
||||
check) cmd_check "$@" ;;
|
||||
status) cmd_status "$@" ;;
|
||||
todo) cmd_todo "$@" ;;
|
||||
new) cmd_new "$@" ;;
|
||||
help|-h|--help) usage ;;
|
||||
*) usage; exit 1 ;;
|
||||
esac
|
||||
254
scripts/make-deb.sh
Executable file
254
scripts/make-deb.sh
Executable file
@@ -0,0 +1,254 @@
|
||||
#!/bin/sh
|
||||
# Build a .deb package for gKeyMapper.
|
||||
# Usage: scripts/make-deb.sh [version] [arch]
|
||||
set -e
|
||||
|
||||
VERSION="${1:-0.1.2}"
|
||||
ARCH="${2:-$(dpkg --print-architecture 2>/dev/null || echo amd64)}"
|
||||
PACKAGE="gkeymapper"
|
||||
STAGING="build/deb"
|
||||
DEB_NAME="${PACKAGE}_${VERSION}_${ARCH}.deb"
|
||||
|
||||
# Build everything first.
|
||||
make all translations
|
||||
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING/DEBIAN"
|
||||
|
||||
# Stage files with Debian layout (PREFIX=/usr).
|
||||
make install DESTDIR="$STAGING" PREFIX=/usr
|
||||
|
||||
# Calculate installed size (in KB) for the control file.
|
||||
INSTALLED_SIZE=$(du -sk "$STAGING" | cut -f1)
|
||||
|
||||
# Control file.
|
||||
cat > "$STAGING/DEBIAN/control" <<EOF
|
||||
Package: $PACKAGE
|
||||
Version: $VERSION
|
||||
Section: utils
|
||||
Installed-Size: $INSTALLED_SIZE
|
||||
Priority: optional
|
||||
Architecture: $ARCH
|
||||
Depends: libc6, libgtk-4-1, libxkbcommon0, libglib2.0-0, systemd, udev
|
||||
Maintainer: gKeyMapper <gkeymapper@rager.dev>
|
||||
Description: Remap G1-G9 keys of the Logitech G910
|
||||
A GTK4 GUI plus a background daemon for remapping the G1-G9 keys
|
||||
of a Logitech G910 keyboard to keys or commands.
|
||||
EOF
|
||||
|
||||
# preinst: stop any running daemon and re-attach the kernel driver BEFORE
|
||||
# upgrading. The old (libusb-based) daemon detached the kernel driver from
|
||||
# interface 1; if it was not stopped gracefully, the driver stays detached
|
||||
# and the new (hidraw-based) daemon cannot find the device.
|
||||
cat > "$STAGING/DEBIAN/preinst" <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
case "$1" in
|
||||
install|upgrade)
|
||||
# Stop any running daemon (old or new).
|
||||
for pid in $(pgrep -f gkeymapper-daemon 2>/dev/null); do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 1
|
||||
|
||||
# Re-attach the kernel driver to interface 1 if it was detached by
|
||||
# an older (libusb-based) daemon.
|
||||
for _dev in /sys/bus/usb/devices/*; do
|
||||
_vendor=$(cat "$_dev/idVendor" 2>/dev/null) || continue
|
||||
_product=$(cat "$_dev/idProduct" 2>/dev/null) || continue
|
||||
case "$_vendor" in
|
||||
046d)
|
||||
case "$_product" in
|
||||
c32b|c335)
|
||||
for _iface in "$_dev"/*:*; do
|
||||
case "$_iface" in
|
||||
*:1.*)
|
||||
_name=$(basename "$_iface")
|
||||
if [ -w /sys/bus/usb/drivers/usbhid/bind ]; then
|
||||
echo "$_name" > /sys/bus/usb/drivers/usbhid/bind 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod 755 "$STAGING/DEBIAN/preinst"
|
||||
|
||||
# postinst: reload udev/systemd and enable the user daemon.
|
||||
cat > "$STAGING/DEBIAN/postinst" <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
case "$1" in
|
||||
configure)
|
||||
if command -v udevadm >/dev/null 2>&1; then
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
udevadm trigger --subsystem-match=usb 2>/dev/null || true
|
||||
udevadm trigger --subsystem-match=input 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Remove stale service file from old "make install" (PREFIX=/usr/local)
|
||||
# so the new one from /usr takes precedence.
|
||||
rm -f /usr/local/lib/systemd/user/gkeymapper-daemon.service
|
||||
|
||||
# Re-attach the kernel driver to interface 1 if it was detached by an
|
||||
# older (libusb-based) daemon. Without this the new (hidraw-based)
|
||||
# daemon cannot find /dev/hidraw for interface 1.
|
||||
for _dev in /sys/bus/usb/devices/*; do
|
||||
_vendor=$(cat "$_dev/idVendor" 2>/dev/null) || continue
|
||||
_product=$(cat "$_dev/idProduct" 2>/dev/null) || continue
|
||||
case "$_vendor" in
|
||||
046d)
|
||||
case "$_product" in
|
||||
c32b|c335)
|
||||
for _iface in "$_dev"/*:*; do
|
||||
case "$_iface" in
|
||||
*:1.*)
|
||||
_name=$(basename "$_iface")
|
||||
if [ -w /sys/bus/usb/drivers/usbhid/bind ]; then
|
||||
echo "$_name" > /sys/bus/usb/drivers/usbhid/bind 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Helper: run a command as a given user with the D-Bus session bus
|
||||
# available. "systemctl --user" needs DBUS_SESSION_BUS_ADDRESS, which
|
||||
# is not set in a plain "su -" login shell.
|
||||
run_as_user() {
|
||||
_user="$1"; shift
|
||||
_uid=$(id -u "$_user" 2>/dev/null) || return 1
|
||||
_bus="unix:path=/run/user/$_uid/bus"
|
||||
runuser -u "$_user" -- env DBUS_SESSION_BUS_ADDRESS="$_bus" "$@" 2>&1
|
||||
}
|
||||
|
||||
_target_user=""
|
||||
if [ -n "$SUDO_USER" ]; then
|
||||
_target_user="$SUDO_USER"
|
||||
fi
|
||||
|
||||
if [ -n "$_target_user" ]; then
|
||||
echo "gkeymapper: enabling daemon for user $_target_user ..."
|
||||
if run_as_user "$_target_user" systemctl --user daemon-reload; then
|
||||
echo " daemon-reload: OK"
|
||||
else
|
||||
echo " daemon-reload: FAILED (is the user session running?)"
|
||||
fi
|
||||
if run_as_user "$_target_user" systemctl --user enable --now gkeymapper-daemon; then
|
||||
echo " enable --now gkeymapper-daemon: OK"
|
||||
else
|
||||
echo " enable --now gkeymapper-daemon: FAILED"
|
||||
echo " -> run manually: systemctl --user enable --now gkeymapper-daemon"
|
||||
fi
|
||||
else
|
||||
echo "gkeymapper: no installing user detected (SUDO_USER empty)."
|
||||
echo " Enable the daemon manually with:"
|
||||
echo " systemctl --user daemon-reload"
|
||||
echo " systemctl --user enable --now gkeymapper-daemon"
|
||||
for _user in $(loginctl list-users --no-legend 2>/dev/null | awk '{print $2}'); do
|
||||
_uid=$(id -u "$_user" 2>/dev/null) || continue
|
||||
echo " trying user $_user ..."
|
||||
run_as_user "$_user" systemctl --user daemon-reload >/dev/null 2>&1 || true
|
||||
run_as_user "$_user" systemctl --user enable --now gkeymapper-daemon >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod 755 "$STAGING/DEBIAN/postinst"
|
||||
|
||||
# prerm: stop the daemon and re-attach the kernel driver before upgrading.
|
||||
# The old (libusb-based) daemon detached the kernel driver from interface 1;
|
||||
# if it was not stopped gracefully, the driver stays detached and the new
|
||||
# (hidraw-based) daemon cannot find the device.
|
||||
cat > "$STAGING/DEBIAN/prerm" <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
case "$1" in
|
||||
remove|upgrade|deconfigure)
|
||||
# Stop the daemon if running.
|
||||
if [ -n "$SUDO_USER" ]; then
|
||||
runuser -u "$SUDO_USER" -- systemctl --user stop gkeymapper-daemon >/dev/null 2>&1 || true
|
||||
runuser -u "$SUDO_USER" -- systemctl --user daemon-reload >/dev/null 2>&1 || true
|
||||
else
|
||||
for _user in $(loginctl list-users --no-legend 2>/dev/null | awk '{print $2}'); do
|
||||
runuser -u "$_user" -- systemctl --user stop gkeymapper-daemon >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Re-attach the kernel driver to interface 1 if it was detached by
|
||||
# an older (libusb-based) daemon. Without this, the new daemon
|
||||
# (hidraw-based) cannot find /dev/hidraw for interface 1.
|
||||
for _dev in /sys/bus/usb/devices/*; do
|
||||
_vendor=$(cat "$_dev/idVendor" 2>/dev/null) || continue
|
||||
_product=$(cat "$_dev/idProduct" 2>/dev/null) || continue
|
||||
case "$_vendor" in
|
||||
046d)
|
||||
case "$_product" in
|
||||
c32b|c335)
|
||||
for _iface in "$_dev"/*:*; do
|
||||
case "$_iface" in
|
||||
*:1.*)
|
||||
_name=$(basename "$_iface")
|
||||
if [ -w /sys/bus/usb/drivers/usbhid/bind ]; then
|
||||
echo "$_name" > /sys/bus/usb/drivers/usbhid/bind 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod 755 "$STAGING/DEBIAN/prerm"
|
||||
|
||||
# postrm: disable the user daemon on removal.
|
||||
cat > "$STAGING/DEBIAN/postrm" <<'EOF'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
case "$1" in
|
||||
remove|purge)
|
||||
if [ -n "$SUDO_USER" ]; then
|
||||
su - "$SUDO_USER" -c "systemctl --user disable --now gkeymapper-daemon" >/dev/null 2>&1 || true
|
||||
su - "$SUDO_USER" -c "systemctl --user daemon-reload" >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
EOF
|
||||
chmod 755 "$STAGING/DEBIAN/postrm"
|
||||
|
||||
# Fix ownership (files must be owned by root in a .deb).
|
||||
# Strip debug symbols from binaries.
|
||||
strip "$STAGING/usr/bin/gkeymapper" "$STAGING/usr/bin/gkeymapper-daemon" 2>/dev/null || true
|
||||
|
||||
# Remove generated cache files (they are regenerated on the target).
|
||||
rm -f "$STAGING/usr/share/applications/mimeinfo.cache"
|
||||
rm -f "$STAGING/usr/share/icons/hicolor/icon-theme.cache"
|
||||
# Fix file permissions.
|
||||
chmod 644 "$STAGING"/usr/share/applications/* 2>/dev/null || true
|
||||
chmod 755 "$STAGING"/usr/bin/* 2>/dev/null || true
|
||||
|
||||
# Build the .deb.
|
||||
dpkg-deb --root-owner-group --build "$STAGING" "$DEB_NAME"
|
||||
|
||||
echo ""
|
||||
echo "Built: $DEB_NAME"
|
||||
@@ -13,7 +13,7 @@ namespace gkeymapper::ipc {
|
||||
inline constexpr const char* kAppId = "dev.rager.gkeymapper";
|
||||
|
||||
// Reported by GetStatus and shown in the About dialog.
|
||||
inline constexpr const char* kVersion = "0.1.0";
|
||||
inline constexpr const char* kVersion = "0.1.2";
|
||||
|
||||
// The daemon owns this name; the GUI talks to it via the object below.
|
||||
//
|
||||
@@ -24,6 +24,16 @@ inline constexpr const char* kBusName = "dev.rager.gkeymapper.Daemon";
|
||||
inline constexpr const char* kObjectPath = "/dev/rager/gkeymapper/Daemon";
|
||||
inline constexpr const char* kInterface = "dev.rager.gkeymapper.Daemon";
|
||||
|
||||
// Where the GUI answers a second start (see src/singleinstance and src/main.cpp):
|
||||
// it hands out the rows its window shows, so an instance that is replaced does not
|
||||
// take unsaved edits with it. The name is the app id itself - GtkApplication owns
|
||||
// that (see kBusName above), and the GUI is not a service, it only answers while a
|
||||
// window is up.
|
||||
inline constexpr const char* kWindowObjectPath = "/dev/rager/gkeymapper/Window";
|
||||
inline constexpr const char* kWindowInterface = "dev.rager.gkeymapper.Window";
|
||||
// The one method: no arguments, the rows as the mappings text (the file format).
|
||||
inline constexpr const char* kWindowRowsMethod = "GetRows";
|
||||
|
||||
} // namespace gkeymapper::ipc
|
||||
|
||||
#endif // GKEYMAPPER_IPC_IDS_H
|
||||
|
||||
119
src/autostart.cpp
Normal file
119
src/autostart.cpp
Normal file
@@ -0,0 +1,119 @@
|
||||
// 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. The flag keeps the window out of the way
|
||||
// at login, see kStartHiddenArg.
|
||||
g_autofree gchar* command =
|
||||
g_strdup_printf("\"%s\" %s", program_path().c_str(), kStartHiddenArg);
|
||||
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
|
||||
32
src/autostart.h
Normal file
32
src/autostart.h
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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();
|
||||
|
||||
// The argument the entry hands to the program: the window is then built but stays
|
||||
// in the tray (main.cpp), so a login does not put a window in front of the user,
|
||||
// and asking for it - the tray's Show, or starting gKeyMapper by hand - brings it
|
||||
// up. It belongs on the Exec line and not in gui.conf: the entry is what the
|
||||
// session runs, so the flag is the whole truth about what happens at login.
|
||||
inline constexpr const char* kStartHiddenArg = "--start-hidden";
|
||||
|
||||
// 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
|
||||
@@ -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
115
src/help.cpp
Normal 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> opens this window minimized to the tray "
|
||||
"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 yet come along."),
|
||||
};
|
||||
|
||||
} // 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
21
src/help.h
Normal 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
|
||||
436
src/main.cpp
436
src/main.cpp
@@ -9,13 +9,19 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "autostart.h"
|
||||
#include "capturedialog.h"
|
||||
#include "daemonclient.h"
|
||||
#include "help.h"
|
||||
#include "ipc_ids.h"
|
||||
#include "keyrow.h"
|
||||
#include "mapping.h"
|
||||
#include "mappings.h"
|
||||
#include "optionsdialog.h"
|
||||
#include "popovers.h"
|
||||
#include "settings.h"
|
||||
#include "singleinstance.h"
|
||||
#include "trayicon.h"
|
||||
|
||||
#ifndef PACKAGE
|
||||
#define PACKAGE "gkeymapper"
|
||||
@@ -32,19 +38,33 @@ 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 rows of the instance this start replaced, as a mappings text (empty when
|
||||
// there was none, or when it did not hand them over - an older build). main()
|
||||
// fills this in before the window exists; build_ui() starts from it instead of
|
||||
// from the file, so edits that were never saved survive a second start.
|
||||
std::string handedOverRows;
|
||||
|
||||
// Set by main() when the session's autostart entry started this instance (the
|
||||
// --start-hidden argument): the window is built but stays in the tray, so a login
|
||||
// does not put it in the way. on_activate is what reads it - a window that is
|
||||
// built later (a language switch) comes up the way it was left.
|
||||
bool startHidden = false;
|
||||
|
||||
// The daemon view behind the footer. It is filled by the asynchronous callbacks
|
||||
// of DaemonClient, which arrive on the GTK main loop, so nothing here ever waits
|
||||
// for the daemon; the options window shows the same data in full.
|
||||
gkeymapper::DaemonView daemonView;
|
||||
|
||||
GtkWidget* daemonLabel = nullptr;
|
||||
gkeymapper::DaemonClient daemonClient;
|
||||
|
||||
// The window can be hidden instead of closed (the tray brings it back), and the
|
||||
// options window keeps showing the daemon status while it is open.
|
||||
GtkWidget* mainWindow = nullptr;
|
||||
gkeymapper::TrayIcon tray;
|
||||
gkeymapper::GuiSettings guiSettings;
|
||||
gkeymapper::OptionsDialog optionsDialog;
|
||||
|
||||
// Rebuilds the footer line from the state above. " · " is a separator, not a
|
||||
// sentence, so it is not translated.
|
||||
static void refresh_footer() {
|
||||
@@ -52,47 +72,58 @@ static void refresh_footer() {
|
||||
return;
|
||||
}
|
||||
std::string text;
|
||||
if (!daemonFooter.reachable) {
|
||||
if (!daemonView.reachable) {
|
||||
text = _("Daemon: not reachable");
|
||||
// Hint for users who installed the package but did not (or could not)
|
||||
// enable the daemon service during installation.
|
||||
gtk_widget_set_tooltip_text(daemonLabel,
|
||||
_("Run: systemctl --user enable --now gkeymapper-daemon"));
|
||||
} else {
|
||||
gtk_widget_set_tooltip_text(daemonLabel, nullptr);
|
||||
text = _("Daemon: running");
|
||||
text += " · ";
|
||||
if (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 +261,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 +303,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,7 +388,227 @@ static std::string find_asset(const std::string& name) {
|
||||
return local;
|
||||
}
|
||||
|
||||
void build_ui(GtkApplication* app) {
|
||||
// Size of the pixmap handed to the panel; the panel scales it to its own size.
|
||||
constexpr int kTrayIconSize = 64;
|
||||
|
||||
// How long a start from the autostart entry waits for the tray icon before it
|
||||
// gives up and shows the window (on_start_hidden_timeout).
|
||||
constexpr guint kStartHiddenGraceMs = 5000;
|
||||
|
||||
// Shown and hidden are the two states the tray works with: a hidden window is
|
||||
// still a window, so the app keeps running and the tray stays the way back.
|
||||
static void show_main_window() {
|
||||
if (mainWindow == nullptr) {
|
||||
return;
|
||||
}
|
||||
gtk_widget_set_visible(mainWindow, TRUE);
|
||||
// present() also restores a window the window manager had minimized.
|
||||
gtk_window_present(GTK_WINDOW(mainWindow));
|
||||
}
|
||||
|
||||
static void hide_main_window() {
|
||||
if (mainWindow != nullptr) {
|
||||
gtk_widget_set_visible(mainWindow, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
// Hiding is only safe while the tray really hosts the icon: without one there
|
||||
// would be no way back to the window, so the app quits as usual.
|
||||
static bool hides_to_tray() {
|
||||
return guiSettings.minimizeToTray && tray.ready();
|
||||
}
|
||||
|
||||
// Closing the window: with the tray option on it is hidden, otherwise the default
|
||||
// handler closes the window and the app quits with it.
|
||||
static gboolean on_close_request(GtkWindow* /*window*/, gpointer /*userData*/) {
|
||||
if (!hides_to_tray()) {
|
||||
return FALSE;
|
||||
}
|
||||
hide_main_window();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// GTK4 has no signal for "minimized" (GdkToplevel has no properties either, only
|
||||
// gdk_toplevel_get_state), so the one state that needs an action is polled: one
|
||||
// call per tick, and only while the option is on and the window is up.
|
||||
static gboolean on_minimize_watch(gpointer /*userData*/) {
|
||||
if (!hides_to_tray() || mainWindow == nullptr || !gtk_widget_get_visible(mainWindow)) {
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
GdkSurface* surface = gtk_native_get_surface(GTK_NATIVE(mainWindow));
|
||||
if (surface != nullptr && GDK_IS_TOPLEVEL(surface) &&
|
||||
(gdk_toplevel_get_state(GDK_TOPLEVEL(surface)) & GDK_TOPLEVEL_STATE_MINIMIZED) != 0) {
|
||||
hide_main_window();
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
// A start from the session's autostart entry keeps the window out of the way, but
|
||||
// only a hosted tray icon can bring it back - a desktop without one would leave the
|
||||
// program with no window and no way to open one. Nothing knows that at startup
|
||||
// (the panel answers asynchronously), so the window is built hidden and appears
|
||||
// here if the icon did not turn up in the meantime.
|
||||
static gboolean on_start_hidden_timeout(gpointer /*userData*/) {
|
||||
if (!tray.ready()) {
|
||||
g_printerr("gkeymapper: no tray icon, showing the window\n");
|
||||
show_main_window();
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// The panel is another process and cannot read the icon compiled into this
|
||||
// binary, so the tray icon travels as a pixmap. GdkPixbuf hands out RGBA, the
|
||||
// protocol wants ARGB32 in network byte order, so the bytes are reordered here.
|
||||
static bool load_tray_icon(std::vector<unsigned char>& pixels, int& width, int& height) {
|
||||
GError* error = nullptr;
|
||||
GdkPixbuf* pixbuf = have_embedded_icon()
|
||||
? gdk_pixbuf_new_from_resource(kIconResource, &error)
|
||||
: gdk_pixbuf_new_from_file(find_asset("icon.png").c_str(), &error);
|
||||
if (pixbuf == nullptr) {
|
||||
g_printerr("gkeymapper: no tray icon: %s\n",
|
||||
error != nullptr ? error->message : "unknown error");
|
||||
g_clear_error(&error);
|
||||
return false;
|
||||
}
|
||||
if (gdk_pixbuf_get_width(pixbuf) > kTrayIconSize ||
|
||||
gdk_pixbuf_get_height(pixbuf) > kTrayIconSize) {
|
||||
GdkPixbuf* scaled =
|
||||
gdk_pixbuf_scale_simple(pixbuf, kTrayIconSize, kTrayIconSize, GDK_INTERP_BILINEAR);
|
||||
g_object_unref(pixbuf);
|
||||
pixbuf = scaled;
|
||||
}
|
||||
if (!gdk_pixbuf_get_has_alpha(pixbuf)) {
|
||||
GdkPixbuf* withAlpha = gdk_pixbuf_add_alpha(pixbuf, FALSE, 0, 0, 0);
|
||||
g_object_unref(pixbuf);
|
||||
pixbuf = withAlpha;
|
||||
}
|
||||
width = gdk_pixbuf_get_width(pixbuf);
|
||||
height = gdk_pixbuf_get_height(pixbuf);
|
||||
const int stride = gdk_pixbuf_get_rowstride(pixbuf);
|
||||
const guchar* source = gdk_pixbuf_get_pixels(pixbuf);
|
||||
pixels.assign(static_cast<size_t>(width) * static_cast<size_t>(height) * 4, 0);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
const guchar* row = source + static_cast<size_t>(y) * static_cast<size_t>(stride);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
unsigned char* target =
|
||||
pixels.data() + (static_cast<size_t>(y) * static_cast<size_t>(width) + x) * 4;
|
||||
target[0] = row[x * 4 + 3]; // A
|
||||
target[1] = row[x * 4 + 0]; // R
|
||||
target[2] = row[x * 4 + 1]; // G
|
||||
target[3] = row[x * 4 + 2]; // B
|
||||
}
|
||||
}
|
||||
g_object_unref(pixbuf);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void start_tray(GtkApplication* app) {
|
||||
std::vector<unsigned char> pixels;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
if (!load_tray_icon(pixels, width, height)) {
|
||||
return;
|
||||
}
|
||||
tray.start(std::move(pixels), width, height, show_main_window,
|
||||
[app]() { g_application_quit(G_APPLICATION(app)); });
|
||||
}
|
||||
|
||||
static void build_ui(GtkApplication* app, bool show);
|
||||
static void open_options_dialog();
|
||||
|
||||
// Rebuilt from scratch when the language changes: the widgets keep the strings
|
||||
// they were made with, so switching means building them again. Runs as an idle,
|
||||
// because the caller is the dropdown that this destroys.
|
||||
static gboolean rebuild_ui_idle(gpointer /*userData*/) {
|
||||
if (mainWindow == nullptr) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
GtkApplication* app = gtk_window_get_application(GTK_WINDOW(mainWindow));
|
||||
// Both are transient windows: they go with the rebuild and come back if they
|
||||
// were open, so nothing on screen is left in the old language.
|
||||
const bool helpOpen = gkeymapper::help_window_open();
|
||||
const bool optionsOpen = optionsDialog.is_open();
|
||||
// A window that was in the tray (or was built hidden at login) stays there: the
|
||||
// new one comes up the way the old one was, not in front of the user.
|
||||
const bool windowShown = gtk_widget_get_visible(mainWindow);
|
||||
// Rows that were not saved yet are widget state, not file content; without
|
||||
// this the rebuilt window would silently show the last saved file again.
|
||||
const gkeymapper::MappingSet edits = gkeymapper::collect_mappings(rows);
|
||||
|
||||
gkeymapper::apply_gui_language(guiSettings.language);
|
||||
|
||||
// Destroying the last window would end the application, so it is held while
|
||||
// there is none.
|
||||
g_application_hold(G_APPLICATION(app));
|
||||
optionsDialog.close();
|
||||
gkeymapper::close_help_window();
|
||||
daemonLabel = nullptr; // dangling from here on, and a reply may still arrive
|
||||
gtk_window_destroy(GTK_WINDOW(mainWindow));
|
||||
mainWindow = nullptr;
|
||||
|
||||
build_ui(app, windowShown);
|
||||
gkeymapper::apply_mappings(edits, rows);
|
||||
if (helpOpen) {
|
||||
gkeymapper::show_help_window(GTK_WINDOW(mainWindow));
|
||||
}
|
||||
if (optionsOpen) {
|
||||
open_options_dialog();
|
||||
}
|
||||
g_application_release(G_APPLICATION(app));
|
||||
|
||||
refresh_footer();
|
||||
// The panel mirrors the menu once, so the new labels have to be announced.
|
||||
tray.refresh_labels();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// Chosen in the options window. Only remembered here; the switch itself is what
|
||||
// the user sees next, and that is the rebuild.
|
||||
static void on_language_selected(const std::string& language) {
|
||||
if (language == guiSettings.language) {
|
||||
return;
|
||||
}
|
||||
guiSettings.language = language;
|
||||
gkeymapper::save_gui_settings(guiSettings);
|
||||
g_idle_add(rebuild_ui_idle, nullptr);
|
||||
}
|
||||
|
||||
static void open_options_dialog() {
|
||||
// None of the switches needs a restart: the tray one decides what closing
|
||||
// does next, autostart only writes or removes the session's entry, and the
|
||||
// language one rebuilds the windows right away.
|
||||
gkeymapper::OptionsState state;
|
||||
state.trayAvailable = tray.ready();
|
||||
state.autostart = gkeymapper::autostart_enabled();
|
||||
state.settings = guiSettings;
|
||||
state.view = daemonView;
|
||||
optionsDialog.show(mainWindow != nullptr ? GTK_WINDOW(mainWindow) : nullptr, state,
|
||||
[](bool minimizeToTray) {
|
||||
guiSettings.minimizeToTray = minimizeToTray;
|
||||
gkeymapper::save_gui_settings(guiSettings);
|
||||
},
|
||||
[](bool autostart) {
|
||||
// Starting the window at login is a convenience; the G
|
||||
// keys work without it (the daemon is what has to run).
|
||||
if (!gkeymapper::set_autostart_enabled(autostart)) {
|
||||
g_printerr("gkeymapper: autostart entry not %s\n",
|
||||
autostart ? "written" : "removed");
|
||||
}
|
||||
},
|
||||
on_language_selected);
|
||||
}
|
||||
|
||||
static void action_help(GSimpleAction* /*action*/, GVariant* /*parameter*/,
|
||||
gpointer /*userData*/) {
|
||||
gkeymapper::show_help_window(mainWindow != nullptr ? GTK_WINDOW(mainWindow) : nullptr);
|
||||
}
|
||||
|
||||
static void action_options(GSimpleAction* /*action*/, GVariant* /*parameter*/,
|
||||
gpointer /*userData*/) {
|
||||
open_options_dialog();
|
||||
}
|
||||
|
||||
void build_ui(GtkApplication* app, bool show) {
|
||||
GtkWidget* window = gtk_application_window_new(app);
|
||||
gtk_window_set_title(GTK_WINDOW(window), _("gKeyMapper – Logitech G910"));
|
||||
gtk_window_set_resizable(GTK_WINDOW(window), FALSE);
|
||||
@@ -363,10 +632,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));
|
||||
@@ -408,8 +688,18 @@ void build_ui(GtkApplication* app) {
|
||||
gtk_grid_attach(GTK_GRID(grid), h, c, 0, 1, 1);
|
||||
}
|
||||
|
||||
// GTK draws the check mark of the selected entry right behind the text of a
|
||||
// dropdown row, so every entry carries a trailing space. The catalogs keep
|
||||
// the bare words; the space is added here, where the model is built. The
|
||||
// strings are copied by gtk_drop_down_new_from_strings, so the array can be
|
||||
// freed once every dropdown has been created.
|
||||
const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"),
|
||||
_("Key (hold)"), nullptr };
|
||||
char** typeShown = g_new(char*, 5);
|
||||
for (int i = 0; typeOptions[i] != nullptr; ++i) {
|
||||
typeShown[i] = g_strconcat(typeOptions[i], " ", nullptr);
|
||||
}
|
||||
typeShown[4] = nullptr;
|
||||
|
||||
rows.resize(kNumKeys);
|
||||
for (int i = 0; i < kNumKeys; ++i) {
|
||||
@@ -423,7 +713,7 @@ void build_ui(GtkApplication* app) {
|
||||
rows[i].keyLabel = keyLabel;
|
||||
|
||||
// Type dropdown (fixed width so rows don't jump between languages)
|
||||
GtkWidget* drop = gtk_drop_down_new_from_strings(typeOptions);
|
||||
GtkWidget* drop = gtk_drop_down_new_from_strings((const char* const*)typeShown);
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(drop), 0);
|
||||
gtk_widget_set_size_request(drop, 150, -1);
|
||||
gtk_grid_attach(GTK_GRID(grid), drop, 1, i + 1, 1, 1);
|
||||
@@ -448,14 +738,22 @@ void build_ui(GtkApplication* app) {
|
||||
|
||||
update_row_state(&rows[i]);
|
||||
}
|
||||
g_strfreev(typeShown);
|
||||
|
||||
// Start from the saved mappings. Without this the rows are empty and the
|
||||
// first "Save mappings" of a freshly opened window would overwrite the
|
||||
// user's file with empty rows. A missing file is not an error (first start).
|
||||
// Start from the rows the replaced instance had on screen, or - when this is
|
||||
// the only instance - from the saved mappings. Without either the rows are
|
||||
// empty and the first "Save mappings" of a freshly opened window would
|
||||
// overwrite the user's file with empty rows; a missing file is not an error
|
||||
// (first start). Taken once: after this the live rows are the source.
|
||||
if (!handedOverRows.empty()) {
|
||||
gkeymapper::apply_mappings_text(handedOverRows, rows);
|
||||
handedOverRows.clear();
|
||||
} else {
|
||||
const std::string configPath = gkeymapper::default_config_path();
|
||||
if (g_file_test(configPath.c_str(), G_FILE_TEST_EXISTS)) {
|
||||
gkeymapper::read_mappings(configPath, rows);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer with save button and the daemon status line
|
||||
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||||
@@ -492,9 +790,19 @@ void build_ui(GtkApplication* app) {
|
||||
gtk_label_set_ellipsize(GTK_LABEL(daemonLabel), PANGO_ELLIPSIZE_END);
|
||||
gtk_box_append(GTK_BOX(footer), daemonLabel);
|
||||
|
||||
mainWindow = window;
|
||||
// The type dropdowns and the header bar menu are popovers; without this they
|
||||
// grab the pointer and freeze the rest of the desktop while they are open.
|
||||
gkeymapper::make_popovers_release_input(GTK_WINDOW(window));
|
||||
// Closing is a request, not the end: the handler decides whether the window
|
||||
// hides to the tray or the app quits with it.
|
||||
g_signal_connect(window, "close-request", G_CALLBACK(on_close_request), nullptr);
|
||||
// `show` is false for the one start that must not put a window in the way: the
|
||||
// one the session's autostart entry makes (on_start_hidden_timeout is what then
|
||||
// decides whether it has to appear after all).
|
||||
if (show) {
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
|
||||
daemonClient.init(on_daemon_status, on_daemon_key);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_activate(GtkApplication* app, gpointer /*user_data*/) {
|
||||
@@ -519,11 +827,49 @@ 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));
|
||||
|
||||
build_ui(app);
|
||||
const char* helpAccels[] = {"F1", NULL};
|
||||
gtk_application_set_accels_for_action(app, "app.help", helpAccels);
|
||||
const char* quitAccels[] = {"<Control>q", NULL};
|
||||
gtk_application_set_accels_for_action(app, "app.quit", quitAccels);
|
||||
|
||||
// A start from the session leaves the window in the tray; a start by hand shows
|
||||
// it (main() reads the flag off the command line).
|
||||
build_ui(app, !startHidden);
|
||||
|
||||
// From here on this instance answers a new start with the rows its window
|
||||
// shows, so the instance that replaces it does not lose edits that were never
|
||||
// saved (see replace_running_instance). It has to come after build_ui(), which
|
||||
// is what fills the rows.
|
||||
gkeymapper::serve_rows_handover([]() { return gkeymapper::mappings_text(rows); });
|
||||
|
||||
// One-time, and deliberately not part of build_ui(): the window is rebuilt
|
||||
// when the language changes, while the tray, its poll and the daemon client
|
||||
// keep running as they are.
|
||||
start_tray(app);
|
||||
if (startHidden) {
|
||||
// The tray is what keeps a hidden window reachable; is it one? Only the
|
||||
// panel can answer that, and it does so asynchronously (start_tray above is
|
||||
// the question). Until then the window stays hidden and comes up if that
|
||||
// question was never answered.
|
||||
g_timeout_add(kStartHiddenGraceMs, on_start_hidden_timeout, nullptr);
|
||||
}
|
||||
// Four checks per second: one cheap call into the backend each, quick enough
|
||||
// that a minimized window does not linger in the task list. It does nothing
|
||||
// while the tray option is off.
|
||||
g_timeout_add(250, on_minimize_watch, nullptr);
|
||||
daemonClient.init(on_daemon_status, on_daemon_key);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
@@ -538,16 +884,40 @@ 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);
|
||||
|
||||
// The autostart entry starts the window in the tray and says so with an argument
|
||||
// of its own (kStartHiddenArg). GTK does not know that argument and would stop
|
||||
// on it, so it is taken out of the command line here, before it sees it.
|
||||
std::vector<char*> args;
|
||||
args.reserve(static_cast<size_t>(argc) + 1);
|
||||
for (int i = 0; i < argc; ++i) {
|
||||
if (g_strcmp0(argv[i], gkeymapper::kStartHiddenArg) == 0) {
|
||||
startHidden = true;
|
||||
continue;
|
||||
}
|
||||
args.push_back(argv[i]);
|
||||
}
|
||||
args.push_back(nullptr);
|
||||
|
||||
// One GUI only, and it is the one that was just started: close the instance
|
||||
// that already owns the app id (an older build, or the same one) before
|
||||
// becoming the primary instance. Without this the new process just hands over
|
||||
// to the old one and its stale window stays on screen.
|
||||
gkeymapper::replace_running_instance(gkeymapper::ipc::kAppId);
|
||||
// to the old one and its stale window stays on screen. Its rows come along,
|
||||
// so a mapping that was typed but not saved is not lost with it.
|
||||
gkeymapper::replace_running_instance(gkeymapper::ipc::kAppId, &handedOverRows);
|
||||
|
||||
GtkApplication* app = gtk_application_new(gkeymapper::ipc::kAppId,
|
||||
G_APPLICATION_FLAGS_NONE);
|
||||
g_signal_connect(app, "activate", G_CALLBACK(on_activate), nullptr);
|
||||
int status = g_application_run(G_APPLICATION(app), argc, argv);
|
||||
int status = g_application_run(G_APPLICATION(app), static_cast<int>(args.size()) - 1,
|
||||
args.data());
|
||||
g_object_unref(app);
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -55,4 +63,12 @@ void read_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string mappings_text(const std::vector<KeyRow>& rows) {
|
||||
return format_mappings(collect_mappings(rows));
|
||||
}
|
||||
|
||||
void apply_mappings_text(const std::string& text, const std::vector<KeyRow>& rows) {
|
||||
apply_mappings(parse_mappings(text), rows);
|
||||
}
|
||||
|
||||
} // namespace gkeymapper
|
||||
|
||||
@@ -6,9 +6,24 @@
|
||||
#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);
|
||||
|
||||
// The same round trip as text (the mappings file format), for rows that have to
|
||||
// leave the process: the window that replaces a running instance is given them by
|
||||
// it (see src/singleinstance), so edits that were never saved survive a second
|
||||
// start as well.
|
||||
std::string mappings_text(const std::vector<KeyRow>& rows);
|
||||
void apply_mappings_text(const std::string& text, 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);
|
||||
|
||||
|
||||
296
src/optionsdialog.cpp
Normal file
296
src/optionsdialog.cpp
Normal file
@@ -0,0 +1,296 @@
|
||||
// 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>
|
||||
|
||||
#include "popovers.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 minimized to the tray 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.
|
||||
|
||||
// Trailing space like the type dropdown in the main window: GTK draws the
|
||||
// check mark of the selected entry right behind the text.
|
||||
const char* languageOptions[] = { _("System language"), "Deutsch", "English", "Español",
|
||||
"Français", nullptr };
|
||||
char** languageShown = g_new(char*, 6);
|
||||
for (int i = 0; languageOptions[i] != nullptr; ++i) {
|
||||
languageShown[i] = g_strconcat(languageOptions[i], " ", nullptr);
|
||||
}
|
||||
languageShown[5] = nullptr;
|
||||
languageDrop_ = gtk_drop_down_new_from_strings((const char* const*)languageShown);
|
||||
g_strfreev(languageShown);
|
||||
// Right-aligned like the switches above: the GUI's own controls sit on one
|
||||
// line along the right edge, the text explains them on the left.
|
||||
gtk_widget_set_halign(languageDrop_, GTK_ALIGN_END);
|
||||
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);
|
||||
// The language dropdown is a popover; without this it grabs the pointer
|
||||
// and freezes the rest of the desktop while it is open.
|
||||
make_popovers_release_input(GTK_WINDOW(window_));
|
||||
}
|
||||
|
||||
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
83
src/optionsdialog.h
Normal 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
|
||||
144
src/popovers.cpp
Normal file
144
src/popovers.cpp
Normal file
@@ -0,0 +1,144 @@
|
||||
// gKeyMapper - popup handling that GTK gets wrong in this version.
|
||||
//
|
||||
// GTK 4.6's popovers - the list a dropdown opens and the header bar menu - grab
|
||||
// the pointer while they are open. An X11 grab swallows every click of the
|
||||
// other clients, and this GTK does not close the popup on a click outside the
|
||||
// application either: with a dropdown open, nothing on the desktop can be
|
||||
// clicked any more, neither another window nor the desktop itself, until Escape
|
||||
// is pressed. GTK 3 replayed such a click to the window under the pointer, GTK 4
|
||||
// dropped that. A popup that is left open is not the problem; a desktop that is
|
||||
// frozen is.
|
||||
//
|
||||
// A popover only grabs while its "autohide" is on, so switching that off hands
|
||||
// the clicks back to the rest of the desktop. The popups are then closed here,
|
||||
// which is the part that autohide would have done:
|
||||
// - a click in the window, outside the popup: on the press, so the click still
|
||||
// reaches the widget under the pointer (a previous popup must not eat it),
|
||||
// - a click on the widget that opened it: left alone, its own toggle closes it,
|
||||
// - a click in the popup: left alone, it picks the entry and closes the popup,
|
||||
// - a click anywhere else (another program, the desktop): the window loses the
|
||||
// focus, and that is what closes it here - the click itself goes to whatever
|
||||
// was clicked, which is the whole point,
|
||||
// - Escape.
|
||||
|
||||
#include "popovers.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace gkeymapper {
|
||||
namespace {
|
||||
|
||||
void collect_popovers(GtkWidget* widget, std::vector<GtkWidget*>* popovers) {
|
||||
for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr;
|
||||
child = gtk_widget_get_next_sibling(child)) {
|
||||
if (GTK_IS_POPOVER(child)) {
|
||||
popovers->push_back(child);
|
||||
}
|
||||
collect_popovers(child, popovers);
|
||||
}
|
||||
}
|
||||
|
||||
void popovers_of(GtkWindow* window, std::vector<GtkWidget*>* popovers) {
|
||||
collect_popovers(GTK_WIDGET(window), popovers);
|
||||
}
|
||||
|
||||
// A press in the popup itself is a choice and must reach the entry under the
|
||||
// pointer. GTK hands such a press to the popup's own surface, so this controller
|
||||
// normally does not see it at all - the check is a guard for the case where it
|
||||
// does, and it has to be by surface and not by position, because the coordinates
|
||||
// of that press are the popup's and not the window's.
|
||||
bool press_on_popup(GdkEvent* event, GtkWidget* popover) {
|
||||
GdkSurface* popup = gtk_native_get_surface(GTK_NATIVE(popover));
|
||||
return popup != nullptr && gdk_event_get_surface(event) == popup;
|
||||
}
|
||||
|
||||
bool press_inside(GtkWidget* widget, GtkWindow* window, double x, double y) {
|
||||
graphene_rect_t rect;
|
||||
if (widget == nullptr || !gtk_widget_compute_bounds(widget, GTK_WIDGET(window), &rect)) {
|
||||
return false;
|
||||
}
|
||||
return x >= rect.origin.x && x <= rect.origin.x + rect.size.width && y >= rect.origin.y &&
|
||||
y <= rect.origin.y + rect.size.height;
|
||||
}
|
||||
|
||||
void close_visible_popovers(GtkWindow* window) {
|
||||
std::vector<GtkWidget*> popovers;
|
||||
popovers_of(window, &popovers);
|
||||
for (GtkWidget* popover : popovers) {
|
||||
if (gtk_widget_get_visible(popover)) {
|
||||
gtk_popover_popdown(GTK_POPOVER(popover));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gboolean on_event(GtkEventControllerLegacy* /*controller*/, GdkEvent* event, gpointer userData) {
|
||||
GtkWindow* window = GTK_WINDOW(userData);
|
||||
const GdkEventType type = gdk_event_get_event_type(event);
|
||||
if (type != GDK_BUTTON_PRESS && type != GDK_KEY_PRESS) {
|
||||
return GDK_EVENT_PROPAGATE;
|
||||
}
|
||||
|
||||
std::vector<GtkWidget*> popovers;
|
||||
popovers_of(window, &popovers);
|
||||
for (GtkWidget* popover : popovers) {
|
||||
// A popover GTK made after this was installed must not grab either; the
|
||||
// press that opens it is seen here before the widget acts on it, and
|
||||
// switching a visible one off drops the grab right away.
|
||||
gtk_popover_set_autohide(GTK_POPOVER(popover), FALSE);
|
||||
}
|
||||
|
||||
if (type == GDK_KEY_PRESS) {
|
||||
if (g_strcmp0(gdk_keyval_name(gdk_key_event_get_keyval(event)), "Escape") == 0) {
|
||||
for (GtkWidget* popover : popovers) {
|
||||
if (gtk_widget_get_visible(popover)) {
|
||||
gtk_popover_popdown(GTK_POPOVER(popover));
|
||||
}
|
||||
}
|
||||
}
|
||||
return GDK_EVENT_PROPAGATE;
|
||||
}
|
||||
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
gdk_event_get_position(event, &x, &y);
|
||||
for (GtkWidget* popover : popovers) {
|
||||
if (!gtk_widget_get_visible(popover) || press_on_popup(event, popover)) {
|
||||
continue;
|
||||
}
|
||||
// The widget that opened the popup sits in the window, so the position is
|
||||
// in the window's coordinates here.
|
||||
if (press_inside(gtk_widget_get_parent(popover), window, x, y)) {
|
||||
continue;
|
||||
}
|
||||
gtk_popover_popdown(GTK_POPOVER(popover));
|
||||
}
|
||||
return GDK_EVENT_PROPAGATE;
|
||||
}
|
||||
|
||||
void on_focus_leave(GtkEventControllerFocus* /*controller*/, gpointer userData) {
|
||||
close_visible_popovers(GTK_WINDOW(userData));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void make_popovers_release_input(GtkWindow* window) {
|
||||
std::vector<GtkWidget*> popovers;
|
||||
popovers_of(window, &popovers);
|
||||
for (GtkWidget* popover : popovers) {
|
||||
gtk_popover_set_autohide(GTK_POPOVER(popover), FALSE);
|
||||
}
|
||||
|
||||
// A legacy controller does not claim the events it sees, so the widgets keep
|
||||
// behaving as usual; the capture phase is what makes it see a press before
|
||||
// the widget under the pointer does anything with it.
|
||||
GtkEventController* events = gtk_event_controller_legacy_new();
|
||||
gtk_event_controller_set_propagation_phase(events, GTK_PHASE_CAPTURE);
|
||||
g_signal_connect(events, "event", G_CALLBACK(on_event), window);
|
||||
gtk_widget_add_controller(GTK_WIDGET(window), events);
|
||||
|
||||
GtkEventController* focus = gtk_event_controller_focus_new();
|
||||
g_signal_connect(focus, "leave", G_CALLBACK(on_focus_leave), window);
|
||||
gtk_widget_add_controller(GTK_WIDGET(window), focus);
|
||||
}
|
||||
|
||||
} // namespace gkeymapper
|
||||
18
src/popovers.h
Normal file
18
src/popovers.h
Normal file
@@ -0,0 +1,18 @@
|
||||
// gKeyMapper - popup handling that GTK gets wrong in this version.
|
||||
//
|
||||
// See popovers.cpp for why this exists; in short, a popover that keeps its
|
||||
// input grab makes the rest of the desktop unclickable on X11.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
namespace gkeymapper {
|
||||
|
||||
// Call once per toplevel window, after the widgets are built. Every popover in
|
||||
// the window (the dropdowns' popup lists, the header bar menu) then lets the
|
||||
// pointer go, and is closed by us instead - outside click, Escape, or the
|
||||
// window losing the focus.
|
||||
void make_popovers_release_input(GtkWindow* window);
|
||||
|
||||
} // namespace gkeymapper
|
||||
95
src/settings.cpp
Normal file
95
src/settings.cpp
Normal 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
33
src/settings.h
Normal 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
|
||||
@@ -3,16 +3,20 @@
|
||||
// GtkApplication already makes a second process hand over to the first one (the
|
||||
// app id is unique), but that leaves the *old* window on screen - including one
|
||||
// from an earlier build. So main() first closes what is running and only then
|
||||
// becomes the primary instance itself.
|
||||
// becomes the primary instance itself. What it takes along are the rows that
|
||||
// window shows: the takeover would otherwise drop edits that were never saved.
|
||||
|
||||
#include "singleinstance.h"
|
||||
|
||||
#include <csignal>
|
||||
#include <utility>
|
||||
|
||||
#include <gio/gio.h>
|
||||
#include <glib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "ipc_ids.h"
|
||||
|
||||
namespace gkeymapper {
|
||||
|
||||
namespace {
|
||||
@@ -22,6 +26,9 @@ constexpr int kQuitWaitMs = 1500;
|
||||
constexpr int kTermWaitMs = 1500;
|
||||
constexpr int kPollMs = 50;
|
||||
|
||||
// Asked by the next start (see serve_rows_handover).
|
||||
RowsProvider gRowsProvider;
|
||||
|
||||
GDBusConnection* session_bus() {
|
||||
GError* error = nullptr;
|
||||
GDBusConnection* bus = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &error);
|
||||
@@ -94,6 +101,33 @@ bool ask_to_quit(GDBusConnection* bus, const std::string& name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The rows of the instance that is running, fetched before it goes away. No answer
|
||||
// (an older build has no such method) simply leaves the new window with the file.
|
||||
bool ask_for_rows(GDBusConnection* bus, const std::string& name, std::string& out) {
|
||||
GVariant* reply = call(bus, name.c_str(), ipc::kWindowObjectPath, ipc::kWindowInterface,
|
||||
ipc::kWindowRowsMethod, nullptr, G_VARIANT_TYPE("(s)"));
|
||||
if (reply == nullptr) {
|
||||
return false;
|
||||
}
|
||||
const gchar* text = nullptr;
|
||||
g_variant_get(reply, "(&s)", &text);
|
||||
out = text != nullptr ? text : "";
|
||||
g_variant_unref(reply);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Answers the next start. This runs on the main loop, which is the thread that
|
||||
// owns the widgets, so the provider may read them.
|
||||
void on_get_rows(GDBusConnection* /*connection*/, const gchar* /*sender*/,
|
||||
const gchar* /*objectPath*/, const gchar* /*interfaceName*/,
|
||||
const gchar* /*methodName*/, GVariant* /*parameters*/,
|
||||
GDBusMethodInvocation* invocation, gpointer /*userData*/) {
|
||||
const std::string rows = gRowsProvider ? gRowsProvider() : std::string();
|
||||
g_dbus_method_invocation_return_value(invocation, g_variant_new("(s)", rows.c_str()));
|
||||
}
|
||||
|
||||
const GDBusInterfaceVTable kHandoverVTable = {on_get_rows, nullptr, nullptr, {}};
|
||||
|
||||
pid_t owner_pid(GDBusConnection* bus, const std::string& name) {
|
||||
GVariant* reply = call(bus, "org.freedesktop.DBus", "/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus", "GetConnectionUnixProcessID",
|
||||
@@ -124,7 +158,32 @@ bool is_our_gui(pid_t pid) {
|
||||
|
||||
} // namespace
|
||||
|
||||
bool replace_running_instance(const std::string& appId) {
|
||||
void serve_rows_handover(RowsProvider provider) {
|
||||
gRowsProvider = std::move(provider);
|
||||
|
||||
GDBusConnection* bus = session_bus();
|
||||
if (bus == nullptr) {
|
||||
return; // without a bus nobody can ask
|
||||
}
|
||||
// Built from the ids, so the XML and the constants cannot drift apart.
|
||||
g_autofree gchar* xml = g_strdup_printf(
|
||||
"<node><interface name='%s'><method name='%s'>"
|
||||
"<arg type='s' name='rows' direction='out'/></method></interface></node>",
|
||||
ipc::kWindowInterface, ipc::kWindowRowsMethod);
|
||||
GError* error = nullptr;
|
||||
GDBusNodeInfo* info = g_dbus_node_info_new_for_xml(xml, &error);
|
||||
if (info != nullptr) {
|
||||
// The registration lives as long as the process; being called twice would
|
||||
// only report "already registered", which is not worth a message.
|
||||
g_dbus_connection_register_object(bus, ipc::kWindowObjectPath, info->interfaces[0],
|
||||
&kHandoverVTable, nullptr, nullptr, &error);
|
||||
g_dbus_node_info_unref(info);
|
||||
}
|
||||
g_clear_error(&error);
|
||||
g_object_unref(bus);
|
||||
}
|
||||
|
||||
bool replace_running_instance(const std::string& appId, std::string* rowsOut) {
|
||||
GDBusConnection* bus = session_bus();
|
||||
if (bus == nullptr) {
|
||||
return true; // without a bus there is no other instance either
|
||||
@@ -132,6 +191,11 @@ bool replace_running_instance(const std::string& appId) {
|
||||
|
||||
bool free = !has_owner(bus, appId);
|
||||
if (!free) {
|
||||
// The rows first, then the quit: after it there is nobody left to ask.
|
||||
if (rowsOut != nullptr) {
|
||||
ask_for_rows(bus, appId, *rowsOut);
|
||||
}
|
||||
|
||||
// Clean path: the running instance quits itself.
|
||||
ask_to_quit(bus, appId);
|
||||
free = wait_until_free(bus, appId, kQuitWaitMs);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#ifndef GKEYMAPPER_SINGLEINSTANCE_H
|
||||
#define GKEYMAPPER_SINGLEINSTANCE_H
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace gkeymapper {
|
||||
@@ -13,10 +14,25 @@ namespace gkeymapper {
|
||||
// terminated with SIGTERM instead, after checking that the bus name really
|
||||
// belongs to a gkeymapper process.
|
||||
//
|
||||
// Before the quit it asks that instance for the rows its window shows and puts
|
||||
// them into `rowsOut` (the mappings text, the same format as the file). A new
|
||||
// window starts from that instead of from the file, so edits that were never
|
||||
// saved are not lost with the replaced window. An instance that does not answer
|
||||
// (an older build) leaves `rowsOut` alone; pass nullptr to skip the asking.
|
||||
//
|
||||
// Returns true when the name is free afterwards, which is also the case when
|
||||
// nobody owned it. The caller still runs its usual single-instance logic, so a
|
||||
// `false` (a hung instance that cannot be closed) only means "no new window".
|
||||
bool replace_running_instance(const std::string& appId);
|
||||
bool replace_running_instance(const std::string& appId, std::string* rowsOut = nullptr);
|
||||
|
||||
// The rows of this window, as `mappings_text` produces them.
|
||||
using RowsProvider = std::function<std::string()>;
|
||||
|
||||
// The other side of the above: answers a new start that asks for the rows. Call it
|
||||
// once the window exists; `provider` runs on this process's main loop, so reading
|
||||
// the widgets is safe. Registering twice keeps the second provider, which only
|
||||
// matters if a start could reach here more than once.
|
||||
void serve_rows_handover(RowsProvider provider);
|
||||
|
||||
} // namespace gkeymapper
|
||||
|
||||
|
||||
635
src/trayicon.cpp
Normal file
635
src/trayicon.cpp
Normal 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
109
src/trayicon.h
Normal 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
|
||||
Reference in New Issue
Block a user