diff --git a/README.md b/README.md index c81b69cc..10d0c813 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ ## ✨ Features - 🖱️ **Per-app profiles** — automatic button/scroll/DPI switching on window focus -- ⌨️ **Button remapping** — keystrokes, media controls, app launch, gestures, SmartShift toggle +- ⌨️ **Button remapping** — keystrokes, sticky modifiers, media controls, app launch, gestures, SmartShift toggle - 🎛️ **Thumb wheel modes** — volume, zoom, horizontal scroll with invert control -- 👆 **Gesture support** — hold + swipe for desktop switching, task view, custom keystrokes +- 👆 **Gesture support** — hold + swipe for desktop switching, task view, custom keystrokes, sticky modifiers - ⚡ **DPI / SmartShift / Scroll** — full control with live preview - 🔋 **System tray** — battery status, minimize to tray - 📡 **HID++ 2.0** — direct communication via Bolt receiver, no daemon needed diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 3d9d1e78..8fab21b0 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -561,9 +561,9 @@ graph LR |------|------|-------------| | `NameRole` | QString | Display name (e.g., "Copy") | | `DescriptionRole` | QString | Help text | -| `ActionTypeRole` | QString | "default", "keystroke", "app-launch", "preset", etc. | -| `PayloadRole` | QString | Keystroke combo, app command, or preset id (when type is "preset") | -| `CategoryRole` | QString | Group label ("Workspace", "Window", "Media", "System", "Edit", "Navigation", "Device", "Other"). The picker uses `ListView.section.property` to render section headers between groups. Rows are stored ordered by category, then alphabetically. | +| `ActionTypeRole` | QString | "default", "keystroke", "sticky", "app-launch", "preset", etc. | +| `PayloadRole` | QString | Keystroke combo, modifier combo (when type is "sticky"), app command, or preset id (when type is "preset") | +| `CategoryRole` | QString | Group label ("Workspace", "Window", "Media", "Modifiers", "System", "Edit", "Navigation", "Device", "Other"). The picker uses `ListView.section.property` to render section headers between groups. Rows are stored ordered by category, then alphabetically. | **ProfileModel** — `QAbstractListModel` for the profile tab bar: @@ -792,14 +792,20 @@ After the extension is in place, `GnomeDesktop` registers the service `com.logit ### Input Injection -`IInputInjector` (`src/core/interfaces/IInputInjector.h`) is the abstract interface through which the app delivers synthesized input: `init()`, `injectKeystroke(combo)`, `injectCtrlScroll(direction)`, `injectHorizontalScroll(direction)`, `sendDBusCall(spec)`, `launchApp(command)`. `ActionExecutor` holds a non-owning pointer to one; tests substitute `MockInjector` to capture the calls for assertions. +`IInputInjector` (`src/core/interfaces/IInputInjector.h`) is the abstract interface through which the app delivers synthesized input: `init()`, `injectKeystroke(combo)`, `toggleModifierLatch(combo)`, `injectCtrlScroll(direction)`, `injectHorizontalScroll(direction)`, `sendDBusCall(spec)`, `launchApp(command)`. `ActionExecutor` holds a non-owning pointer to one; tests substitute `MockInjector` to capture the calls for assertions. `UinputInjector` (`src/core/input/UinputInjector.{h,cpp}`) is the production `/dev/uinput` implementation. `init()` opens `/dev/uinput` with `O_WRONLY | O_NONBLOCK`, registers the key and relative-axis bits the app can emit (modifiers, arrows, media keys, F1 to F12, A to Z, 0 to 9, plus `REL_WHEEL` and `REL_HWHEEL`), sets up a `uinput_setup` with vendor `0x046d` and product `0x0001` under the name `logitune-virtual-kbd`, and finalizes with `UI_DEV_CREATE`. If any step fails (most often because `/dev/uinput` is not accessible under the user's group or the `logitune` udev rules are missing), `init()` returns false and all subsequent `injectKeystroke` calls are silent no-ops. Keystroke chord parsing lives in `UinputInjector::parseKeystroke(combo)` (static, unit-tested directly). It splits on `+`, maps modifier tokens (`Ctrl`, `Shift`, `Alt`, `Super` / `Meta`), special keys (`Tab`, `Space`, `Enter`, `Up`, `Down`, `Home`, `PageUp`, `VolumeUp`, `Print`, `BrightnessDown`, etc.), symbols (`Minus`, `Equal`, `LeftBrace`, `Semicolon`, `Comma`), and letters / digits to `KEY_*` codes from ``. The bare `"+"` chord is handled before the split to preserve the `KEY_KPPLUS` case. `ActionExecutor::parseKeystroke` forwards to this function so tests can cover the parser without constructing an injector. +`UinputInjector::parseModifierCombo(combo)` refines that parse to the combos a latch will accept: it defers to `parseKeystroke` and then rejects the whole combo unless every keycode is one of `KEY_LEFTCTRL`, `KEY_LEFTSHIFT`, `KEY_LEFTALT`, `KEY_LEFTMETA`. Latching is meaningful for modifiers alone — a client autorepeats an ordinary key that stays down — so `sticky:Ctrl+C` is refused rather than latched in part. + Key emission in `injectKeystroke` presses all resolved keycodes in order (`emitKey(k, true)` + `emitSync()`), then releases them in reverse order. `injectCtrlScroll(direction)` wraps a `REL_WHEEL` write in a `KEY_LEFTCTRL` press / release so applications that bind zoom to Ctrl+scroll respond. `injectHorizontalScroll(direction)` writes a `REL_HWHEEL` event for the thumb-wheel scroll mode. `launchApp(command)` uses `QProcess::startDetached`; `sendDBusCall(spec)` parses a four-part `service,path,interface,method` string and dispatches through `QDBusConnection::sessionBus().send`. +`toggleModifierLatch(combo)` separates the press from the release so a modifier can outlive the event that requested it, which is what lets a pointer drag started afterwards carry it — KDE's Meta+drag window move and Meta+right-drag resize are the motivating case. The keys a latch holds are the virtual keyboard's own state, so the held set lives in `UinputInjector` (`m_latched`) rather than in `ButtonActionDispatcher`, and the invariant that follows is enforceable in one place: no key survives the device it was pressed on, so `shutdown()` drains the latch before `UI_DEV_DESTROY`. Quitting the app is therefore always an escape from a stuck modifier. + +The toggle is a flip-flop over that set. A combo releases only when *every* one of its keycodes is already held; otherwise the keycodes it adds join the set. Toggling one combo twice therefore returns the keyboard to the state it started in even where two latched combos share a modifier, and the return value reports whether the combo is held afterwards (`false` also covers a combo `parseModifierCombo` refused). + ## Device Discovery and Connection ### DeviceManager @@ -1196,7 +1202,7 @@ Direction resolution: - If `|dy| > |dx|`: Up (dy < 0) or Down (dy > 0) - If neither exceeds threshold (50 units): Click -Per-direction `ButtonAction` is stored in `Profile::gestures` (a `std::map` keyed by direction name). The fire path supports the same action types as button presses: keystroke, media, app-launch, DBus, and `PresetRef` (resolved via `IDesktopIntegration::resolveNamedAction` exactly like the button-press path - see [PresetRef resolution](#presetref-resolution)). +Per-direction `ButtonAction` is stored in `Profile::gestures` (a `std::map` keyed by direction name). The fire path supports the same action types as button presses: keystroke, sticky modifier, media, app-launch, DBus, and `PresetRef` (resolved via `IDesktopIntegration::resolveNamedAction` exactly like the button-press path - see [PresetRef resolution](#presetref-resolution)). Both paths converge on `ActionExecutor::executeAction`, which is the single place that knows how to fire an action needing no device state; `ButtonActionDispatcher` handles only the types that do — `SmartShiftToggle` and `DpiCycle` need the `DeviceSession`, and `GestureTrigger` arms the accumulator. The gesture button (CID `0x00C3` on MX Master 3S) is diverted with `rawXY=true`, which causes the device to send `DivertedRawXYEvent` notifications instead of normal mouse movement. diff --git a/src/app/models/ActionModel.cpp b/src/app/models/ActionModel.cpp index 98b11ee9..c75ceda4 100644 --- a/src/app/models/ActionModel.cpp +++ b/src/app/models/ActionModel.cpp @@ -28,6 +28,13 @@ ActionModel::ActionModel(QObject *parent) { "Volume down", "Decrease system volume", "keystroke", "VolumeDown", "Media" }, { "Volume up", "Increase system volume", "keystroke", "VolumeUp", "Media" }, + // Modifiers — held down until the button fires again, so a drag + // started afterwards carries the modifier. + { "Sticky Alt", "Hold Alt until pressed again", "sticky", "Alt", "Modifiers" }, + { "Sticky Ctrl", "Hold Ctrl until pressed again", "sticky", "Ctrl", "Modifiers" }, + { "Sticky Meta", "Hold Meta until pressed again", "sticky", "Meta", "Modifiers" }, + { "Sticky Shift", "Hold Shift until pressed again", "sticky", "Shift", "Modifiers" }, + // Navigation { "Back", "Navigate backward in browser/file manager", "keystroke", "Alt+Left", "Navigation" }, { "Forward", "Navigate forward in browser/file manager", "keystroke", "Alt+Right", "Navigation" }, @@ -119,26 +126,43 @@ QString ActionModel::payloadForName(const QString &name) const return {}; } +QString ActionModel::buttonActionToType(const ButtonAction &ba) const +{ + switch (ba.type) { + case ButtonAction::GestureTrigger: return QStringLiteral("gesture-trigger"); + case ButtonAction::SmartShiftToggle: return QStringLiteral("smartshift-toggle"); + case ButtonAction::DpiCycle: return QStringLiteral("dpi-cycle"); + case ButtonAction::AppLaunch: return QStringLiteral("app-launch"); + case ButtonAction::PresetRef: return QStringLiteral("preset"); + case ButtonAction::DBus: return QStringLiteral("dbus"); + case ButtonAction::Keystroke: return QStringLiteral("keystroke"); + case ButtonAction::StickyModifier: return QStringLiteral("sticky"); + + // Profiles written by older releases store media keys as "media:...". + // The picker has no separate row for them, so they surface — and are + // injected — as ordinary keystrokes. + case ButtonAction::Media: return QStringLiteral("keystroke"); + + case ButtonAction::Default: return QStringLiteral("default"); + } + return QStringLiteral("default"); +} + QString ActionModel::buttonActionToName(const ButtonAction &ba) const { if (ba.type == ButtonAction::Default) return QString(); - if (ba.type == ButtonAction::GestureTrigger) - return QStringLiteral("Gestures"); - if (ba.type == ButtonAction::PresetRef) { - for (const auto &a : m_actions) { - if (a.actionType == QStringLiteral("preset") && a.payload == ba.payload) - return a.name; - } - return ba.payload; - } - if (ba.type == ButtonAction::Keystroke) { - for (const auto &a : m_actions) { - if (a.actionType == QStringLiteral("keystroke") && a.payload == ba.payload) - return a.name; - } - return ba.payload; + + // Every row is identified by its (actionType, payload) pair, and the + // payload-less action types each own exactly one row, so one uniform + // lookup names all of them. + const QString type = buttonActionToType(ba); + for (const auto &a : m_actions) { + if (a.actionType == type && a.payload == ba.payload) + return a.name; } + + // A custom binding the picker has no row for — show the payload itself. return ba.payload; } @@ -158,11 +182,21 @@ ButtonAction ActionModel::buttonEntryToAction(const QString &actionType, const Q payload = actionName; return {ButtonAction::Keystroke, payload}; } + if (actionType == QStringLiteral("sticky")) { + QString payload = payloadForName(actionName); + if (payload.isEmpty()) payload = actionName; + return {ButtonAction::StickyModifier, payload}; + } if (actionType == QStringLiteral("app-launch")) { QString payload = payloadForName(actionName); if (payload.isEmpty()) payload = actionName; return {ButtonAction::AppLaunch, payload}; } + // No picker row spells a D-Bus call — only a hand-edited profile does — + // but the type still has to survive a UI save, or editing any other + // button would quietly reset this one. + if (actionType == QStringLiteral("dbus")) + return {ButtonAction::DBus, actionName}; if (actionType == QStringLiteral("preset")) { QString payload = payloadForName(actionName); if (payload.isEmpty()) payload = actionName; diff --git a/src/app/models/ActionModel.h b/src/app/models/ActionModel.h index 443f6c0c..854d2d0c 100644 --- a/src/app/models/ActionModel.h +++ b/src/app/models/ActionModel.h @@ -40,8 +40,13 @@ class ActionModel : public QAbstractListModel { Q_INVOKABLE int indexForName(const QString &name) const; Q_INVOKABLE QString payloadForName(const QString &name) const; - /// Translate a ButtonAction (domain type) to the (typeName, displayName) - /// pair the UI uses. Inverse of buttonEntryToAction. + /// The UI's type token for a ButtonAction. Together with + /// buttonActionToName this is the (actionType, actionName) pair the UI + /// speaks in, and the inverse of buttonEntryToAction. + QString buttonActionToType(const ButtonAction &ba) const; + + /// The UI's display name for a ButtonAction, resolved by looking up the + /// row whose (actionType, payload) the action carries. QString buttonActionToName(const ButtonAction &ba) const; /// Translate the UI's (typeName, displayName) pair back to a ButtonAction. diff --git a/src/app/services/ButtonActionDispatcher.cpp b/src/app/services/ButtonActionDispatcher.cpp index f27c335e..e62bae02 100644 --- a/src/app/services/ButtonActionDispatcher.cpp +++ b/src/app/services/ButtonActionDispatcher.cpp @@ -130,16 +130,11 @@ void ButtonActionDispatcher::onDivertedButtonPressed(uint16_t controlId, bool pr } else if (ba.type == ButtonAction::DpiCycle) { session->cycleDpi(); emit dpiChangedByButton(session->currentDPI()); - } else if ((ba.type == ButtonAction::Keystroke || ba.type == ButtonAction::Media) - && !ba.payload.isEmpty()) { - m_actionExecutor->injectKeystroke(ba.payload); } else if (ba.type == ButtonAction::GestureTrigger) { state.gestureAccumX = 0; state.gestureAccumY = 0; state.gestureActive = true; state.gestureControlId = controlId; - } else if (ba.type == ButtonAction::AppLaunch && !ba.payload.isEmpty()) { - m_actionExecutor->launchApp(ba.payload); } else if (ba.type == ButtonAction::PresetRef && !ba.payload.isEmpty()) { if (!m_desktop) { qCWarning(lcApp) << "preset action requested but desktop integration is null" @@ -153,6 +148,11 @@ void ButtonActionDispatcher::onDivertedButtonPressed(uint16_t controlId, bool pr return; } m_actionExecutor->executeAction(*resolved); + } else if (!ba.payload.isEmpty()) { + // Everything left — keystrokes, sticky modifiers, legacy media keys, + // app launches, D-Bus calls — needs no device state, so the executor's + // own dispatch is the single place that knows how to fire it. + m_actionExecutor->executeAction(ba); } } diff --git a/src/app/services/ProfileOrchestrator.cpp b/src/app/services/ProfileOrchestrator.cpp index 294b252a..349eda75 100644 --- a/src/app/services/ProfileOrchestrator.cpp +++ b/src/app/services/ProfileOrchestrator.cpp @@ -264,51 +264,14 @@ void ProfileOrchestrator::restoreButtonModelFromProfile(const Profile &p) ? p.buttons[static_cast(i)] : ButtonAction{ButtonAction::Default, {}}; - QString aType, aName; - switch (ba.type) { - case ButtonAction::Default: - aType = QStringLiteral("default"); - aName = ctrl.defaultName; - break; - case ButtonAction::GestureTrigger: - aType = QStringLiteral("gesture-trigger"); - aName = QStringLiteral("Gestures"); - break; - case ButtonAction::SmartShiftToggle: - aType = QStringLiteral("smartshift-toggle"); - aName = QStringLiteral("Shift wheel mode"); - break; - case ButtonAction::DpiCycle: - aType = QStringLiteral("dpi-cycle"); - aName = QStringLiteral("DPI cycle"); - break; - case ButtonAction::Keystroke: - aType = QStringLiteral("keystroke"); - aName = m_actionModel->buttonActionToName(ba); - break; - case ButtonAction::AppLaunch: - aType = QStringLiteral("app-launch"); - aName = m_actionModel->buttonActionToName(ba); - break; - case ButtonAction::PresetRef: - aType = QStringLiteral("preset"); - aName = m_actionModel->buttonActionToName(ba); - break; - case ButtonAction::Media: { - // Legacy ButtonAction::Media (from older serialized profiles - // using the "media:..." form) surfaces in the UI as a regular - // keystroke entry; the dispatcher still injects the keystroke - // payload through the same code path. - aType = QStringLiteral("keystroke"); - ButtonAction asKs{ButtonAction::Keystroke, ba.payload}; - aName = m_actionModel->buttonActionToName(asKs); - break; - } - default: - aType = QStringLiteral("default"); - aName = ctrl.defaultName; - break; - } + // ActionModel owns the domain -> UI vocabulary in both directions. + // The one thing it cannot know is the button's factory label, which + // is what an unbound button shows. + const QString aType = m_actionModel->buttonActionToType(ba); + const QString aName = (ba.type == ButtonAction::Default) + ? ctrl.defaultName + : m_actionModel->buttonActionToName(ba); + assignments.append({aName, aType, ctrl.controlId}); } diff --git a/src/core/ActionExecutor.cpp b/src/core/ActionExecutor.cpp index 5fc79b50..fe362c83 100644 --- a/src/core/ActionExecutor.cpp +++ b/src/core/ActionExecutor.cpp @@ -60,6 +60,9 @@ void ActionExecutor::executeAction(const ButtonAction &action) case ButtonAction::Keystroke: injectKeystroke(action.payload); break; + case ButtonAction::StickyModifier: + toggleModifierLatch(action.payload); + break; case ButtonAction::DBus: executeDBusCall(action.payload); break; @@ -82,6 +85,11 @@ void ActionExecutor::injectKeystroke(const QString &combo) m_injector->injectKeystroke(combo); } +bool ActionExecutor::toggleModifierLatch(const QString &combo) +{ + return m_injector->toggleModifierLatch(combo); +} + void ActionExecutor::injectCtrlScroll(int direction) { m_injector->injectCtrlScroll(direction); diff --git a/src/core/ActionExecutor.h b/src/core/ActionExecutor.h index 8479992f..d8270320 100644 --- a/src/core/ActionExecutor.h +++ b/src/core/ActionExecutor.h @@ -37,6 +37,7 @@ class ActionExecutor : public QObject { void executeAction(const ButtonAction &action); void injectKeystroke(const QString &combo); + bool toggleModifierLatch(const QString &combo); void injectCtrlScroll(int direction); void injectHorizontalScroll(int direction); void executeDBusCall(const QString &spec); diff --git a/src/core/ButtonAction.h b/src/core/ButtonAction.h index f3dd41d6..c18e2ee6 100644 --- a/src/core/ButtonAction.h +++ b/src/core/ButtonAction.h @@ -7,6 +7,9 @@ struct ButtonAction { enum Type { Default, Keystroke, + /// Latch a modifier-only combo down until the action fires again, + /// which is what lets a pointer drag carry the modifier. + StickyModifier, GestureTrigger, SmartShiftToggle, DpiCycle, diff --git a/src/core/ProfileEngine.cpp b/src/core/ProfileEngine.cpp index bc26b945..f6d9ed2e 100644 --- a/src/core/ProfileEngine.cpp +++ b/src/core/ProfileEngine.cpp @@ -38,6 +38,7 @@ ButtonAction ButtonAction::parse(const QString &str) if (payload == "smartshift-toggle") return {SmartShiftToggle, {}}; return {Keystroke, payload}; } + if (prefix == "sticky") return {StickyModifier, payload}; if (prefix == "media") return {Media, payload}; if (prefix == "dbus") return {DBus, payload}; if (prefix == "app-launch") return {AppLaunch, payload}; @@ -53,12 +54,13 @@ QString ButtonAction::serialize() const case Default: return "default"; case GestureTrigger: return "gesture-trigger"; case SmartShiftToggle: return "smartshift-toggle"; - case DpiCycle: return "dpi-cycle"; - case Keystroke: return "keystroke:" + payload; - case Media: return "media:" + payload; - case DBus: return "dbus:" + payload; - case AppLaunch: return "app-launch:" + payload; - case PresetRef: return "preset:" + payload; + case DpiCycle: return "dpi-cycle"; + case Keystroke: return "keystroke:" + payload; + case StickyModifier: return "sticky:" + payload; + case Media: return "media:" + payload; + case DBus: return "dbus:" + payload; + case AppLaunch: return "app-launch:" + payload; + case PresetRef: return "preset:" + payload; } return "default"; } diff --git a/src/core/input/UinputInjector.cpp b/src/core/input/UinputInjector.cpp index 4fb2a6b8..ea58a96d 100644 --- a/src/core/input/UinputInjector.cpp +++ b/src/core/input/UinputInjector.cpp @@ -2,6 +2,8 @@ #include "ActionExecutor.h" #include "logging/LogManager.h" +#include + #include #include #include @@ -90,6 +92,7 @@ bool UinputInjector::init() void UinputInjector::shutdown() { if (m_uinputFd >= 0) { + releaseLatchedModifiers(); ::ioctl(m_uinputFd, UI_DEV_DESTROY); ::close(m_uinputFd); m_uinputFd = -1; @@ -145,6 +148,50 @@ void UinputInjector::injectKeystroke(const QString &combo) emitSync(); } +bool UinputInjector::toggleModifierLatch(const QString &combo) +{ + const auto mods = parseModifierCombo(combo); + if (mods.empty()) { + qCWarning(lcInput) << "refusing to latch" << combo + << "— only modifier combos can be held"; + return false; + } + + // A combo whose every key is already held releases; otherwise the keys it + // adds join the latch. Toggling one combo twice therefore always returns + // the virtual keyboard to the state it started in, even where two latched + // combos share a modifier. + const bool release = std::all_of(mods.begin(), mods.end(), + [this](int k) { return m_latched.count(k) > 0; }); + + for (int k : mods) { + if (release) { + if (m_latched.erase(k) > 0) + emitKey(k, false); + } else if (m_latched.insert(k).second) { + emitKey(k, true); + } + } + emitSync(); + + qCDebug(lcInput) << "modifier latch" << combo << (release ? "released" : "held") + << "— latched keys:" << static_cast(m_latched.size()); + + return !release; +} + +void UinputInjector::releaseLatchedModifiers() +{ + if (m_latched.empty()) + return; + + for (int k : m_latched) + emitKey(k, false); + emitSync(); + + m_latched.clear(); +} + void UinputInjector::injectCtrlScroll(int direction) { if (m_uinputFd < 0 || direction == 0) @@ -309,4 +356,23 @@ std::vector UinputInjector::parseKeystroke(const QString &combo) return keys; } +std::vector UinputInjector::parseModifierCombo(const QString &combo) +{ + static constexpr int kModifiers[] = { + KEY_LEFTCTRL, KEY_LEFTSHIFT, KEY_LEFTALT, KEY_LEFTMETA, + }; + + const auto keys = parseKeystroke(combo); + if (keys.empty()) + return {}; + + for (int k : keys) { + if (std::find(std::begin(kModifiers), std::end(kModifiers), k) + == std::end(kModifiers)) + return {}; + } + + return keys; +} + } // namespace logitune diff --git a/src/core/input/UinputInjector.h b/src/core/input/UinputInjector.h index 7c86204e..1212d647 100644 --- a/src/core/input/UinputInjector.h +++ b/src/core/input/UinputInjector.h @@ -1,5 +1,6 @@ #pragma once #include "interfaces/IInputInjector.h" +#include #include namespace logitune { @@ -12,6 +13,7 @@ class UinputInjector : public IInputInjector { bool init() override; void injectKeystroke(const QString &combo) override; + bool toggleModifierLatch(const QString &combo) override; void injectCtrlScroll(int direction) override; void injectHorizontalScroll(int direction) override; void sendDBusCall(const QString &spec) override; @@ -22,10 +24,22 @@ class UinputInjector : public IInputInjector { // Static helpers (testable) static std::vector parseKeystroke(const QString &combo); + /// The keycodes of a combo made *only* of modifiers, or an empty vector + /// for anything else. A latch is meaningful for modifiers alone: the + /// compositor autorepeats an ordinary key that stays down, so a combo + /// mixing the two is refused whole rather than latched in part. + static std::vector parseModifierCombo(const QString &combo); + private: int m_uinputFd = -1; + + /// Keys the latch is holding down. The virtual keyboard must not outlive + /// its own pressed keys, so shutdown() drains this set. + std::set m_latched; + void emitKey(int keycode, bool press); void emitSync(); + void releaseLatchedModifiers(); }; } // namespace logitune diff --git a/src/core/interfaces/IInputInjector.h b/src/core/interfaces/IInputInjector.h index e6e69925..50860e9d 100644 --- a/src/core/interfaces/IInputInjector.h +++ b/src/core/interfaces/IInputInjector.h @@ -12,6 +12,13 @@ class IInputInjector : public QObject { virtual bool init() = 0; virtual void injectKeystroke(const QString &combo) = 0; + + /// Flip the latch on a modifier-only combo: hold its keys down until the + /// same combo is toggled again, so a pointer drag can carry the modifier. + /// Returns whether the combo is held afterwards — false also covers a + /// combo the injector refuses to latch. + virtual bool toggleModifierLatch(const QString &combo) = 0; + virtual void injectCtrlScroll(int direction) = 0; virtual void injectHorizontalScroll(int direction) = 0; virtual void sendDBusCall(const QString &spec) = 0; diff --git a/tests/mocks/MockInjector.h b/tests/mocks/MockInjector.h index a319fed5..4189faf4 100644 --- a/tests/mocks/MockInjector.h +++ b/tests/mocks/MockInjector.h @@ -1,5 +1,6 @@ #pragma once #include "interfaces/IInputInjector.h" +#include #include #include @@ -23,6 +24,17 @@ class MockInjector : public logitune::IInputInjector { m_calls.append({QStringLiteral("injectKeystroke"), combo}); } + /// Mirrors UinputInjector's flip-flop at combo granularity, which is all + /// a caller can observe: the real injector's key-level bookkeeping only + /// matters where two latched combos overlap. + bool toggleModifierLatch(const QString &combo) override { + m_calls.append({QStringLiteral("toggleModifierLatch"), combo}); + if (m_latched.remove(combo)) + return false; + m_latched.insert(combo); + return true; + } + void injectCtrlScroll(int direction) override { m_calls.append({QStringLiteral("injectCtrlScroll"), QString::number(direction)}); } @@ -43,6 +55,8 @@ class MockInjector : public logitune::IInputInjector { void clear() { m_calls.clear(); } + bool isLatched(const QString &combo) const { return m_latched.contains(combo); } + bool hasCalled(const QString &method) const { for (const auto &c : m_calls) { if (c.method == method) @@ -63,6 +77,7 @@ class MockInjector : public logitune::IInputInjector { private: QVector m_calls; + QSet m_latched; }; } // namespace logitune::test diff --git a/tests/services/test_button_action_dispatcher.cpp b/tests/services/test_button_action_dispatcher.cpp index a33100d3..929eb8cb 100644 --- a/tests/services/test_button_action_dispatcher.cpp +++ b/tests/services/test_button_action_dispatcher.cpp @@ -417,3 +417,78 @@ TEST_F(ButtonActionDispatcherFixture, GesturePresetUnresolvedFiresNothing) { EXPECT_FALSE(m_injector->hasCalled("sendDBusCall")); EXPECT_FALSE(m_injector->hasCalled("launchApp")); } + +// --- Sticky modifiers ------------------------------------------------------- + +TEST_F(ButtonActionDispatcherFixture, StickyModifierButtonTogglesTheLatch) { + attachMockSession(); + setProfileButton(hwProfile(), 3, + ButtonAction{ButtonAction::StickyModifier, QStringLiteral("Meta")}); + + m_dispatcher->onDivertedButtonPressed(kBackCid, true); + EXPECT_EQ(m_injector->lastArg("toggleModifierLatch"), QStringLiteral("Meta")); + EXPECT_TRUE(m_injector->isLatched(QStringLiteral("Meta"))); + + // Second press is the release half of the toggle, not a second hold. + m_dispatcher->onDivertedButtonPressed(kBackCid, true); + EXPECT_FALSE(m_injector->isLatched(QStringLiteral("Meta"))); +} + +TEST_F(ButtonActionDispatcherFixture, StickyModifierNeverPulsesAKeystroke) { + // A latch must not also fire the pulse path — that would tap Meta and + // let the compositor treat it as a Meta press-and-release. + attachMockSession(); + setProfileButton(hwProfile(), 3, + ButtonAction{ButtonAction::StickyModifier, QStringLiteral("Meta")}); + + m_dispatcher->onDivertedButtonPressed(kBackCid, true); + EXPECT_FALSE(m_injector->hasCalled("injectKeystroke")); +} + +TEST_F(ButtonActionDispatcherFixture, StickyModifierWithEmptyPayloadIsNoOp) { + attachMockSession(); + setProfileButton(hwProfile(), 3, ButtonAction{ButtonAction::StickyModifier, {}}); + + m_dispatcher->onDivertedButtonPressed(kBackCid, true); + EXPECT_FALSE(m_injector->hasCalled("toggleModifierLatch")); +} + +TEST_F(ButtonActionDispatcherFixture, GestureClickTogglesStickyModifier) { + attachMockSession(); + setProfileButton(hwProfile(), 5, + ButtonAction{ButtonAction::GestureTrigger, {}}); + hwProfile().gestures["click"] = ButtonAction{ButtonAction::StickyModifier, + QStringLiteral("Meta")}; + + m_dispatcher->onDivertedButtonPressed(kGestureCid, true); + m_dispatcher->onGestureRaw(3, 3); // under kGestureThreshold — a click + m_dispatcher->onDivertedButtonPressed(kGestureCid, false); + + EXPECT_EQ(m_injector->lastArg("toggleModifierLatch"), QStringLiteral("Meta")); + EXPECT_TRUE(m_injector->isLatched(QStringLiteral("Meta"))); +} + +TEST_F(ButtonActionDispatcherFixture, GestureDirectionTogglesStickyModifier) { + attachMockSession(); + setProfileButton(hwProfile(), 5, + ButtonAction{ButtonAction::GestureTrigger, {}}); + hwProfile().gestures["up"] = ButtonAction{ButtonAction::StickyModifier, + QStringLiteral("Ctrl+Meta")}; + + m_dispatcher->onDivertedButtonPressed(kGestureCid, true); + m_dispatcher->onGestureRaw(2, -80); + m_dispatcher->onDivertedButtonPressed(kGestureCid, false); + + EXPECT_EQ(m_injector->lastArg("toggleModifierLatch"), QStringLiteral("Ctrl+Meta")); +} + +TEST_F(ButtonActionDispatcherFixture, DBusButtonFiresTheCall) { + // Only a hand-edited profile can spell this, but it now reaches the bus + // instead of being silently dropped by the dispatcher. + attachMockSession(); + setProfileButton(hwProfile(), 3, + ButtonAction{ButtonAction::DBus, QStringLiteral("a,b,c,d")}); + + m_dispatcher->onDivertedButtonPressed(kBackCid, true); + EXPECT_EQ(m_injector->lastArg("sendDBusCall"), QStringLiteral("a,b,c,d")); +} diff --git a/tests/test_action_model.cpp b/tests/test_action_model.cpp index 31ec867e..74816d36 100644 --- a/tests/test_action_model.cpp +++ b/tests/test_action_model.cpp @@ -278,3 +278,104 @@ TEST(ActionModel, buttonActionToNamePresetLooksUpLabel) { EXPECT_EQ(m.buttonActionToName(ButtonAction{ButtonAction::PresetRef, "show-desktop"}), "Show desktop"); } + +// --------------------------------------------------------------------------- +// Domain -> UI vocabulary: buttonActionToType / buttonActionToName +// --------------------------------------------------------------------------- + +TEST(ActionModel, buttonActionToTypeCoversEveryActionType) { + ActionModel m; + EXPECT_EQ(m.buttonActionToType({ButtonAction::Default, {}}), "default"); + EXPECT_EQ(m.buttonActionToType({ButtonAction::GestureTrigger, {}}), "gesture-trigger"); + EXPECT_EQ(m.buttonActionToType({ButtonAction::SmartShiftToggle, {}}), "smartshift-toggle"); + EXPECT_EQ(m.buttonActionToType({ButtonAction::DpiCycle, {}}), "dpi-cycle"); + EXPECT_EQ(m.buttonActionToType({ButtonAction::Keystroke, "Ctrl+C"}), "keystroke"); + EXPECT_EQ(m.buttonActionToType({ButtonAction::AppLaunch, "kcalc"}), "app-launch"); + EXPECT_EQ(m.buttonActionToType({ButtonAction::PresetRef, "screenshot"}), "preset"); +} + +TEST(ActionModel, buttonActionToTypeFoldsMediaOntoKeystroke) { + ActionModel m; + EXPECT_EQ(m.buttonActionToType({ButtonAction::Media, "Mute"}), "keystroke"); +} + +TEST(ActionModel, buttonActionToNameNamesMediaPayload) { + // A legacy "media:Mute" binding must reach the same row as "keystroke:Mute". + ActionModel m; + EXPECT_EQ(m.buttonActionToName({ButtonAction::Media, "Mute"}), "Mute"); +} + +TEST(ActionModel, buttonActionToNameNamesPayloadlessDeviceActions) { + ActionModel m; + EXPECT_EQ(m.buttonActionToName({ButtonAction::GestureTrigger, {}}), "Gestures"); + EXPECT_EQ(m.buttonActionToName({ButtonAction::SmartShiftToggle, {}}), "Shift wheel mode"); + EXPECT_EQ(m.buttonActionToName({ButtonAction::DpiCycle, {}}), "DPI cycle"); +} + +TEST(ActionModel, buttonActionToNameDefaultHasNoName) { + // The factory label belongs to the device descriptor, not the action list. + ActionModel m; + EXPECT_TRUE(m.buttonActionToName({ButtonAction::Default, {}}).isEmpty()); +} + +TEST(ActionModel, buttonActionToNameEmptyKeystrokeIsCustomShortcut) { + ActionModel m; + EXPECT_EQ(m.buttonActionToName({ButtonAction::Keystroke, {}}), "Keyboard shortcut"); +} + +TEST(ActionModel, buttonActionToNameFallsBackToPayload) { + ActionModel m; + EXPECT_EQ(m.buttonActionToName({ButtonAction::Keystroke, "Ctrl+Q"}), "Ctrl+Q"); + EXPECT_EQ(m.buttonActionToName({ButtonAction::AppLaunch, "kcalc"}), "kcalc"); + EXPECT_EQ(m.buttonActionToName({ButtonAction::PresetRef, "no-such"}), "no-such"); +} + +// --------------------------------------------------------------------------- +// Sticky modifier rows +// --------------------------------------------------------------------------- + +TEST(ActionModel, StickyRowsCarryModifierPayloads) { + ActionModel m; + for (const auto &[name, payload] : { + std::pair{"Sticky Alt", "Alt"}, + std::pair{"Sticky Ctrl", "Ctrl"}, + std::pair{"Sticky Meta", "Meta"}, + std::pair{"Sticky Shift", "Shift"}, + }) { + int idx = m.indexForName(name); + ASSERT_GE(idx, 0) << name; + auto i = m.index(idx, 0); + EXPECT_EQ(m.data(i, ActionModel::ActionTypeRole).toString(), "sticky"); + EXPECT_EQ(m.data(i, ActionModel::PayloadRole).toString(), payload); + EXPECT_EQ(m.data(i, ActionModel::CategoryRole).toString(), "Modifiers"); + } +} + +TEST(ActionModel, StickyActionRoundTripsThroughTheUiVocabulary) { + ActionModel m; + ButtonAction orig{ButtonAction::StickyModifier, QStringLiteral("Meta")}; + + EXPECT_EQ(m.buttonActionToType(orig), "sticky"); + const QString name = m.buttonActionToName(orig); + EXPECT_EQ(name, "Sticky Meta"); + EXPECT_EQ(m.buttonEntryToAction("sticky", name), orig); +} + +TEST(ActionModel, HandWrittenStickyComboRoundTripsAsItsPayload) { + // No row spells "Ctrl+Meta", so the payload itself is the display name — + // and it must survive a save unchanged. + ActionModel m; + ButtonAction orig{ButtonAction::StickyModifier, QStringLiteral("Ctrl+Meta")}; + + const QString name = m.buttonActionToName(orig); + EXPECT_EQ(name, "Ctrl+Meta"); + EXPECT_EQ(m.buttonEntryToAction("sticky", name), orig); +} + +TEST(ActionModel, DBusActionRoundTripsInsteadOfBeingDropped) { + ActionModel m; + ButtonAction orig{ButtonAction::DBus, QStringLiteral("svc,/path,iface,method")}; + + EXPECT_EQ(m.buttonActionToType(orig), "dbus"); + EXPECT_EQ(m.buttonEntryToAction("dbus", m.buttonActionToName(orig)), orig); +} diff --git a/tests/test_button_action.cpp b/tests/test_button_action.cpp index 0068a2fb..c1f1eec4 100644 --- a/tests/test_button_action.cpp +++ b/tests/test_button_action.cpp @@ -178,3 +178,34 @@ TEST(ButtonAction, RoundTripPresetRefSwitchDesktopLeft) { ButtonAction result = ButtonAction::parse(orig.serialize()); EXPECT_EQ(result, orig); } + +// --------------------------------------------------------------------------- +// StickyModifier +// --------------------------------------------------------------------------- + +TEST(ButtonAction, ParseSticky) { + auto a = ButtonAction::parse("sticky:Meta"); + EXPECT_EQ(a.type, ButtonAction::StickyModifier); + EXPECT_EQ(a.payload, "Meta"); +} + +TEST(ButtonAction, ParseStickyCombo) { + auto a = ButtonAction::parse("sticky:Ctrl+Meta"); + EXPECT_EQ(a.type, ButtonAction::StickyModifier); + EXPECT_EQ(a.payload, "Ctrl+Meta"); +} + +TEST(ButtonAction, SerializeSticky) { + ButtonAction a{ButtonAction::StickyModifier, "Meta"}; + EXPECT_EQ(a.serialize(), "sticky:Meta"); +} + +TEST(ButtonAction, RoundTripSticky) { + ButtonAction orig{ButtonAction::StickyModifier, "Shift"}; + EXPECT_EQ(ButtonAction::parse(orig.serialize()), orig); +} + +TEST(ButtonAction, StickyIsDistinctFromKeystroke) { + // Same payload, different temporal envelope: one pulses, one latches. + EXPECT_NE(ButtonAction::parse("sticky:Meta"), ButtonAction::parse("keystroke:Meta")); +} diff --git a/tests/test_keystroke_parser.cpp b/tests/test_keystroke_parser.cpp index 5523c99f..48dee644 100644 --- a/tests/test_keystroke_parser.cpp +++ b/tests/test_keystroke_parser.cpp @@ -296,3 +296,79 @@ TEST(KeystrokeParser, EdgeCaseWhitespaceInCombo) { EXPECT_EQ(UinputInjector::parseKeystroke("Ctrl + C"), (std::vector{KEY_LEFTCTRL, KEY_C})); } + +// --------------------------------------------------------------------------- +// parseModifierCombo — the refinement a latch accepts +// --------------------------------------------------------------------------- + +TEST(ModifierCombo, SingleModifier) { + EXPECT_EQ(UinputInjector::parseModifierCombo("Meta"), (std::vector{KEY_LEFTMETA})); + EXPECT_EQ(UinputInjector::parseModifierCombo("Ctrl"), (std::vector{KEY_LEFTCTRL})); + EXPECT_EQ(UinputInjector::parseModifierCombo("Shift"), (std::vector{KEY_LEFTSHIFT})); + EXPECT_EQ(UinputInjector::parseModifierCombo("Alt"), (std::vector{KEY_LEFTALT})); +} + +TEST(ModifierCombo, SuperIsMeta) { + EXPECT_EQ(UinputInjector::parseModifierCombo("Super"), (std::vector{KEY_LEFTMETA})); +} + +TEST(ModifierCombo, ModifierOnlyCombo) { + EXPECT_EQ(UinputInjector::parseModifierCombo("Ctrl+Shift"), + (std::vector{KEY_LEFTCTRL, KEY_LEFTSHIFT})); +} + +TEST(ModifierCombo, MixedComboRefusedWhole) { + // Not "latch the Ctrl and drop the C" — the whole combo is rejected. + EXPECT_TRUE(UinputInjector::parseModifierCombo("Ctrl+C").empty()); + EXPECT_TRUE(UinputInjector::parseModifierCombo("Alt+Tab").empty()); +} + +TEST(ModifierCombo, OrdinaryKeyRefused) { + EXPECT_TRUE(UinputInjector::parseModifierCombo("A").empty()); + EXPECT_TRUE(UinputInjector::parseModifierCombo("F5").empty()); + EXPECT_TRUE(UinputInjector::parseModifierCombo("VolumeUp").empty()); +} + +TEST(ModifierCombo, EmptyAndUnknownRefused) { + EXPECT_TRUE(UinputInjector::parseModifierCombo("").empty()); + EXPECT_TRUE(UinputInjector::parseModifierCombo("NoSuchKey").empty()); +} + +// --------------------------------------------------------------------------- +// toggleModifierLatch — the flip-flop, observable without a uinput device +// --------------------------------------------------------------------------- + +TEST(ModifierLatch, TogglingOneComboAlternates) { + // No init() here: with no fd the key writes are no-ops, but the latch + // bookkeeping (which is what callers act on) still runs. + UinputInjector inj; + EXPECT_TRUE(inj.toggleModifierLatch("Meta")); + EXPECT_FALSE(inj.toggleModifierLatch("Meta")); + EXPECT_TRUE(inj.toggleModifierLatch("Meta")); +} + +TEST(ModifierLatch, RefusedComboNeverLatches) { + UinputInjector inj; + EXPECT_FALSE(inj.toggleModifierLatch("Ctrl+C")); + EXPECT_FALSE(inj.toggleModifierLatch("Ctrl+C")); +} + +TEST(ModifierLatch, DistinctCombosLatchIndependently) { + UinputInjector inj; + EXPECT_TRUE(inj.toggleModifierLatch("Meta")); + EXPECT_TRUE(inj.toggleModifierLatch("Alt")); + EXPECT_FALSE(inj.toggleModifierLatch("Meta")); + EXPECT_FALSE(inj.toggleModifierLatch("Alt")); +} + +TEST(ModifierLatch, OverlappingCombosReleaseOnlyWhenFullyHeld) { + UinputInjector inj; + + EXPECT_TRUE(inj.toggleModifierLatch("Meta")); // Meta held + EXPECT_TRUE(inj.toggleModifierLatch("Ctrl+Meta")); // Ctrl joins, not a release + EXPECT_FALSE(inj.toggleModifierLatch("Ctrl+Meta")); // both held -> both released + + // Meta went down once and came back up with the pair, so the next toggle + // starts from nothing held. + EXPECT_TRUE(inj.toggleModifierLatch("Meta")); +}