Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions docs/wiki/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 `<linux/input-event-codes.h>`. 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
Expand Down Expand Up @@ -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<QString, ButtonAction>` 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<QString, ButtonAction>` 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.

Expand Down
64 changes: 49 additions & 15 deletions src/app/models/ActionModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions src/app/models/ActionModel.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions src/app/services/ButtonActionDispatcher.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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);
}
}

Expand Down
53 changes: 8 additions & 45 deletions src/app/services/ProfileOrchestrator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -264,51 +264,14 @@ void ProfileOrchestrator::restoreButtonModelFromProfile(const Profile &p)
? p.buttons[static_cast<std::size_t>(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});
}

Expand Down
8 changes: 8 additions & 0 deletions src/core/ActionExecutor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/core/ActionExecutor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/core/ButtonAction.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading