diff --git a/AGENTS.md b/AGENTS.md index 947b734..14e37a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ 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 **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 - `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`. ## Code layout and conventions -- `src/main.cpp` holds everything in an anonymous namespace. The global `rows` - vector keeps one `KeyRow` (widget pointers) per G key. `on_activate` registers - the app actions `app.export-config`, `app.import-config`, `app.about`; - `build_ui` constructs the window. App id: `com.example.gkeymapper`. +- Modules under `src/` (shared API lives in `namespace gkeymapper`): + `keyrow.h` (`KeyRow`, `kNumKeys`), `mappings.{h,cpp}` (config file I/O), + `capturedialog.{h,cpp}` (key-capture dialog), `main.cpp` (app actions + UI). +- 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`, `2=Disabled`. Keep that order — import/export map against it. - The capture dialog's `CaptureSession` is heap-allocated and freed only by the diff --git a/Makefile b/Makefile index fdac805..f7a494e 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,8 @@ GTK_LIBS := $(shell $(PKG_CONFIG) --libs gtk4) PACKAGE := gkeymapper TARGET := gkeymapper SRC_DIR := src -SOURCES := $(SRC_DIR)/main.cpp +SOURCES := $(wildcard $(SRC_DIR)/*.cpp) +HEADERS := $(wildcard $(SRC_DIR)/*.h) PREFIX ?= /usr/local LOCALEDIR := $(PREFIX)/share/locale ASSETS_DIR := $(PREFIX)/share/$(PACKAGE)/assets @@ -29,7 +30,7 @@ MO_FILES := $(patsubst %,locale/%/LC_MESSAGES/$(PACKAGE).mo,$(LANGS)) all: $(TARGET) translations -$(TARGET): $(SOURCES) +$(TARGET): $(SOURCES) $(HEADERS) $(CXX) $(CXXFLAGS) $(GTK_CFLAGS) -o $@ $(SOURCES) $(GTK_LIBS) translations: $(MO_FILES) diff --git a/README.md b/README.md index 6aae0da..0c52c08 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,10 @@ replay is planned next, no Python involved). ## 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) Makefile # Build via pkg-config + gettext .vscode/tasks.json # VS Code tasks for make diff --git a/docs/daemon-plan.md b/docs/daemon-plan.md new file mode 100644 index 0000000..edafb30 --- /dev/null +++ b/docs/daemon-plan.md @@ -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: + +- 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 + `` / `` — 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=:` 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: `` — 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`? diff --git a/src/capturedialog.cpp b/src/capturedialog.cpp new file mode 100644 index 0000000..73e6f87 --- /dev/null +++ b/src/capturedialog.cpp @@ -0,0 +1,243 @@ +// gKeyMapper - key sequence capture dialog. + +#include "capturedialog.h" + +#include +#include +#include +#include +#include + +#include + +namespace gkeymapper { + +namespace { + +struct CaptureSession { + KeyRow* row = nullptr; + GtkWidget* displayLabel = nullptr; + std::vector steps; + std::set 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& 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(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( + GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_ALT_MASK | + GDK_SUPER_MASK | GDK_META_MASK); + session->steps.push_back(format_capture_step( + static_cast(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(userData); + session->heldKeys.erase(keyval); + return TRUE; +} + +void on_capture_session_destroy(GtkWidget* /*widget*/, gpointer userData) { + delete static_cast(userData); +} + +void on_capture_done_clicked(GtkButton* btn, gpointer userData) { + auto* session = static_cast(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(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( + "%s", _("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 diff --git a/src/capturedialog.h b/src/capturedialog.h new file mode 100644 index 0000000..b52b2b1 --- /dev/null +++ b/src/capturedialog.h @@ -0,0 +1,17 @@ +// gKeyMapper - modal dialog that records a key sequence for one row. +#ifndef GKEYMAPPER_CAPTUREDIALOG_H +#define GKEYMAPPER_CAPTUREDIALOG_H + +#include + +#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 diff --git a/src/keyrow.h b/src/keyrow.h new file mode 100644 index 0000000..8c943bc --- /dev/null +++ b/src/keyrow.h @@ -0,0 +1,22 @@ +// gKeyMapper - shared state for one G key row (G1..G9). +#ifndef GKEYMAPPER_KEYROW_H +#define GKEYMAPPER_KEYROW_H + +#include + +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 diff --git a/src/main.cpp b/src/main.cpp index f7c45be..a4fb07e 100644 --- a/src/main.cpp +++ b/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. #include -#include -#include -#include #include #include -#include -#include +#include #include #include +#include "capturedialog.h" +#include "keyrow.h" +#include "mappings.h" + #ifndef PACKAGE #define PACKAGE "gkeymapper" #endif @@ -21,19 +21,10 @@ #define LOCALEDIR "/usr/local/share/locale" #endif -#define _(s) gettext(s) -#define N_(s) (s) - namespace { -constexpr int NUM_KEYS = 9; -struct KeyRow { - int index; // 0..8 -> G1..G9 - GtkWidget* keyLabel = nullptr; - GtkWidget* typeDrop = nullptr; - GtkWidget* targetEntry = nullptr; - GtkWidget* captureButton = nullptr; -}; +using gkeymapper::KeyRow; +using gkeymapper::kNumKeys; std::vector rows; @@ -62,215 +53,6 @@ static void pick_command_file(KeyRow* row, GtkWindow* parent) { gtk_window_present(GTK_WINDOW(dialog)); } -struct CaptureSession { - KeyRow* row = nullptr; - GtkWidget* displayLabel = nullptr; - std::vector steps; - std::set 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& 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(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( - GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_ALT_MASK | - GDK_SUPER_MASK | GDK_META_MASK); - session->steps.push_back(format_capture_step( - static_cast(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(userData); - session->heldKeys.erase(keyval); - return TRUE; -} - -static void on_capture_session_destroy(GtkWidget* /*widget*/, gpointer userData) { - delete static_cast(userData); -} - -static void on_capture_done_clicked(GtkButton* btn, gpointer userData) { - auto* session = static_cast(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(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("%s", - _("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) { auto* row = static_cast(userData); 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) { - 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(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*/) { - const char* home = g_get_home_dir(); - std::string dir = std::string(home) + "/.config/gKeyMapper"; - g_mkdir_with_parents(dir.c_str(), 0755); - std::string path = dir + "/mappings.conf"; + const std::string path = gkeymapper::default_config_path(); + g_autofree gchar* dir = g_path_get_dirname(path.c_str()); + g_mkdir_with_parents(dir, 0755); - if (!write_mappings_to_file(path)) { + if (!gkeymapper::write_mappings(path, rows)) { g_printerr(_("Could not save %s.\n"), path.c_str()); return; } @@ -343,61 +103,13 @@ void on_save_clicked(GtkButton* /*btn*/, gpointer /*userData*/) { 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*/) { if (response == GTK_RESPONSE_ACCEPT) { GtkFileChooser* chooser = GTK_FILE_CHOOSER(dialog); g_autoptr(GFile) file = gtk_file_chooser_get_file(chooser); char* path = g_file_get_path(file); if (path) { - if (!write_mappings_to_file(path)) { + if (!gkeymapper::write_mappings(path, rows)) { g_printerr(_("Could not export mappings.\n")); } 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); char* path = g_file_get_path(file); if (path) { - import_mappings_from_file(path); + gkeymapper::read_mappings(path, rows); g_free(path); } } @@ -580,21 +292,23 @@ void build_ui(GtkApplication* app) { // Column headers const char* headers[] = { _("G-Key"), _("Type"), _("Target (Key/Command)"), _("Action") }; for (int c = 0; c < 4; ++c) { - GtkWidget* h = gtk_label_new(headers[c]); - gtk_label_set_markup(GTK_LABEL(h), g_strdup_printf("%s", headers[c])); + GtkWidget* h = gtk_label_new(nullptr); + g_autofree gchar* headerMarkup = g_strdup_printf("%s", headers[c]); + gtk_label_set_markup(GTK_LABEL(h), headerMarkup); gtk_widget_add_css_class(h, "dim-label"); gtk_grid_attach(GTK_GRID(grid), h, c, 0, 1, 1); } const char* typeOptions[] = { _("Key"), _("Command"), _("Disabled"), nullptr }; - rows.resize(NUM_KEYS); - for (int i = 0; i < NUM_KEYS; ++i) { + rows.resize(kNumKeys); + for (int i = 0; i < kNumKeys; ++i) { rows[i].index = i; // G key label - GtkWidget* keyLabel = gtk_label_new(g_strdup_printf("G%d", i + 1)); - gtk_label_set_markup(GTK_LABEL(keyLabel), g_strdup_printf("G%d", i + 1)); + GtkWidget* keyLabel = gtk_label_new(nullptr); + g_autofree gchar* keyMarkup = g_strdup_printf("G%d", i + 1); + gtk_label_set_markup(GTK_LABEL(keyLabel), keyMarkup); gtk_grid_attach(GTK_GRID(grid), keyLabel, 0, i + 1, 1, 1); rows[i].keyLabel = keyLabel; diff --git a/src/mappings.cpp b/src/mappings.cpp new file mode 100644 index 0000000..0b24b0d --- /dev/null +++ b/src/mappings.cpp @@ -0,0 +1,85 @@ +// gKeyMapper - mappings configuration file I/O. + +#include "mappings.h" + +#include +#include + +#include + +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& 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& 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(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 diff --git a/src/mappings.h b/src/mappings.h new file mode 100644 index 0000000..729a489 --- /dev/null +++ b/src/mappings.h @@ -0,0 +1,24 @@ +// gKeyMapper - reading and writing the mappings configuration file. +#ifndef GKEYMAPPER_MAPPINGS_H +#define GKEYMAPPER_MAPPINGS_H + +#include +#include + +#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& 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& rows); + +} // namespace gkeymapper + +#endif // GKEYMAPPER_MAPPINGS_H