Nicer structure

This commit is contained in:
2026-10-17 02:57:21 +02:00
parent 99146577d4
commit 335a4674d1
10 changed files with 767 additions and 317 deletions

View File

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

View File

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

View File

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

View File

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