Nicer structure
This commit is contained in:
16
AGENTS.md
16
AGENTS.md
@@ -4,7 +4,7 @@
|
|||||||
A pure GTK4 C++ GUI for remapping the G1–G9 keys of a Logitech G910. Today it
|
A pure GTK4 C++ GUI for remapping the G1–G9 keys of a Logitech G910. Today it
|
||||||
only edits and persists mappings: there is **no** hardware G-key detection and
|
only edits and persists mappings: there is **no** hardware G-key detection and
|
||||||
**no** command execution yet (a native daemon is the planned next step). The
|
**no** command execution yet (a native daemon is the planned next step). The
|
||||||
whole program is one translation unit, `src/main.cpp`.
|
code is split into a few small translation units under `src/` (see below).
|
||||||
|
|
||||||
## Build / run / verify
|
## Build / run / verify
|
||||||
- `make` — builds the `gkeymapper` binary **and** the `.mo` catalogs.
|
- `make` — builds the `gkeymapper` binary **and** the `.mo` catalogs.
|
||||||
@@ -30,10 +30,16 @@ whole program is one translation unit, `src/main.cpp`.
|
|||||||
`po/de.po`.
|
`po/de.po`.
|
||||||
|
|
||||||
## Code layout and conventions
|
## Code layout and conventions
|
||||||
- `src/main.cpp` holds everything in an anonymous namespace. The global `rows`
|
- Modules under `src/` (shared API lives in `namespace gkeymapper`):
|
||||||
vector keeps one `KeyRow` (widget pointers) per G key. `on_activate` registers
|
`keyrow.h` (`KeyRow`, `kNumKeys`), `mappings.{h,cpp}` (config file I/O),
|
||||||
the app actions `app.export-config`, `app.import-config`, `app.about`;
|
`capturedialog.{h,cpp}` (key-capture dialog), `main.cpp` (app actions + UI).
|
||||||
`build_ui` constructs the window. App id: `com.example.gkeymapper`.
|
- The Makefile globs sources (`$(wildcard src/*.cpp)`): a new `.cpp` is compiled
|
||||||
|
automatically, and `src/*.h` are prerequisites of the target.
|
||||||
|
- `main.cpp` keeps the `rows` vector (one `KeyRow` per G key, widget pointers)
|
||||||
|
in an anonymous namespace; mappings functions receive it as a parameter.
|
||||||
|
`on_activate` registers the app actions `app.export-config`,
|
||||||
|
`app.import-config`, `app.about`; `build_ui` constructs the window. App id:
|
||||||
|
`com.example.gkeymapper`.
|
||||||
- The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`,
|
- The Type dropdown index is meaningful everywhere: `0=Key`, `1=Command`,
|
||||||
`2=Disabled`. Keep that order — import/export map against it.
|
`2=Disabled`. Keep that order — import/export map against it.
|
||||||
- The capture dialog's `CaptureSession` is heap-allocated and freed only by the
|
- The capture dialog's `CaptureSession` is heap-allocated and freed only by the
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -13,7 +13,8 @@ GTK_LIBS := $(shell $(PKG_CONFIG) --libs gtk4)
|
|||||||
PACKAGE := gkeymapper
|
PACKAGE := gkeymapper
|
||||||
TARGET := gkeymapper
|
TARGET := gkeymapper
|
||||||
SRC_DIR := src
|
SRC_DIR := src
|
||||||
SOURCES := $(SRC_DIR)/main.cpp
|
SOURCES := $(wildcard $(SRC_DIR)/*.cpp)
|
||||||
|
HEADERS := $(wildcard $(SRC_DIR)/*.h)
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
LOCALEDIR := $(PREFIX)/share/locale
|
LOCALEDIR := $(PREFIX)/share/locale
|
||||||
ASSETS_DIR := $(PREFIX)/share/$(PACKAGE)/assets
|
ASSETS_DIR := $(PREFIX)/share/$(PACKAGE)/assets
|
||||||
@@ -29,7 +30,7 @@ MO_FILES := $(patsubst %,locale/%/LC_MESSAGES/$(PACKAGE).mo,$(LANGS))
|
|||||||
|
|
||||||
all: $(TARGET) translations
|
all: $(TARGET) translations
|
||||||
|
|
||||||
$(TARGET): $(SOURCES)
|
$(TARGET): $(SOURCES) $(HEADERS)
|
||||||
$(CXX) $(CXXFLAGS) $(GTK_CFLAGS) -o $@ $(SOURCES) $(GTK_LIBS)
|
$(CXX) $(CXXFLAGS) $(GTK_CFLAGS) -o $@ $(SOURCES) $(GTK_LIBS)
|
||||||
|
|
||||||
translations: $(MO_FILES)
|
translations: $(MO_FILES)
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ replay is planned next, no Python involved).
|
|||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/main.cpp # GTK4 GUI
|
src/main.cpp # app actions + GUI
|
||||||
|
src/keyrow.h # KeyRow / kNumKeys
|
||||||
|
src/mappings.{h,cpp} # mappings.conf read/write
|
||||||
|
src/capturedialog.{h,cpp} # key-capture dialog
|
||||||
po/ # Translations (pot/po/mo targets)
|
po/ # Translations (pot/po/mo targets)
|
||||||
Makefile # Build via pkg-config + gettext
|
Makefile # Build via pkg-config + gettext
|
||||||
.vscode/tasks.json # VS Code tasks for make
|
.vscode/tasks.json # VS Code tasks for make
|
||||||
|
|||||||
335
docs/daemon-plan.md
Normal file
335
docs/daemon-plan.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# gKeyMapper daemon — implementation plan
|
||||||
|
|
||||||
|
Status: draft / proposal
|
||||||
|
Scope: a native C/C++ background service that detects G1–G9 (and later M1–M3/MR
|
||||||
|
and media keys) on the Logitech G910 and replays the mappings stored by the GUI.
|
||||||
|
|
||||||
|
## 1. Goals and non-goals
|
||||||
|
|
||||||
|
Goals
|
||||||
|
- Detect G-key presses in C/C++ and execute the configured action.
|
||||||
|
- Run with a very small, stable resource footprint (idle ≈ 0 % CPU, few MB RSS,
|
||||||
|
no interpreter, no polling when nothing happens).
|
||||||
|
- Reuse the existing `mappings.conf` format as the single source of truth.
|
||||||
|
- Let the GUI and the daemon run independently; either can start first.
|
||||||
|
- No Python at runtime.
|
||||||
|
|
||||||
|
Non-goals (v1)
|
||||||
|
- Profiles (M1–M3/MR), per-locale text typing (`typeout`), embedded scripting.
|
||||||
|
- GUI redesign beyond a status indicator / reload / test button.
|
||||||
|
|
||||||
|
## 2. What the Python reference does (and what we keep)
|
||||||
|
|
||||||
|
Reference: <https://github.com/JSubelj/g910-gkey-macro-support>
|
||||||
|
|
||||||
|
- G-keys are **not** exposed as distinct evdev key codes. The driver therefore
|
||||||
|
opens the keyboard's **vendor HID interface (USB interface 1) directly via
|
||||||
|
libusb**, detaches the kernel driver from that interface, and reads raw
|
||||||
|
interrupt-IN reports.
|
||||||
|
- On start it sends a control transfer to **disable the firmware F-key→G-key
|
||||||
|
binding**, so pressing G1..G9 no longer also emits F1..F9 on the normal
|
||||||
|
keyboard interface.
|
||||||
|
- Raw report byte patterns are looked up in tables
|
||||||
|
(`events.macroKeys`, `memoryKeys`, `mediaKeys`, `releaseEvents`) and turned
|
||||||
|
into actions.
|
||||||
|
- A **uinput virtual keyboard** replays `shortcut` (press, later release),
|
||||||
|
`typeout` (per-char), `uinput` (direct `KEY_*`); `run` uses `/bin/bash -c`.
|
||||||
|
- Config is JSON at `~/.config/g910-gkeys/config.json`, reloaded on directory
|
||||||
|
change (inotify via `fcntl`), plus `SIGUSR1` reload.
|
||||||
|
- Ship: a **systemd --user** service and **udev rules** with `TAG+="uaccess"` for
|
||||||
|
the device and `/dev/uinput`, so no root is needed.
|
||||||
|
|
||||||
|
Relevant USB IDs (from the udev rules): `046d:c335`, `c33f`, `c33e`, `c32b`,
|
||||||
|
`c24d`, `c22d`. The G910 in this project is `046d:c32b`.
|
||||||
|
|
||||||
|
What we keep: libusb raw HID access, disabling the F-key binding, uinput output,
|
||||||
|
config-dir watching. What we change: JSON→our line format, single-threaded
|
||||||
|
event-driven loop instead of a Python loop with fixed sleeps, fork/exec reaping,
|
||||||
|
and a real IPC channel to the GUI.
|
||||||
|
|
||||||
|
## 3. Architecture overview
|
||||||
|
|
||||||
|
```
|
||||||
|
+---------------------+ mappings.conf (shared truth) +----------------------+
|
||||||
|
| gkeymapper (GUI) | <------------ write / inotify ---------> | gkeymapper-daemon |
|
||||||
|
| GTK4 + GIO/GDBus | | libusb + uinput |
|
||||||
|
| | <===== D-Bus (session bus) ============> | GDBus + inotify |
|
||||||
|
+---------------------+ status / reload / events +----------+-----------+
|
||||||
|
| raw HID (iface 1)
|
||||||
|
v
|
||||||
|
+----------------------+
|
||||||
|
| Logitech G910 |
|
||||||
|
+----------+-----------+
|
||||||
|
| uinput
|
||||||
|
v
|
||||||
|
+----------------------+
|
||||||
|
| /dev/uinput keyboard |
|
||||||
|
+----------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
Key property: the **hot path never crosses IPC**. G-key press → decode → uinput
|
||||||
|
happens entirely inside the daemon. IPC (D-Bus) is only used for control, status
|
||||||
|
and notifications, so a slow/absent GUI never delays a keypress.
|
||||||
|
|
||||||
|
## 4. Daemon design
|
||||||
|
|
||||||
|
### 4.1 Process model
|
||||||
|
- One process, single-threaded, event loop.
|
||||||
|
- No threads in v1. `run`/`command` actions are launched with `fork()` +
|
||||||
|
`execve()`; children are reaped via a `SIGCHLD`/`signalfd` handler so no
|
||||||
|
zombies accumulate (the reference leaks a `Popen` per invocation).
|
||||||
|
- `python` type is intentionally dropped.
|
||||||
|
|
||||||
|
### 4.2 Event loop
|
||||||
|
Use one `poll()`/`epoll()` loop over:
|
||||||
|
- the **libusb** file descriptor (from `libusb_get_pollfds()`; use libusb's async
|
||||||
|
API or `libusb_interrupt_transfer` on a worker, see below),
|
||||||
|
- an **inotify** fd for the config file/directory,
|
||||||
|
- a **signalfd** for `SIGTERM`/`SIGINT`/`SIGUSR1`,
|
||||||
|
- the **D-Bus** connection (GDBus integrates with the same `GMainContext`).
|
||||||
|
|
||||||
|
Preferred: drive libusb asynchronously with `libusb_submit_transfer()` and fold
|
||||||
|
the reported poll fds into the same loop (libusb has an official "poll with
|
||||||
|
another event loop" pattern). That yields exactly **0 wakeups while idle**
|
||||||
|
(excluding the kernel's own HID interrupts). If that is too much work for v1,
|
||||||
|
fall back to a blocking `libusb_interrupt_transfer(..., timeout=1000)` inside a
|
||||||
|
dedicated thread that posts events into the main loop — still cheap (1 wake/s).
|
||||||
|
|
||||||
|
To keep GDBus and the fd loop together, the loop is easiest on the **GLib main
|
||||||
|
loop** (`GMainLoop`): GDBus, `GFileMonitor` and `GSocket`/`GSource` cover
|
||||||
|
inotify and signalfd, and libusb poll fds can be wrapped in a custom
|
||||||
|
`GSource`. GLib is already a dependency of the GUI, so this adds no new library.
|
||||||
|
If minimal footprint is the priority over convenience, a raw `epoll` loop is the
|
||||||
|
alternative; decision point in §11.
|
||||||
|
|
||||||
|
### 4.3 Device layer (`libusb`)
|
||||||
|
- Find the device by vendor/product from the supported-device table; pick
|
||||||
|
interface 1, endpoint from the same table.
|
||||||
|
- `libusb_detach_kernel_driver()` on interface 1; remember to
|
||||||
|
`libusb_attach_kernel_driver()` on shutdown (reference does this).
|
||||||
|
- Send the "disable F-key to G-key binding" control transfer
|
||||||
|
(`bmRequestType=0x21, bRequest=0x09, wValue=0x0212, wIndex=1`) and consume the
|
||||||
|
confirmation reports; ignore the known LED-return patterns
|
||||||
|
(`\x11\xff\x0f`, `\x11\xff\x10`, `\x11\xff\xff`).
|
||||||
|
- Hot-plug / re-plug: on `LIBUSB_ERROR_NO_DEVICE`/`IO` re-run discovery with a
|
||||||
|
small backoff (reference re-inits in place).
|
||||||
|
- Optional (later): `libusb_hotplug_register_callback` to react to device
|
||||||
|
add/remove without polling.
|
||||||
|
|
||||||
|
The device table in `supported_devices.py` (byte patterns per model) is ported
|
||||||
|
to a generated C++ header (`constexpr` tables) — keep it a build-time
|
||||||
|
artifact so the runtime does zero parsing.
|
||||||
|
|
||||||
|
### 4.4 Decoding
|
||||||
|
- The report is a fixed-length byte blob. Build a **compile-time lookup**:
|
||||||
|
a sorted array of `{report bytes, action id}` with binary search, or a small
|
||||||
|
open-addressed hash keyed on the first 8 bytes. This replaces the Python
|
||||||
|
`dict` + `list(...).index()` O(n) scans with O(1)/O(log n) and no allocation.
|
||||||
|
- Categories: `macro` (G1..G9), `memory` (profiles, later), `media` (later),
|
||||||
|
`release` (stops a held shortcut), and ignored/known-noise.
|
||||||
|
|
||||||
|
### 4.5 Action layer (uinput)
|
||||||
|
- Open `/dev/uinput` once; `UI_SET_EVBIT` for `EV_KEY`, register the key set used
|
||||||
|
by the mappings, `UI_DEV_CREATE`. Keep the fd for the daemon's lifetime.
|
||||||
|
- Emit events by `write()`-ing `struct input_event` (from
|
||||||
|
`<linux/uinput.h>` / `<linux/input-event-codes.h>` — no libevdev needed).
|
||||||
|
- `key` mapping = shortcut grammar `ctrl+c,ctrl+v`:
|
||||||
|
- single combo: press modifiers+key in order, remember the keys, release them
|
||||||
|
when the keyboard's `release` event arrives (mirror the reference's
|
||||||
|
`press_release_fifo` stack).
|
||||||
|
- comma list: `emit_combo()` each group (press+release).
|
||||||
|
- `command` mapping: `fork()`; in the child `setsid()`, redirect stdio to
|
||||||
|
`/dev/null`, `execve("/bin/sh", "-c", cmd)`. Reap via `SIGCHLD`.
|
||||||
|
- `disabled`: no-op.
|
||||||
|
- Give the uinput device a clear name/IDs (e.g. `gkeymapper virtual keyboard`)
|
||||||
|
so `libinput`/users can identify it.
|
||||||
|
|
||||||
|
### 4.6 Config
|
||||||
|
- Parse the existing `mappings.conf`:
|
||||||
|
`G<n>=<type>:<target>` with `type ∈ {key, command, disabled}`.
|
||||||
|
- Parse at load and on change only; the runtime holds a small pre-parsed action
|
||||||
|
per G-key (enum + fixed target buffer / `std::string`), so no parsing in the
|
||||||
|
hot path.
|
||||||
|
- Watch the config file (and, for atomic-save editors, its directory) with
|
||||||
|
inotify/`GFileMonitor`; reload on `IN_CLOSE_WRITE`/`IN_MOVED_TO`. Debounce
|
||||||
|
(e.g. 100 ms) to coalesce editor save bursts.
|
||||||
|
- Also reload on `SIGUSR1` (manual/test) and on `Reload()` D-Bus call.
|
||||||
|
|
||||||
|
### 4.7 Privileges and packaging
|
||||||
|
- udev rule with `TAG+="uaccess"` for the supported USB IDs and for
|
||||||
|
`KERNEL=="uinput"` (mirrors the reference's `60-g910-gkeys.rules`).
|
||||||
|
- `uinput` kernel module must be present; ship `/etc/modules-load.d/` fragment
|
||||||
|
or document `modprobe uinput` (here it is built in).
|
||||||
|
- **systemd --user** unit `gkeymapper-daemon.service`:
|
||||||
|
`Type=dbus`, `BusName=…`, `ExecStart=gkeymapper-daemon`,
|
||||||
|
`Restart=on-failure`, `Documentation=…`. `BusName` enables D-Bus activation,
|
||||||
|
so starting the GUI can start the daemon on demand.
|
||||||
|
- Do **not** require root. Commands run with the user's privileges (the
|
||||||
|
reference's "executed as root" note is a wiki artifact, not a design goal).
|
||||||
|
|
||||||
|
## 5. GUI ↔ daemon interaction
|
||||||
|
|
||||||
|
Three layers, from loosest to tightest:
|
||||||
|
|
||||||
|
1. **Shared state = the config file** (`~/.config/gKeyMapper/mappings.conf`).
|
||||||
|
The GUI is the writer, the daemon is a watcher/reader. This alone is enough
|
||||||
|
for basic operation and is robust: whichever side starts first, the other
|
||||||
|
converges. The daemon reloads via inotify; no coupling to the GUI process.
|
||||||
|
2. **Control/status = D-Bus on the session bus** (GDBus; part of GIO, already
|
||||||
|
linked by the GUI). Used for status, explicit reload, and notifications.
|
||||||
|
3. **Lifecycle**: the daemon is a `systemd --user` service; the GUI can start it
|
||||||
|
implicitly by calling a D-Bus method (activation) and can show whether it is
|
||||||
|
running.
|
||||||
|
|
||||||
|
### 5.1 Proposed D-Bus interface
|
||||||
|
|
||||||
|
- Bus name: `<app-id>` — decide once (§11). Suggested: `dev.rager.gkeymapper`
|
||||||
|
(replace the current placeholder `com.example.gkeymapper`).
|
||||||
|
- Object path: `/dev/rager/gkeymapper/Daemon`
|
||||||
|
- Interface: `dev.rager.gkeymapper.Daemon`
|
||||||
|
|
||||||
|
Methods
|
||||||
|
| Method | Signature | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `Reload` | `() -> ()` | Re-read `mappings.conf` now (GUI "Save" also calls this). |
|
||||||
|
| `GetStatus` | `() -> (s version, b running, s device, s configPath)` | Status for the GUI footer/indicator. |
|
||||||
|
| `TestMapping` | `(u index) -> ()` | Fire mapping for G1..G9 without hardware (GUI "Test" button). |
|
||||||
|
| `SetProfile` | `(s profile) -> ()` | Reserved for M-key profiles (later). |
|
||||||
|
|
||||||
|
Signals
|
||||||
|
| Signal | Signature | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `KeyPressed` | `(u index, s action)` | A G-key fired (for GUI highlight/history). |
|
||||||
|
| `ConfigReloaded` | `(b ok)` | Daemon finished reloading. |
|
||||||
|
| `DeviceChanged` | `(b present, s device)` | Keyboard attached/detached. |
|
||||||
|
|
||||||
|
Notes
|
||||||
|
- All are control-plane calls; none is on the keypress path.
|
||||||
|
- `Reload` is best-effort: inotify is the backstop if the call is lost.
|
||||||
|
- If D-Bus is unavailable, the daemon still works; the GUI degrades to
|
||||||
|
"no status" and "save only". Signals `SIGUSR1` (reload) / `SIGTERM` remain for
|
||||||
|
scripted use, matching the reference.
|
||||||
|
|
||||||
|
### 5.2 Interaction sequences
|
||||||
|
|
||||||
|
Save in the GUI:
|
||||||
|
```
|
||||||
|
GUI: write mappings.conf (atomic: temp + rename)
|
||||||
|
GUI: call Daemon.Reload() [optional, for immediacy]
|
||||||
|
Daemon: inotify IN_MOVED_TO -> (re)parse -> apply
|
||||||
|
Daemon: emit ConfigReloaded(ok)
|
||||||
|
```
|
||||||
|
|
||||||
|
Key press:
|
||||||
|
```
|
||||||
|
G910 --raw HID--> Daemon -> decode -> uinput emit / fork+exec
|
||||||
|
Daemon: emit KeyPressed(index, action) [GUI only, non-blocking]
|
||||||
|
```
|
||||||
|
|
||||||
|
Start-up orders:
|
||||||
|
- Daemon first, GUI later: GUI reads status via D-Bus; no config write needed.
|
||||||
|
- GUI first, daemon later: GUI writes config; on daemon start it reads the file.
|
||||||
|
|
||||||
|
### 5.3 GUI-side changes (small)
|
||||||
|
- On "Save mappings": keep writing the file, then call `Reload()` (ignore error).
|
||||||
|
- Add a footer status line: daemon running / device present / last reload, fed by
|
||||||
|
`GetStatus` + the `DeviceChanged`/`ConfigReloaded` signals.
|
||||||
|
- Optional "Test" button per row → `TestMapping(index)`.
|
||||||
|
- The key-name vocabulary must match: the capture dialog already writes
|
||||||
|
lowercase GDK key names; move the canonical name list into the shared module
|
||||||
|
(§6) so GUI and daemon agree on the same spellings.
|
||||||
|
|
||||||
|
## 6. Code layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ # GUI (existing)
|
||||||
|
daemon/
|
||||||
|
main.cpp # event loop, wiring
|
||||||
|
device_libusb.{h,cpp} # discovery, detach/attach, read, disable-binding
|
||||||
|
decoder.{h,cpp} # report bytes -> action id (compile-time tables)
|
||||||
|
uinput_keyboard.{h,cpp}# virtual keyboard, emit / combo / press-release stack
|
||||||
|
actions.{h,cpp} # key / command / disabled execution, child reaping
|
||||||
|
config.{h,cpp} # mappings.conf parse + watch
|
||||||
|
ipc_dbus.{h,cpp} # GDBus service
|
||||||
|
generated/devices.inc # ported from supported_devices.py
|
||||||
|
shared/
|
||||||
|
mapping.{h,cpp} # parse/format mappings.conf (used by GUI and daemon)
|
||||||
|
keys.{h,cpp} # canonical names <-> gdk keyval <-> linux KEY_*
|
||||||
|
ipc_ids.h # D-Bus name/path/interface constants
|
||||||
|
data/
|
||||||
|
60-gkeymapper.rules
|
||||||
|
gkeymapper-daemon.service
|
||||||
|
uinput.conf # modules-load.d
|
||||||
|
po/ # daemon strings, reuse the existing catalogs
|
||||||
|
```
|
||||||
|
|
||||||
|
Build: the Makefile gets a second target `gkeymapper-daemon` and install rules
|
||||||
|
for the unit, udev rule and modules-load fragment. New build dependency:
|
||||||
|
`libusb-1.0-0-dev` (GLib/GIO is already required by the GUI).
|
||||||
|
|
||||||
|
## 7. Resource-efficiency measures (summary)
|
||||||
|
|
||||||
|
- No Python interpreter; native binary, tables in `.rodata`.
|
||||||
|
- Event-driven loop, **no idle polling** (libusb async + inotify + signalfd; or a
|
||||||
|
1 s blocking USB read in the simple variant).
|
||||||
|
- O(1)/O(log n) decode via compile-time tables instead of dict/linear scan.
|
||||||
|
- One `uinput` fd for the process lifetime; fixed-size event writes; no
|
||||||
|
per-event heap allocation.
|
||||||
|
- Config parsed only on load/reload; hot path is table lookups + `write()`.
|
||||||
|
- `fork`+`exec` for programs, children reaped (no zombies), stdio to `/dev/null`;
|
||||||
|
no shell parsing for `key`/`uinput` actions.
|
||||||
|
- Target footprint: idle 0 % CPU, wakeups only on real HID/DBus/file events,
|
||||||
|
RSS in the low single-digit MB.
|
||||||
|
|
||||||
|
## 8. Milestones
|
||||||
|
|
||||||
|
- **M0 – Shared core.** `shared/mapping` + `shared/keys`; refactor the GUI to use
|
||||||
|
them (still file-only). Unit tests for parse/format and key tables.
|
||||||
|
- **M1 – Device bring-up.** `daemon` opens the G910 via libusb, disables the
|
||||||
|
F-key binding, logs `G1..G9` presses. No output yet.
|
||||||
|
- **M2 – Action layer.** uinput keyboard; `key` (shortcut, incl. release) and
|
||||||
|
`command` work end-to-end. `disabled` no-op.
|
||||||
|
- **M3 – Config & lifecycle.** config parse/reload (inotify, SIGUSR1), signals,
|
||||||
|
systemd --user unit + udev rule + modules-load.
|
||||||
|
- **M4 – IPC.** GDBus service (`Reload`, `GetStatus`, `TestMapping`, signals);
|
||||||
|
GUI status line + Reload-on-save + optional Test button.
|
||||||
|
- **M5 – Polish.** Packaging/install rules, docs, translations, hardening
|
||||||
|
(hot-plug, debounce, error reporting to the GUI).
|
||||||
|
|
||||||
|
## 9. Testing strategy
|
||||||
|
|
||||||
|
- Unit: mapping parser round-trip, key-name tables, decoder against recorded
|
||||||
|
byte sequences from the reference tables.
|
||||||
|
- Device: replay captured reports into the decoder (no hardware needed).
|
||||||
|
- Integration: `evtest` / `libinput debug-events` to observe the virtual device;
|
||||||
|
compare `xev` output for a configured shortcut between reference and daemon.
|
||||||
|
- Permission tests: verify the udev rule grants access as a normal user.
|
||||||
|
- Resource check: `powertop`/`perf` or simple wakeup counting while idle.
|
||||||
|
|
||||||
|
## 10. Risks / open points
|
||||||
|
|
||||||
|
- **Device table size**: `supported_devices.py` is large; porting/generating the
|
||||||
|
byte tables is real work and model-specific.
|
||||||
|
- **Interface detach** may momentarily disturb the device; must re-attach on
|
||||||
|
exit and handle crashes gracefully.
|
||||||
|
- **Permissions**: relies on `TAG+="uaccess"` + a user service; must never fall
|
||||||
|
back to requiring root silently.
|
||||||
|
- **`command` environment**: `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY` and
|
||||||
|
`PATH` differ between a user service and an interactive shell; decide whether
|
||||||
|
to inherit the user manager environment (`systemd --user` import-environment)
|
||||||
|
or document `run` snippets setting `DISPLAY=:0` explicitly.
|
||||||
|
- **Key-name vocabulary drift** between GUI (GDK names) and daemon (Linux KEY_*)
|
||||||
|
is the most likely source of silent mismatches — that is why the shared table
|
||||||
|
in M0 is mandatory before the daemon consumes the file.
|
||||||
|
- **uinput device identity**: a second keyboard appears; choose stable
|
||||||
|
name/vendor/product so the desktop does not confuse it with the real one.
|
||||||
|
|
||||||
|
## 11. Decisions to make
|
||||||
|
|
||||||
|
1. Bus name / app-id: replace `com.example.gkeymapper` with a real reverse-DNS id
|
||||||
|
(suggested `dev.rager.gkeymapper`)? Affects the `.desktop` name too.
|
||||||
|
2. Event loop: GLib `GMainLoop` (simplest, reuses GIO) vs. raw `epoll` (leanest).
|
||||||
|
3. IPC: D-Bus (idiomatic, GIO already present) vs. a Unix socket (no bus
|
||||||
|
dependency). Recommendation: D-Bus, with signals as a fallback.
|
||||||
|
4. Scope of v1: keep `key`/`command`/`disabled` only, or also port
|
||||||
|
`typeout`/`media`?
|
||||||
243
src/capturedialog.cpp
Normal file
243
src/capturedialog.cpp
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
// gKeyMapper - key sequence capture dialog.
|
||||||
|
|
||||||
|
#include "capturedialog.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <glib/gi18n.h>
|
||||||
|
|
||||||
|
namespace gkeymapper {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct CaptureSession {
|
||||||
|
KeyRow* row = nullptr;
|
||||||
|
GtkWidget* displayLabel = nullptr;
|
||||||
|
std::vector<std::string> steps;
|
||||||
|
std::set<guint> heldKeys; // non-modifier keyvals currently held (ignores auto-repeat)
|
||||||
|
};
|
||||||
|
|
||||||
|
bool is_modifier_keyval(guint keyval) {
|
||||||
|
switch (keyval) {
|
||||||
|
case GDK_KEY_Control_L:
|
||||||
|
case GDK_KEY_Control_R:
|
||||||
|
case GDK_KEY_Alt_L:
|
||||||
|
case GDK_KEY_Alt_R:
|
||||||
|
case GDK_KEY_Shift_L:
|
||||||
|
case GDK_KEY_Shift_R:
|
||||||
|
case GDK_KEY_Super_L:
|
||||||
|
case GDK_KEY_Super_R:
|
||||||
|
case GDK_KEY_Meta_L:
|
||||||
|
case GDK_KEY_Meta_R:
|
||||||
|
case GDK_KEY_ISO_Level3_Shift: // AltGr
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format one step like the reference project: modifiers joined with '+',
|
||||||
|
// e.g. "ctrl+shift+t". Multiple steps are joined with ',' ("ctrl+c,ctrl+v").
|
||||||
|
std::string format_capture_step(GdkModifierType state, guint keyval) {
|
||||||
|
std::string step;
|
||||||
|
if (state & GDK_CONTROL_MASK) {
|
||||||
|
step += "ctrl+";
|
||||||
|
}
|
||||||
|
if (state & GDK_ALT_MASK) {
|
||||||
|
step += "alt+";
|
||||||
|
}
|
||||||
|
if (state & GDK_SHIFT_MASK) {
|
||||||
|
step += "shift+";
|
||||||
|
}
|
||||||
|
if (state & (GDK_SUPER_MASK | GDK_META_MASK)) {
|
||||||
|
step += "super+";
|
||||||
|
}
|
||||||
|
const char* name = gdk_keyval_name(keyval);
|
||||||
|
std::string key = name ? name : "?";
|
||||||
|
std::transform(key.begin(), key.end(), key.begin(),
|
||||||
|
[](unsigned char c) { return std::tolower(c); });
|
||||||
|
step += key;
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string join_capture_steps(const std::vector<std::string>& steps) {
|
||||||
|
std::string out;
|
||||||
|
for (size_t i = 0; i < steps.size(); ++i) {
|
||||||
|
if (i > 0) {
|
||||||
|
out += ",";
|
||||||
|
}
|
||||||
|
out += steps[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void update_capture_display(CaptureSession* session) {
|
||||||
|
std::string text = join_capture_steps(session->steps);
|
||||||
|
if (text.empty()) {
|
||||||
|
text = _("No keys recorded yet");
|
||||||
|
}
|
||||||
|
gtk_label_set_text(GTK_LABEL(session->displayLabel), text.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean on_capture_key_pressed(GtkEventControllerKey* controller,
|
||||||
|
guint keyval, guint /*keycode*/,
|
||||||
|
GdkModifierType state, gpointer userData) {
|
||||||
|
auto* session = static_cast<CaptureSession*>(userData);
|
||||||
|
|
||||||
|
if (keyval == GDK_KEY_Escape) {
|
||||||
|
// Escape cancels the dialog; the window "destroy" handler frees the
|
||||||
|
// session, so return immediately and do not touch it afterwards.
|
||||||
|
gtk_window_destroy(GTK_WINDOW(gtk_widget_get_root(
|
||||||
|
gtk_event_controller_get_widget(GTK_EVENT_CONTROLLER(controller)))));
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_modifier_keyval(keyval)) {
|
||||||
|
return TRUE; // modifiers are recorded as part of the next real key
|
||||||
|
}
|
||||||
|
if (session->heldKeys.count(keyval)) {
|
||||||
|
return TRUE; // ignore auto-repeat while the key is held down
|
||||||
|
}
|
||||||
|
session->heldKeys.insert(keyval);
|
||||||
|
constexpr GdkModifierType kModMask = static_cast<GdkModifierType>(
|
||||||
|
GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_ALT_MASK |
|
||||||
|
GDK_SUPER_MASK | GDK_META_MASK);
|
||||||
|
session->steps.push_back(format_capture_step(
|
||||||
|
static_cast<GdkModifierType>(state & kModMask), keyval));
|
||||||
|
update_capture_display(session);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean on_capture_key_released(GtkEventControllerKey* /*controller*/,
|
||||||
|
guint keyval, guint /*keycode*/,
|
||||||
|
GdkModifierType /*state*/, gpointer userData) {
|
||||||
|
auto* session = static_cast<CaptureSession*>(userData);
|
||||||
|
session->heldKeys.erase(keyval);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_capture_session_destroy(GtkWidget* /*widget*/, gpointer userData) {
|
||||||
|
delete static_cast<CaptureSession*>(userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_capture_done_clicked(GtkButton* btn, gpointer userData) {
|
||||||
|
auto* session = static_cast<CaptureSession*>(userData);
|
||||||
|
if (!session->steps.empty()) {
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(session->row->targetEntry),
|
||||||
|
join_capture_steps(session->steps).c_str());
|
||||||
|
}
|
||||||
|
GtkWindow* window = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(btn)));
|
||||||
|
gtk_window_destroy(window); // triggers session cleanup via "destroy"
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_capture_cancel_clicked(GtkButton* btn, gpointer /*userData*/) {
|
||||||
|
GtkWindow* window = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(btn)));
|
||||||
|
gtk_window_destroy(window); // triggers session cleanup via "destroy"
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_capture_clear_clicked(GtkButton* /*btn*/, gpointer userData) {
|
||||||
|
auto* session = static_cast<CaptureSession*>(userData);
|
||||||
|
session->steps.clear();
|
||||||
|
session->heldKeys.clear();
|
||||||
|
update_capture_display(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void open_key_capture_dialog(KeyRow* row, GtkWindow* parent) {
|
||||||
|
auto* session = new CaptureSession();
|
||||||
|
session->row = row;
|
||||||
|
|
||||||
|
GtkWidget* dialog = gtk_window_new();
|
||||||
|
gtk_window_set_title(GTK_WINDOW(dialog), _("Capture key sequence"));
|
||||||
|
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
|
||||||
|
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
|
||||||
|
gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE);
|
||||||
|
gtk_window_set_default_size(GTK_WINDOW(dialog), 540, 300);
|
||||||
|
g_signal_connect(dialog, "destroy", G_CALLBACK(on_capture_session_destroy), session);
|
||||||
|
|
||||||
|
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 16);
|
||||||
|
gtk_widget_set_margin_top(outer, 20);
|
||||||
|
gtk_widget_set_margin_bottom(outer, 20);
|
||||||
|
gtk_widget_set_margin_start(outer, 20);
|
||||||
|
gtk_widget_set_margin_end(outer, 20);
|
||||||
|
gtk_window_set_child(GTK_WINDOW(dialog), outer);
|
||||||
|
|
||||||
|
GtkWidget* title = gtk_label_new(nullptr);
|
||||||
|
g_autofree gchar* titleMarkup = g_strdup_printf(
|
||||||
|
"<span size='large' weight='bold'>%s</span>", _("Capture key sequence"));
|
||||||
|
gtk_label_set_markup(GTK_LABEL(title), titleMarkup);
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
|
||||||
|
gtk_box_append(GTK_BOX(outer), title);
|
||||||
|
|
||||||
|
GtkWidget* info = gtk_label_new(_("Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded too). Click Done to apply or Cancel to discard."));
|
||||||
|
gtk_label_set_wrap(GTK_LABEL(info), TRUE);
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(info), 0.0f);
|
||||||
|
gtk_widget_add_css_class(info, "dim-label");
|
||||||
|
gtk_box_append(GTK_BOX(outer), info);
|
||||||
|
|
||||||
|
session->displayLabel = gtk_label_new(_("No keys recorded yet"));
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(session->displayLabel), 0.0f);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(session->displayLabel), PANGO_ELLIPSIZE_MIDDLE);
|
||||||
|
gtk_widget_add_css_class(session->displayLabel, "monospace");
|
||||||
|
gtk_widget_set_margin_top(session->displayLabel, 12);
|
||||||
|
gtk_widget_set_margin_bottom(session->displayLabel, 12);
|
||||||
|
gtk_widget_set_margin_start(session->displayLabel, 12);
|
||||||
|
gtk_widget_set_margin_end(session->displayLabel, 12);
|
||||||
|
GtkWidget* frame = gtk_frame_new(nullptr);
|
||||||
|
gtk_frame_set_child(GTK_FRAME(frame), session->displayLabel);
|
||||||
|
gtk_widget_set_size_request(frame, -1, 64);
|
||||||
|
gtk_widget_set_vexpand(frame, TRUE);
|
||||||
|
// The frame is the focusable recording area: keys are captured there while
|
||||||
|
// the buttons stay focusable so the dialog remains keyboard-navigable.
|
||||||
|
gtk_widget_set_focusable(frame, TRUE);
|
||||||
|
gtk_widget_set_focus_on_click(frame, TRUE);
|
||||||
|
gtk_box_append(GTK_BOX(outer), frame);
|
||||||
|
|
||||||
|
gtk_box_append(GTK_BOX(outer), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||||||
|
|
||||||
|
// Buttons: Clear on the left, Cancel/Done grouped on the right.
|
||||||
|
GtkWidget* buttonRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
||||||
|
gtk_box_append(GTK_BOX(outer), buttonRow);
|
||||||
|
|
||||||
|
GtkWidget* clearBtn = gtk_button_new_with_label(_("Clear"));
|
||||||
|
gtk_widget_set_size_request(clearBtn, 110, -1);
|
||||||
|
g_signal_connect(clearBtn, "clicked", G_CALLBACK(on_capture_clear_clicked), session);
|
||||||
|
gtk_box_append(GTK_BOX(buttonRow), clearBtn);
|
||||||
|
|
||||||
|
GtkWidget* spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
||||||
|
gtk_widget_set_hexpand(spacer, TRUE);
|
||||||
|
gtk_box_append(GTK_BOX(buttonRow), spacer);
|
||||||
|
|
||||||
|
GtkWidget* endBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
||||||
|
gtk_box_append(GTK_BOX(buttonRow), endBox);
|
||||||
|
|
||||||
|
GtkWidget* cancelBtn = gtk_button_new_with_label(_("Cancel"));
|
||||||
|
gtk_widget_set_size_request(cancelBtn, 110, -1);
|
||||||
|
g_signal_connect(cancelBtn, "clicked", G_CALLBACK(on_capture_cancel_clicked), nullptr);
|
||||||
|
gtk_box_append(GTK_BOX(endBox), cancelBtn);
|
||||||
|
|
||||||
|
GtkWidget* doneBtn = gtk_button_new_with_label(_("Done"));
|
||||||
|
gtk_widget_set_size_request(doneBtn, 110, -1);
|
||||||
|
g_signal_connect(doneBtn, "clicked", G_CALLBACK(on_capture_done_clicked), session);
|
||||||
|
gtk_box_append(GTK_BOX(endBox), doneBtn);
|
||||||
|
|
||||||
|
// Key events bubble from the focused recording frame to the window. When a
|
||||||
|
// button is focused it consumes Space/Return itself, so those activate the
|
||||||
|
// button instead of being recorded — the buttons stay fully usable.
|
||||||
|
GtkEventController* keyCtrl = gtk_event_controller_key_new();
|
||||||
|
g_signal_connect(keyCtrl, "key-pressed", G_CALLBACK(on_capture_key_pressed), session);
|
||||||
|
g_signal_connect(keyCtrl, "key-released", G_CALLBACK(on_capture_key_released), session);
|
||||||
|
gtk_widget_add_controller(dialog, keyCtrl);
|
||||||
|
|
||||||
|
// Start recording immediately: focus the recording area, not a button.
|
||||||
|
gtk_window_set_focus(GTK_WINDOW(dialog), frame);
|
||||||
|
|
||||||
|
gtk_window_present(GTK_WINDOW(dialog));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace gkeymapper
|
||||||
17
src/capturedialog.h
Normal file
17
src/capturedialog.h
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// gKeyMapper - modal dialog that records a key sequence for one row.
|
||||||
|
#ifndef GKEYMAPPER_CAPTUREDIALOG_H
|
||||||
|
#define GKEYMAPPER_CAPTUREDIALOG_H
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include "keyrow.h"
|
||||||
|
|
||||||
|
namespace gkeymapper {
|
||||||
|
|
||||||
|
// Opens the capture dialog; on "Done" the recorded sequence is written to
|
||||||
|
// row->targetEntry.
|
||||||
|
void open_key_capture_dialog(KeyRow* row, GtkWindow* parent);
|
||||||
|
|
||||||
|
} // namespace gkeymapper
|
||||||
|
|
||||||
|
#endif // GKEYMAPPER_CAPTUREDIALOG_H
|
||||||
22
src/keyrow.h
Normal file
22
src/keyrow.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// gKeyMapper - shared state for one G key row (G1..G9).
|
||||||
|
#ifndef GKEYMAPPER_KEYROW_H
|
||||||
|
#define GKEYMAPPER_KEYROW_H
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
namespace gkeymapper {
|
||||||
|
|
||||||
|
constexpr int kNumKeys = 9;
|
||||||
|
|
||||||
|
// Widgets belonging to a single G key row.
|
||||||
|
struct KeyRow {
|
||||||
|
int index = 0; // 0..8 -> G1..G9
|
||||||
|
GtkWidget* keyLabel = nullptr;
|
||||||
|
GtkWidget* typeDrop = nullptr;
|
||||||
|
GtkWidget* targetEntry = nullptr;
|
||||||
|
GtkWidget* captureButton = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace gkeymapper
|
||||||
|
|
||||||
|
#endif // GKEYMAPPER_KEYROW_H
|
||||||
332
src/main.cpp
332
src/main.cpp
@@ -1,18 +1,18 @@
|
|||||||
// gKeyMapper - GTK4 GUI for remapping the G keys (G1-G9) of the Logitech G910
|
// gKeyMapper - GTK4 GUI for remapping the G keys (G1-G9) of the Logitech G910.
|
||||||
// GUI only for now. Mappings are stored as a simple text file.
|
// GUI only for now. Mappings are stored as a simple text file.
|
||||||
|
|
||||||
#include <gtk/gtk.h>
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cctype>
|
|
||||||
#include <fstream>
|
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <locale.h>
|
#include <locale.h>
|
||||||
#include <libintl.h>
|
#include <glib/gi18n.h>
|
||||||
#include <set>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "capturedialog.h"
|
||||||
|
#include "keyrow.h"
|
||||||
|
#include "mappings.h"
|
||||||
|
|
||||||
#ifndef PACKAGE
|
#ifndef PACKAGE
|
||||||
#define PACKAGE "gkeymapper"
|
#define PACKAGE "gkeymapper"
|
||||||
#endif
|
#endif
|
||||||
@@ -21,19 +21,10 @@
|
|||||||
#define LOCALEDIR "/usr/local/share/locale"
|
#define LOCALEDIR "/usr/local/share/locale"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#define _(s) gettext(s)
|
|
||||||
#define N_(s) (s)
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int NUM_KEYS = 9;
|
|
||||||
|
|
||||||
struct KeyRow {
|
using gkeymapper::KeyRow;
|
||||||
int index; // 0..8 -> G1..G9
|
using gkeymapper::kNumKeys;
|
||||||
GtkWidget* keyLabel = nullptr;
|
|
||||||
GtkWidget* typeDrop = nullptr;
|
|
||||||
GtkWidget* targetEntry = nullptr;
|
|
||||||
GtkWidget* captureButton = nullptr;
|
|
||||||
};
|
|
||||||
|
|
||||||
std::vector<KeyRow> rows;
|
std::vector<KeyRow> rows;
|
||||||
|
|
||||||
@@ -62,215 +53,6 @@ static void pick_command_file(KeyRow* row, GtkWindow* parent) {
|
|||||||
gtk_window_present(GTK_WINDOW(dialog));
|
gtk_window_present(GTK_WINDOW(dialog));
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CaptureSession {
|
|
||||||
KeyRow* row = nullptr;
|
|
||||||
GtkWidget* displayLabel = nullptr;
|
|
||||||
std::vector<std::string> steps;
|
|
||||||
std::set<guint> heldKeys; // non-modifier keyvals currently held (ignores auto-repeat)
|
|
||||||
};
|
|
||||||
|
|
||||||
static bool is_modifier_keyval(guint keyval) {
|
|
||||||
switch (keyval) {
|
|
||||||
case GDK_KEY_Control_L:
|
|
||||||
case GDK_KEY_Control_R:
|
|
||||||
case GDK_KEY_Alt_L:
|
|
||||||
case GDK_KEY_Alt_R:
|
|
||||||
case GDK_KEY_Shift_L:
|
|
||||||
case GDK_KEY_Shift_R:
|
|
||||||
case GDK_KEY_Super_L:
|
|
||||||
case GDK_KEY_Super_R:
|
|
||||||
case GDK_KEY_Meta_L:
|
|
||||||
case GDK_KEY_Meta_R:
|
|
||||||
case GDK_KEY_ISO_Level3_Shift: // AltGr
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format one step like the reference project: modifiers joined with '+',
|
|
||||||
// e.g. "ctrl+shift+t". Multiple steps are joined with ',' ("ctrl+c,ctrl+v").
|
|
||||||
static std::string format_capture_step(GdkModifierType state, guint keyval) {
|
|
||||||
std::string step;
|
|
||||||
if (state & GDK_CONTROL_MASK) {
|
|
||||||
step += "ctrl+";
|
|
||||||
}
|
|
||||||
if (state & GDK_ALT_MASK) {
|
|
||||||
step += "alt+";
|
|
||||||
}
|
|
||||||
if (state & GDK_SHIFT_MASK) {
|
|
||||||
step += "shift+";
|
|
||||||
}
|
|
||||||
if (state & (GDK_SUPER_MASK | GDK_META_MASK)) {
|
|
||||||
step += "super+";
|
|
||||||
}
|
|
||||||
const char* name = gdk_keyval_name(keyval);
|
|
||||||
std::string key = name ? name : "?";
|
|
||||||
std::transform(key.begin(), key.end(), key.begin(),
|
|
||||||
[](unsigned char c) { return std::tolower(c); });
|
|
||||||
step += key;
|
|
||||||
return step;
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string join_capture_steps(const std::vector<std::string>& steps) {
|
|
||||||
std::string out;
|
|
||||||
for (size_t i = 0; i < steps.size(); ++i) {
|
|
||||||
if (i > 0) {
|
|
||||||
out += ",";
|
|
||||||
}
|
|
||||||
out += steps[i];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void update_capture_display(CaptureSession* session) {
|
|
||||||
std::string text = join_capture_steps(session->steps);
|
|
||||||
if (text.empty()) {
|
|
||||||
text = _("No keys recorded yet");
|
|
||||||
}
|
|
||||||
gtk_label_set_text(GTK_LABEL(session->displayLabel), text.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
static gboolean on_capture_key_pressed(GtkEventControllerKey* /*controller*/,
|
|
||||||
guint keyval, guint /*keycode*/,
|
|
||||||
GdkModifierType state, gpointer userData) {
|
|
||||||
auto* session = static_cast<CaptureSession*>(userData);
|
|
||||||
if (is_modifier_keyval(keyval)) {
|
|
||||||
return TRUE; // modifiers are recorded as part of the next real key
|
|
||||||
}
|
|
||||||
if (session->heldKeys.count(keyval)) {
|
|
||||||
return TRUE; // ignore auto-repeat while the key is held down
|
|
||||||
}
|
|
||||||
session->heldKeys.insert(keyval);
|
|
||||||
constexpr GdkModifierType kModMask = static_cast<GdkModifierType>(
|
|
||||||
GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_ALT_MASK |
|
|
||||||
GDK_SUPER_MASK | GDK_META_MASK);
|
|
||||||
session->steps.push_back(format_capture_step(
|
|
||||||
static_cast<GdkModifierType>(state & kModMask), keyval));
|
|
||||||
update_capture_display(session);
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
static gboolean on_capture_key_released(GtkEventControllerKey* /*controller*/,
|
|
||||||
guint keyval, guint /*keycode*/,
|
|
||||||
GdkModifierType /*state*/, gpointer userData) {
|
|
||||||
auto* session = static_cast<CaptureSession*>(userData);
|
|
||||||
session->heldKeys.erase(keyval);
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void on_capture_session_destroy(GtkWidget* /*widget*/, gpointer userData) {
|
|
||||||
delete static_cast<CaptureSession*>(userData);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void on_capture_done_clicked(GtkButton* btn, gpointer userData) {
|
|
||||||
auto* session = static_cast<CaptureSession*>(userData);
|
|
||||||
if (!session->steps.empty()) {
|
|
||||||
gtk_editable_set_text(GTK_EDITABLE(session->row->targetEntry),
|
|
||||||
join_capture_steps(session->steps).c_str());
|
|
||||||
}
|
|
||||||
GtkWindow* window = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(btn)));
|
|
||||||
gtk_window_destroy(window); // triggers session cleanup via "destroy"
|
|
||||||
}
|
|
||||||
|
|
||||||
static void on_capture_cancel_clicked(GtkButton* btn, gpointer /*userData*/) {
|
|
||||||
GtkWindow* window = GTK_WINDOW(gtk_widget_get_root(GTK_WIDGET(btn)));
|
|
||||||
gtk_window_destroy(window); // triggers session cleanup via "destroy"
|
|
||||||
}
|
|
||||||
|
|
||||||
static void on_capture_clear_clicked(GtkButton* /*btn*/, gpointer userData) {
|
|
||||||
auto* session = static_cast<CaptureSession*>(userData);
|
|
||||||
session->steps.clear();
|
|
||||||
session->heldKeys.clear();
|
|
||||||
update_capture_display(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void open_key_capture_dialog(KeyRow* row, GtkWindow* parent) {
|
|
||||||
auto* session = new CaptureSession();
|
|
||||||
session->row = row;
|
|
||||||
|
|
||||||
GtkWidget* dialog = gtk_window_new();
|
|
||||||
gtk_window_set_title(GTK_WINDOW(dialog), _("Capture key sequence"));
|
|
||||||
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
|
|
||||||
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
|
|
||||||
gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE);
|
|
||||||
gtk_window_set_default_size(GTK_WINDOW(dialog), 540, 300);
|
|
||||||
g_signal_connect(dialog, "destroy", G_CALLBACK(on_capture_session_destroy), session);
|
|
||||||
|
|
||||||
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 16);
|
|
||||||
gtk_widget_set_margin_top(outer, 20);
|
|
||||||
gtk_widget_set_margin_bottom(outer, 20);
|
|
||||||
gtk_widget_set_margin_start(outer, 20);
|
|
||||||
gtk_widget_set_margin_end(outer, 20);
|
|
||||||
gtk_window_set_child(GTK_WINDOW(dialog), outer);
|
|
||||||
|
|
||||||
GtkWidget* title = gtk_label_new(nullptr);
|
|
||||||
gtk_label_set_markup(GTK_LABEL(title),
|
|
||||||
g_strdup_printf("<span size='large' weight='bold'>%s</span>",
|
|
||||||
_("Capture key sequence")));
|
|
||||||
gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
|
|
||||||
gtk_box_append(GTK_BOX(outer), title);
|
|
||||||
|
|
||||||
GtkWidget* info = gtk_label_new(_("Press keys in order (modifiers like Ctrl, Alt, Shift and Super are recorded too). Click Done to apply or Cancel to discard."));
|
|
||||||
gtk_label_set_wrap(GTK_LABEL(info), TRUE);
|
|
||||||
gtk_label_set_xalign(GTK_LABEL(info), 0.0f);
|
|
||||||
gtk_widget_add_css_class(info, "dim-label");
|
|
||||||
gtk_box_append(GTK_BOX(outer), info);
|
|
||||||
|
|
||||||
session->displayLabel = gtk_label_new(_("No keys recorded yet"));
|
|
||||||
gtk_label_set_xalign(GTK_LABEL(session->displayLabel), 0.0f);
|
|
||||||
gtk_label_set_ellipsize(GTK_LABEL(session->displayLabel), PANGO_ELLIPSIZE_MIDDLE);
|
|
||||||
gtk_widget_add_css_class(session->displayLabel, "monospace");
|
|
||||||
gtk_widget_set_margin_top(session->displayLabel, 12);
|
|
||||||
gtk_widget_set_margin_bottom(session->displayLabel, 12);
|
|
||||||
gtk_widget_set_margin_start(session->displayLabel, 12);
|
|
||||||
gtk_widget_set_margin_end(session->displayLabel, 12);
|
|
||||||
GtkWidget* frame = gtk_frame_new(nullptr);
|
|
||||||
gtk_frame_set_child(GTK_FRAME(frame), session->displayLabel);
|
|
||||||
gtk_widget_set_size_request(frame, -1, 64);
|
|
||||||
gtk_widget_set_vexpand(frame, TRUE);
|
|
||||||
gtk_box_append(GTK_BOX(outer), frame);
|
|
||||||
|
|
||||||
gtk_box_append(GTK_BOX(outer), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
|
||||||
|
|
||||||
// Buttons: Clear on the left, Cancel/Done grouped on the right.
|
|
||||||
GtkWidget* buttonRow = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
|
||||||
gtk_box_append(GTK_BOX(outer), buttonRow);
|
|
||||||
|
|
||||||
GtkWidget* clearBtn = gtk_button_new_with_label(_("Clear"));
|
|
||||||
gtk_widget_set_size_request(clearBtn, 110, -1);
|
|
||||||
g_signal_connect(clearBtn, "clicked", G_CALLBACK(on_capture_clear_clicked), session);
|
|
||||||
gtk_box_append(GTK_BOX(buttonRow), clearBtn);
|
|
||||||
|
|
||||||
GtkWidget* spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
|
||||||
gtk_widget_set_hexpand(spacer, TRUE);
|
|
||||||
gtk_box_append(GTK_BOX(buttonRow), spacer);
|
|
||||||
|
|
||||||
GtkWidget* endBox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
|
||||||
gtk_box_append(GTK_BOX(buttonRow), endBox);
|
|
||||||
|
|
||||||
GtkWidget* cancelBtn = gtk_button_new_with_label(_("Cancel"));
|
|
||||||
gtk_widget_set_size_request(cancelBtn, 110, -1);
|
|
||||||
g_signal_connect(cancelBtn, "clicked", G_CALLBACK(on_capture_cancel_clicked), nullptr);
|
|
||||||
gtk_box_append(GTK_BOX(endBox), cancelBtn);
|
|
||||||
|
|
||||||
GtkWidget* doneBtn = gtk_button_new_with_label(_("Done"));
|
|
||||||
gtk_widget_set_size_request(doneBtn, 110, -1);
|
|
||||||
g_signal_connect(doneBtn, "clicked", G_CALLBACK(on_capture_done_clicked), session);
|
|
||||||
gtk_box_append(GTK_BOX(endBox), doneBtn);
|
|
||||||
|
|
||||||
// Key events go to the dialog; buttons must not steal Space/Return.
|
|
||||||
GtkEventController* keyCtrl = gtk_event_controller_key_new();
|
|
||||||
g_signal_connect(keyCtrl, "key-pressed", G_CALLBACK(on_capture_key_pressed), session);
|
|
||||||
g_signal_connect(keyCtrl, "key-released", G_CALLBACK(on_capture_key_released), session);
|
|
||||||
gtk_widget_add_controller(dialog, keyCtrl);
|
|
||||||
gtk_widget_set_focusable(clearBtn, FALSE);
|
|
||||||
gtk_widget_set_focusable(cancelBtn, FALSE);
|
|
||||||
gtk_widget_set_focusable(doneBtn, FALSE);
|
|
||||||
|
|
||||||
gtk_window_present(GTK_WINDOW(dialog));
|
|
||||||
}
|
|
||||||
|
|
||||||
void on_capture_clicked(GtkButton* btn, gpointer userData) {
|
void on_capture_clicked(GtkButton* btn, gpointer userData) {
|
||||||
auto* row = static_cast<KeyRow*>(userData);
|
auto* row = static_cast<KeyRow*>(userData);
|
||||||
guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(row->typeDrop));
|
guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(row->typeDrop));
|
||||||
@@ -282,7 +64,7 @@ void on_capture_clicked(GtkButton* btn, gpointer userData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sel == 0) {
|
if (sel == 0) {
|
||||||
open_key_capture_dialog(row, window);
|
gkeymapper::open_key_capture_dialog(row, window);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,34 +90,12 @@ static void on_type_changed(GObject* /*object*/, GParamSpec* /*pspec*/, gpointer
|
|||||||
update_row_state(static_cast<KeyRow*>(userData));
|
update_row_state(static_cast<KeyRow*>(userData));
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool write_mappings_to_file(const std::string& path) {
|
|
||||||
std::ofstream out(path);
|
|
||||||
if (!out) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
out << "# gKeyMapper mappings (G1..G9)\n";
|
|
||||||
for (const auto& row : rows) {
|
|
||||||
const char* type = "disabled";
|
|
||||||
switch (gtk_drop_down_get_selected(GTK_DROP_DOWN(row.typeDrop))) {
|
|
||||||
case 0: type = "key"; break;
|
|
||||||
case 1: type = "command"; break;
|
|
||||||
default: type = "disabled"; break;
|
|
||||||
}
|
|
||||||
const char* target = gtk_editable_get_text(GTK_EDITABLE(row.targetEntry));
|
|
||||||
out << "G" << (row.index + 1) << "=" << type << ":" << (target ? target : "") << "\n";
|
|
||||||
}
|
|
||||||
out.close();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void on_save_clicked(GtkButton* /*btn*/, gpointer /*userData*/) {
|
void on_save_clicked(GtkButton* /*btn*/, gpointer /*userData*/) {
|
||||||
const char* home = g_get_home_dir();
|
const std::string path = gkeymapper::default_config_path();
|
||||||
std::string dir = std::string(home) + "/.config/gKeyMapper";
|
g_autofree gchar* dir = g_path_get_dirname(path.c_str());
|
||||||
g_mkdir_with_parents(dir.c_str(), 0755);
|
g_mkdir_with_parents(dir, 0755);
|
||||||
std::string path = dir + "/mappings.conf";
|
|
||||||
|
|
||||||
if (!write_mappings_to_file(path)) {
|
if (!gkeymapper::write_mappings(path, rows)) {
|
||||||
g_printerr(_("Could not save %s.\n"), path.c_str());
|
g_printerr(_("Could not save %s.\n"), path.c_str());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -343,61 +103,13 @@ void on_save_clicked(GtkButton* /*btn*/, gpointer /*userData*/) {
|
|||||||
std::cout << _("Mappings saved to ") << path << "\n";
|
std::cout << _("Mappings saved to ") << path << "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
static void import_mappings_from_file(const std::string& path) {
|
|
||||||
std::ifstream in(path);
|
|
||||||
if (!in) {
|
|
||||||
g_printerr(_("Could not open %s.\n"), path.c_str());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string line;
|
|
||||||
while (std::getline(in, line)) {
|
|
||||||
if (line.empty() || line[0] == '#') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
auto eq = line.find('=');
|
|
||||||
if (eq == std::string::npos) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
std::string key = line.substr(0, eq);
|
|
||||||
if (key.size() < 2 || key[0] != 'G') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
int n = 0;
|
|
||||||
try {
|
|
||||||
n = std::stoi(key.substr(1)) - 1;
|
|
||||||
} catch (...) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (n < 0 || n >= (int)rows.size()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string rest = line.substr(eq + 1);
|
|
||||||
auto col = rest.find(':');
|
|
||||||
std::string typeToken = (col == std::string::npos) ? rest : rest.substr(0, col);
|
|
||||||
std::string target = (col == std::string::npos) ? std::string() : rest.substr(col + 1);
|
|
||||||
|
|
||||||
guint idx = 2;
|
|
||||||
if (typeToken == "key") {
|
|
||||||
idx = 0;
|
|
||||||
} else if (typeToken == "command") {
|
|
||||||
idx = 1;
|
|
||||||
} else if (typeToken == "disabled") {
|
|
||||||
idx = 2;
|
|
||||||
}
|
|
||||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(rows[n].typeDrop), idx);
|
|
||||||
gtk_editable_set_text(GTK_EDITABLE(rows[n].targetEntry), target.c_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void on_export_response(GtkDialog* dialog, int response, gpointer /*userData*/) {
|
static void on_export_response(GtkDialog* dialog, int response, gpointer /*userData*/) {
|
||||||
if (response == GTK_RESPONSE_ACCEPT) {
|
if (response == GTK_RESPONSE_ACCEPT) {
|
||||||
GtkFileChooser* chooser = GTK_FILE_CHOOSER(dialog);
|
GtkFileChooser* chooser = GTK_FILE_CHOOSER(dialog);
|
||||||
g_autoptr(GFile) file = gtk_file_chooser_get_file(chooser);
|
g_autoptr(GFile) file = gtk_file_chooser_get_file(chooser);
|
||||||
char* path = g_file_get_path(file);
|
char* path = g_file_get_path(file);
|
||||||
if (path) {
|
if (path) {
|
||||||
if (!write_mappings_to_file(path)) {
|
if (!gkeymapper::write_mappings(path, rows)) {
|
||||||
g_printerr(_("Could not export mappings.\n"));
|
g_printerr(_("Could not export mappings.\n"));
|
||||||
}
|
}
|
||||||
g_free(path);
|
g_free(path);
|
||||||
@@ -412,7 +124,7 @@ static void on_import_response(GtkDialog* dialog, int response, gpointer /*userD
|
|||||||
g_autoptr(GFile) file = gtk_file_chooser_get_file(chooser);
|
g_autoptr(GFile) file = gtk_file_chooser_get_file(chooser);
|
||||||
char* path = g_file_get_path(file);
|
char* path = g_file_get_path(file);
|
||||||
if (path) {
|
if (path) {
|
||||||
import_mappings_from_file(path);
|
gkeymapper::read_mappings(path, rows);
|
||||||
g_free(path);
|
g_free(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,21 +292,23 @@ void build_ui(GtkApplication* app) {
|
|||||||
// Column headers
|
// Column headers
|
||||||
const char* headers[] = { _("G-Key"), _("Type"), _("Target (Key/Command)"), _("Action") };
|
const char* headers[] = { _("G-Key"), _("Type"), _("Target (Key/Command)"), _("Action") };
|
||||||
for (int c = 0; c < 4; ++c) {
|
for (int c = 0; c < 4; ++c) {
|
||||||
GtkWidget* h = gtk_label_new(headers[c]);
|
GtkWidget* h = gtk_label_new(nullptr);
|
||||||
gtk_label_set_markup(GTK_LABEL(h), g_strdup_printf("<b>%s</b>", headers[c]));
|
g_autofree gchar* headerMarkup = g_strdup_printf("<b>%s</b>", headers[c]);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(h), headerMarkup);
|
||||||
gtk_widget_add_css_class(h, "dim-label");
|
gtk_widget_add_css_class(h, "dim-label");
|
||||||
gtk_grid_attach(GTK_GRID(grid), h, c, 0, 1, 1);
|
gtk_grid_attach(GTK_GRID(grid), h, c, 0, 1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"), nullptr };
|
const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"), nullptr };
|
||||||
|
|
||||||
rows.resize(NUM_KEYS);
|
rows.resize(kNumKeys);
|
||||||
for (int i = 0; i < NUM_KEYS; ++i) {
|
for (int i = 0; i < kNumKeys; ++i) {
|
||||||
rows[i].index = i;
|
rows[i].index = i;
|
||||||
|
|
||||||
// G key label
|
// G key label
|
||||||
GtkWidget* keyLabel = gtk_label_new(g_strdup_printf("G%d", i + 1));
|
GtkWidget* keyLabel = gtk_label_new(nullptr);
|
||||||
gtk_label_set_markup(GTK_LABEL(keyLabel), g_strdup_printf("<b>G%d</b>", i + 1));
|
g_autofree gchar* keyMarkup = g_strdup_printf("<b>G%d</b>", i + 1);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(keyLabel), keyMarkup);
|
||||||
gtk_grid_attach(GTK_GRID(grid), keyLabel, 0, i + 1, 1, 1);
|
gtk_grid_attach(GTK_GRID(grid), keyLabel, 0, i + 1, 1, 1);
|
||||||
rows[i].keyLabel = keyLabel;
|
rows[i].keyLabel = keyLabel;
|
||||||
|
|
||||||
|
|||||||
85
src/mappings.cpp
Normal file
85
src/mappings.cpp
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// gKeyMapper - mappings configuration file I/O.
|
||||||
|
|
||||||
|
#include "mappings.h"
|
||||||
|
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <glib/gi18n.h>
|
||||||
|
|
||||||
|
namespace gkeymapper {
|
||||||
|
|
||||||
|
std::string default_config_path() {
|
||||||
|
const char* home = g_get_home_dir();
|
||||||
|
return std::string(home ? home : "") + "/.config/gKeyMapper/mappings.conf";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool write_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
|
||||||
|
std::ofstream out(path);
|
||||||
|
if (!out) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out << "# gKeyMapper mappings (G1..G9)\n";
|
||||||
|
for (const auto& row : rows) {
|
||||||
|
const char* type = "disabled";
|
||||||
|
switch (gtk_drop_down_get_selected(GTK_DROP_DOWN(row.typeDrop))) {
|
||||||
|
case 0: type = "key"; break;
|
||||||
|
case 1: type = "command"; break;
|
||||||
|
default: type = "disabled"; break;
|
||||||
|
}
|
||||||
|
const char* target = gtk_editable_get_text(GTK_EDITABLE(row.targetEntry));
|
||||||
|
out << "G" << (row.index + 1) << "=" << type << ":" << (target ? target : "") << "\n";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void read_mappings(const std::string& path, const std::vector<KeyRow>& rows) {
|
||||||
|
std::ifstream in(path);
|
||||||
|
if (!in) {
|
||||||
|
g_printerr(_("Could not open %s.\n"), path.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(in, line)) {
|
||||||
|
if (line.empty() || line[0] == '#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
auto eq = line.find('=');
|
||||||
|
if (eq == std::string::npos) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::string key = line.substr(0, eq);
|
||||||
|
if (key.size() < 2 || key[0] != 'G') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int n = 0;
|
||||||
|
try {
|
||||||
|
n = std::stoi(key.substr(1)) - 1;
|
||||||
|
} catch (...) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (n < 0 || n >= static_cast<int>(rows.size())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string rest = line.substr(eq + 1);
|
||||||
|
auto col = rest.find(':');
|
||||||
|
std::string typeToken = (col == std::string::npos) ? rest : rest.substr(0, col);
|
||||||
|
std::string target = (col == std::string::npos) ? std::string() : rest.substr(col + 1);
|
||||||
|
|
||||||
|
guint idx = 2;
|
||||||
|
if (typeToken == "key") {
|
||||||
|
idx = 0;
|
||||||
|
} else if (typeToken == "command") {
|
||||||
|
idx = 1;
|
||||||
|
} else if (typeToken == "disabled") {
|
||||||
|
idx = 2;
|
||||||
|
}
|
||||||
|
gtk_drop_down_set_selected(GTK_DROP_DOWN(rows[n].typeDrop), idx);
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(rows[n].targetEntry), target.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace gkeymapper
|
||||||
24
src/mappings.h
Normal file
24
src/mappings.h
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// gKeyMapper - reading and writing the mappings configuration file.
|
||||||
|
#ifndef GKEYMAPPER_MAPPINGS_H
|
||||||
|
#define GKEYMAPPER_MAPPINGS_H
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "keyrow.h"
|
||||||
|
|
||||||
|
namespace gkeymapper {
|
||||||
|
|
||||||
|
// Default config file: ~/.config/gKeyMapper/mappings.conf.
|
||||||
|
std::string default_config_path();
|
||||||
|
|
||||||
|
// Writes all rows as "G1=type:target" lines. Returns false on I/O error.
|
||||||
|
bool write_mappings(const std::string& path, const std::vector<KeyRow>& rows);
|
||||||
|
|
||||||
|
// Loads mappings from path into the rows' widgets. Missing/invalid lines are
|
||||||
|
// skipped; a missing file is reported and otherwise ignored.
|
||||||
|
void read_mappings(const std::string& path, const std::vector<KeyRow>& rows);
|
||||||
|
|
||||||
|
} // namespace gkeymapper
|
||||||
|
|
||||||
|
#endif // GKEYMAPPER_MAPPINGS_H
|
||||||
Reference in New Issue
Block a user