2026-10-17 02:42:09 +02:00
|
|
|
|
# AGENTS.md
|
|
|
|
|
|
|
|
|
|
|
|
## What this is
|
2026-10-17 03:53:51 +02:00
|
|
|
|
A pure GTK4 C++ GUI plus a background daemon for remapping the G1–G9 keys of a
|
|
|
|
|
|
Logitech G910. The GUI edits and persists mappings; the daemon reads the G-keys
|
|
|
|
|
|
and replays the configured action. Hardware G-key detection and action execution
|
|
|
|
|
|
live in the daemon, not the GUI. Code is split across `src/` (GUI), `shared/`
|
|
|
|
|
|
(common) and `daemon/`.
|
2026-10-17 02:42:09 +02:00
|
|
|
|
|
|
|
|
|
|
## Build / run / verify
|
2026-10-17 03:53:51 +02:00
|
|
|
|
- `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` and `libxkbcommon-dev` (see README for `apt install` on a
|
|
|
|
|
|
fresh machine).
|
|
|
|
|
|
- `make daemon` — builds only `gkeymapper-daemon` (needs libusb + xkbcommon,
|
|
|
|
|
|
not GTK).
|
2026-10-17 02:42:09 +02:00
|
|
|
|
- `make translations` — only runs `msgfmt` on the existing `.po` files.
|
|
|
|
|
|
- `make run` — builds, then runs with `GKEYMAPPER_LOCALEDIR=$PWD/locale` and
|
|
|
|
|
|
`GKEYMAPPER_ASSETS_DIR=$PWD/assets` so the working-tree translations and
|
|
|
|
|
|
assets are used. Plain `./gkeymapper` works too (assets fall back to
|
|
|
|
|
|
`./assets`).
|
|
|
|
|
|
- `make clean` — deletes the binary and the generated `locale/` tree.
|
|
|
|
|
|
- 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.
|
|
|
|
|
|
- All documentation and source comments are English. German appears only inside
|
|
|
|
|
|
`po/de.po`.
|
|
|
|
|
|
|
|
|
|
|
|
## Code layout and conventions
|
2026-10-17 03:53:51 +02:00
|
|
|
|
- Modules under `src/` (GUI), `shared/` (used by both binaries) and `daemon/`
|
|
|
|
|
|
(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_*`).
|
|
|
|
|
|
- The Makefile globs sources: `make` builds the GUI (`src/*.cpp` + `shared/*.cpp`)
|
|
|
|
|
|
**and** the daemon (`daemon/*.cpp` + `shared/*.cpp`, needs `libusb-1.0-0-dev`
|
|
|
|
|
|
and `libxkbcommon-dev`). `make daemon` builds only the daemon.
|
|
|
|
|
|
- The daemon is split into: `device_tables` (raw HID report tables per model),
|
|
|
|
|
|
`g910_device` (libusb open/detach/claim/read), `uinput_keyboard` (virtual
|
|
|
|
|
|
keyboard), `action` (dispatch `key`/`command`/`disabled`) and `config_watch`
|
|
|
|
|
|
(inotify auto-reload of the mappings file). `shared/keymap` does the layout
|
|
|
|
|
|
lookups for both sides. The hot path never touches the GUI.
|
|
|
|
|
|
- `make install` (PREFIX default `/usr/local`) also installs the packaging
|
|
|
|
|
|
files from `data/`: the udev rule, the `modules-load.d` fragment and the
|
|
|
|
|
|
systemd **user** unit. The unit contains an `@BINDIR@` placeholder that the
|
|
|
|
|
|
Makefile substitutes with `$(PREFIX)/bin`. Keep it a user service — it needs
|
|
|
|
|
|
the session for `/dev/uinput` and `DISPLAY`.
|
|
|
|
|
|
- Daemon config reload: `config_watch` watches the config **directory** (so an
|
|
|
|
|
|
atomic save via `rename` is caught) and reports a change at most once per loop
|
|
|
|
|
|
iteration, which is the coalescing window. `SIGHUP`/`SIGUSR1` remain as a
|
|
|
|
|
|
fallback. A failed reload keeps the previous mappings on purpose.
|
2026-10-17 02:57:21 +02:00
|
|
|
|
- `main.cpp` keeps the `rows` vector (one `KeyRow` per G key, widget pointers)
|
2026-10-17 03:53:51 +02:00
|
|
|
|
in an anonymous namespace; `src/mappings.cpp` is GUI glue that moves data
|
|
|
|
|
|
between the widgets and the shared `MappingSet`. App id:
|
2026-10-17 02:57:21 +02:00
|
|
|
|
`com.example.gkeymapper`.
|
2026-10-17 02:42:09 +02:00
|
|
|
|
- The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`,
|
2026-10-17 03:53:51 +02:00
|
|
|
|
`2=Disabled`, matching `ActionType` and the `key`/`command`/`disabled` tokens
|
|
|
|
|
|
in the file. Keep that order.
|
2026-10-17 02:42:09 +02:00
|
|
|
|
- The capture dialog's `CaptureSession` is heap-allocated and freed only by the
|
|
|
|
|
|
window's `"destroy"` handler. Always close the dialog with
|
|
|
|
|
|
`gtk_window_destroy` so the session is released.
|
|
|
|
|
|
- Use GTK4 API only: `gtk_editable_set_text` / `gtk_editable_get_text` (there is
|
|
|
|
|
|
no `gtk_entry_set_text`), `gtk_window_destroy` (no `gtk_dialog_run`),
|
|
|
|
|
|
`G_APPLICATION_FLAGS_NONE`.
|
|
|
|
|
|
- The main window is intentionally **non-resizable** and has **no**
|
|
|
|
|
|
`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.
|
|
|
|
|
|
- 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.
|
|
|
|
|
|
|
|
|
|
|
|
## Config file format
|
|
|
|
|
|
`Save mappings` writes `~/.config/gKeyMapper/mappings.conf`; Export/Import use
|
|
|
|
|
|
the same format:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
G1=key:ctrl+c,ctrl+v
|
|
|
|
|
|
G2=command:/usr/bin/firefox
|
|
|
|
|
|
G3=disabled:
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`<type>:<target>`, one line per key. `key` is a captured sequence (lowercase
|
|
|
|
|
|
`gdk_keyval_name` output; modifiers joined with `+`, steps joined with `,`),
|
2026-10-17 03:53:51 +02:00
|
|
|
|
`command` is a program path or command line, `disabled` is inactive. The
|
|
|
|
|
|
canonical modifier order is `ctrl`, `alt`, `shift`, `super`, `altgr`
|
|
|
|
|
|
(`format_shortcut_step()`; parsing accepts any order).
|
|
|
|
|
|
|
|
|
|
|
|
Key names are **layout independent**: `shared/keymap` resolves a name like `z`
|
|
|
|
|
|
to the key that produces `z` on the active layout (read from
|
|
|
|
|
|
`/etc/default/keyboard`, `XKBLAYOUT`) via xkbcommon — on a German keyboard `z` is
|
|
|
|
|
|
evdev 21, not the US position 44. `shared/keys` maps names to the US positions
|
|
|
|
|
|
and is only the fallback. When you add key names, keep them lowercase GDK keysym
|
|
|
|
|
|
names. `parse_shortcut()` takes an optional resolver for this (the daemon passes
|
|
|
|
|
|
one).
|
|
|
|
|
|
|
|
|
|
|
|
The capture dialog stores the **base (level 0) key**: it uses
|
|
|
|
|
|
`Keymap::base_key_name(keycode)` instead of GDK's shifted keyval, so
|
|
|
|
|
|
`Shift`+ß becomes `shift+ssharp` (not `shift+question`, which would double the
|
|
|
|
|
|
shift). `format_shortcut_step()` — not the dialog — renders a step from a
|
|
|
|
|
|
`ModifierBit` mask, so the order stays in one place.
|
|
|
|
|
|
|
|
|
|
|
|
AltGr (level 3) is recorded as the `altgr` modifier plus the base key
|
|
|
|
|
|
(`AltGr`+7 → `altgr+7`). GTK4 dropped the `GDK_MOD5_MASK` name, so the dialog
|
|
|
|
|
|
tracks `ISO_Level3_Shift` / `Mode_switch` through its own key press/release
|
|
|
|
|
|
(`CaptureSession::altGrHeld`) instead of a state bit. On the replay side
|
|
|
|
|
|
`kModAltGr` maps to `KEY_RIGHTALT`.
|
2026-10-17 02:42:09 +02:00
|
|
|
|
|
|
|
|
|
|
## Assets
|
|
|
|
|
|
`find_asset()` resolves in order: `$GKEYMAPPER_ASSETS_DIR` → `./assets` →
|
|
|
|
|
|
compiled-in `ASSETS_DIR` (`/usr/local/share/gkeymapper/assets`). The icon
|
|
|
|
|
|
(`assets/icon.png`) is used in the header bar and the About dialog.
|
|
|
|
|
|
|
2026-10-17 03:53:51 +02:00
|
|
|
|
## Driving the daemon for manual checks
|
|
|
|
|
|
- The process name is truncated by the kernel to 15 chars, so `pkill -x
|
|
|
|
|
|
gkeymapper-daemon` and `pgrep -x gkeymapper-daemon` **never match**. Use
|
|
|
|
|
|
`pkill -x gkeymapper-daem` / `pgrep -x gkeymapper-daem` (or `pkill -f
|
|
|
|
|
|
'./gkeymapper-daemon'`).
|
|
|
|
|
|
- Run it in the background with `setsid nohup ./gkeymapper-daemon [config]
|
|
|
|
|
|
> /tmp/daemon.log 2>&1 < /dev/null &`; it survives across shell calls. It
|
|
|
|
|
|
takes an optional config path as `argv[1]` (default
|
|
|
|
|
|
`~/.config/gKeyMapper/mappings.conf`).
|
|
|
|
|
|
- `SIGHUP`/`SIGUSR1` reload the mappings
|
|
|
|
|
|
(`kill -HUP $(pgrep -x gkeymapper-daem)`). Normally you do not need them: the
|
|
|
|
|
|
config file is watched, so saving in the GUI reloads within about half a
|
|
|
|
|
|
second. Use `SIGHUP` to check the signal path after touching it.
|
|
|
|
|
|
- Test the auto-reload without hardware: write the config through the shared
|
|
|
|
|
|
save path (atomic temp+rename, as the GUI does) and watch `/tmp/daemon.log`
|
|
|
|
|
|
for a second `loaded ...` line. `daemon/config_watch.cpp` also has a
|
|
|
|
|
|
standalone test shape: create a `ConfigWatcher`, `poll_events()` then
|
|
|
|
|
|
`take_change()` per simulated loop iteration.
|
|
|
|
|
|
- `make install DESTDIR=/tmp/stage` stages the packaging files; inspect
|
|
|
|
|
|
`/tmp/stage/usr/local/lib/...` to verify the unit's `ExecStart` substitution.
|
|
|
|
|
|
`systemd-analyze --user verify <unit>` checks the unit. There is no
|
|
|
|
|
|
`udevadm verify` on systemd 249, so the udev rule is checked by eye against
|
|
|
|
|
|
the reference project's rule.
|
|
|
|
|
|
- Starting it detaches the kernel driver from the vendor interface and creates a
|
|
|
|
|
|
`/dev/uinput` keyboard. Warn the user first: while it runs the media keys may
|
|
|
|
|
|
not work, and `key` mappings are injected into the **focused** window.
|
|
|
|
|
|
- The keyboard is `046d:c32b` (also handle `046d:c335`); on this machine the USB
|
|
|
|
|
|
device and `/dev/uinput` already carry ACLs for the user, so no root is needed.
|
|
|
|
|
|
- Verify decoding by pressing G keys and reading `/tmp/daemon.log` (it logs
|
|
|
|
|
|
`G1 -> key:...` per press). Verify what the virtual keyboard emits at the X
|
|
|
|
|
|
level with `xinput test <keyboard-id>` (`gKeyMapper virtual keyboard` shows up
|
|
|
|
|
|
as both a pointer and a keyboard — use the **keyboard** id). X keycodes are
|
|
|
|
|
|
evdev codes + 8. There is no `xdotool`/`wtype`.
|
|
|
|
|
|
|
2026-10-17 02:42:09 +02:00
|
|
|
|
## Driving the GUI for manual checks
|
|
|
|
|
|
There is no `xdotool`/`wtype` on this machine, and the capture-dialog buttons
|
|
|
|
|
|
(`Clear`/`Cancel`/`Done`) expose an empty AT-SPI accessible name — the text is in
|
|
|
|
|
|
a child `label` node, so target the button by its child label. `pyatspi`
|
|
|
|
|
|
(`queryAction().doAction(0)`) plus `Xlib`'s `xtest.fake_input` can click buttons
|
|
|
|
|
|
and inject keys. Warn the user before synthesizing keystrokes or stealing window
|
|
|
|
|
|
focus.
|