Skip to content

feat(actions): sticky modifiers --- latch a modifier until pressed again - #152

Open
0zitro wants to merge 3 commits into
mmaher88:masterfrom
0zitro:feat/0zitro/modifier-latch
Open

feat(actions): sticky modifiers --- latch a modifier until pressed again#152
0zitro wants to merge 3 commits into
mmaher88:masterfrom
0zitro:feat/0zitro/modifier-latch

Conversation

@0zitro

@0zitro 0zitro commented Jul 30, 2026

Copy link
Copy Markdown

Every action a button can perform today happens at an instant: a keystroke presses its keys and releases them in the same breath, a preset invokes a shortcut and returns, an app launch spawns and is done. A modifier does not fit that shape. Ctrl means nothing on its own; it means something only for as long as it is down, while something else happens.

That gap shows up the moment you try the obvious thing. KDE moves a window when you drag it with Meta held and resizes it when you right-drag with Meta held, from anywhere on the surface, no title bar hunting. Bind keystroke:Meta to a side button, press it, then drag --- nothing moves. Meta went back up a microsecond after it went down, long before your hand reached the mouse, so the drag happened with no modifier held at all. No combination of existing action types fixes this, because they all share the same instantaneous shape.

A keyboard solves it twice over: Shift you hold, Caps Lock you tap and it stays down until you tap it again. Same key set, two disciplines about when the release arrives. A mouse button cannot offer the first --- it has its own job during the drag --- but it can offer the second.

The action

sticky:<combo> carries exactly the payload a keystroke would; only the release timing differs.

[Buttons]
4=keystroke:Meta   # down, up            --- a pulse
4=sticky:Meta      # down ... later, up  --- a latch

Press the button and Meta goes down and stays down. Drag a window from its middle and it moves; right-drag near a corner and it resizes from that corner. Press again and Meta comes up.

Anywhere a keystroke can go, a latch can go, gestures included:

[Gestures]
click=sticky:Meta
up=sticky:Ctrl+Meta

The picker gains a Modifiers group with Alt, Ctrl, Meta and Shift. A hand-written combo like sticky:Ctrl+Meta has no row of its own, so it displays as its own payload and survives a save of any other button.

Design notes

Modifiers only. There is no Caps Lock for the letter A, and for the same reason: a held ordinary key is a key the compositor autorepeats, so a latched letter gives you aaaaaaaa. sticky:Ctrl+C is therefore refused whole rather than latching the Ctrl and dropping the C, which would quietly do something other than what the line says. UinputInjector::parseModifierCombo enforces this by refining parseKeystroke instead of repeating its token table, so the two cannot drift apart on what Super means.

The held set lives in UinputInjector, not in the dispatcher. That set is the virtual keyboard's key state, and it carries an invariant only the owner of the device can enforce: no key survives the device it was pressed on. shutdown() drains the latch before UI_DEV_DESTROY, so quitting Logitune is always a way out of a stuck modifier. Owned by the dispatcher, that guarantee would need re-establishing by hand at every exit path, and one missed path leaves Meta down with nothing running to lift it.

One rule makes the toggle total: a combo releases only when every one of its keys is already held, and otherwise the keys it adds join the latch. Latch Meta, then latch Ctrl+Meta --- Ctrl joins and Meta stays down once; toggle Ctrl+Meta again and both release, Meta coming up once having gone down once. So toggling one combo twice always returns the keyboard to its starting state even when two latched combos overlap. tests/test_keystroke_parser.cpp pins that sequence.

ButtonAction::StickyModifier is a distinct type, not a flag on Keystroke. Pulse and latch are different operations that happen to take the same argument, and nothing downstream should inspect a boolean to learn which one it holds.

Incidental changes

Two cleanups rode along, flagged here rather than left to be found in the diff.

ButtonActionDispatcher had a branch per stateless action type, duplicating what ActionExecutor::executeAction already knew. Those now route through the executor, leaving the dispatcher only the types that genuinely need device state --- SmartShiftToggle, DpiCycle, GestureTrigger. One behavioural consequence: a hand-written dbus: binding on a button now reaches the bus, where it was previously dropped even though the gesture path had always honoured it.

ProfileOrchestrator carried a switch over every ButtonAction type producing the (actionType, actionName) pair the picker speaks --- the inverse of a mapping ActionModel already owned, so every new action type had to be taught to both places. ActionModel now owns that direction via buttonActionToType plus a uniform (actionType, payload) row lookup. This closed a latent gap: Shift wheel mode and DPI cycle used to resolve to an empty display name, which the orchestrator papered over with hardcoded strings but the gesture map did not, so a gesture bound to either showed blank. It also means a dbus: binding survives a UI save instead of resetting to default the next time any other button is edited.

Commits

Each builds and passes the full suite on its own.

  • bd0d1d0 --- refactor: fold UI mapping into ActionModel; behaviour-preserving, plus the empty-label fix
  • 3c51495 --- feat(input): hold modifiers with a latch; the injector capability, no caller yet
  • 1704518 --- feat(actions): bind a button to a sticky modifier; the sticky: action end to end

Testing

ctest goes from 754 to 786 passing. New coverage: the parser refinement (modifier-only accepted, mixed combos refused whole), the flip-flop including the overlapping-combo case, dispatch from both the button and the gesture path, the profile round-trip for sticky:, and the domain-to-UI translation for every ButtonAction type.

Smoke-tested on hardware (MX Master 3S, KDE/Wayland) with click=sticky:Meta on the gesture button: seven clean alternations, held to released to held, one latched key each time, with Meta-drag move and resize both behaving.

One interaction to be aware of

KWin opens the application launcher on a Meta press-and-release with nothing in between, so toggling a sticky:Meta binding on and straight back off triggers it. Any pointer button press or wheel event during the hold invalidates that modifier-only shortcut, which is exactly what a Meta-drag does, so the intended use cancels it and only an idle on-off pair sees the launcher. This is KWin's own behaviour, the same as tapping Meta on a keyboard, rather than anything the latch introduces.

README.md and the wiki's Architecture page are updated. There is no CHANGELOG.md in the repo, so nothing to add there.

0zitro added 3 commits July 30, 2026 01:02
`ProfileOrchestrator` carried a switch translating every `ButtonAction`
  type into the `(actionType, actionName)` pair the picker speaks in,
  duplicating the inverse mapping `ActionModel` already owned.
Every new action type had to be taught to both sites.

`ActionModel` now owns the direction outright.
`buttonActionToType` names the UI token for an action, and
  `buttonActionToName` resolves the display label through one uniform
  `(actionType, payload)` row lookup rather than a branch per type.
Because each payload-less type owns exactly one row, that lookup also
  names `Gestures`, `Shift wheel mode` and `DPI cycle` with no special
  case; the latter two previously came back empty, which the orchestrator
  masked with hardcoded labels but the gesture map did not.
Legacy `media:` payloads fold onto the keystroke token in one place
  instead of being rewrapped at the call site.

The orchestrator retains the single fact `ActionModel` cannot know: an
  unbound button shows the descriptor's factory label.

Signed-off-by: zitro <94910351+0zitro@users.noreply.github.com>
`injectKeystroke` can only pulse: it presses every key of a combo and
  releases them in the same breath.
A modifier meant to accompany a pointer drag has to outlive the event
  that started it, so `IInputInjector` gains `toggleModifierLatch`, which
  flips a combo between held and released and reports which state it
  landed in.

The held set belongs to `UinputInjector` because it *is* the virtual
  keyboard's key state, and the invariant that guards it — no key
  survives the device it was pressed on — is only enforceable there.
`shutdown()` drains the latch before `UI_DEV_DESTROY`, so quitting the
  app is always an escape from a stuck modifier.

A combo releases only when every one of its keys is already held;
  otherwise the keys it adds join the latch.
Toggling one combo twice therefore returns the keyboard to the state it
  started in even where two latched combos share a modifier.

`parseModifierCombo` refines `parseKeystroke` rather than repeating its
  token table, and refuses a combo whole when any token is an ordinary
  key: a latched letter would autorepeat in the compositor, so there is
  nothing worth keeping in latching part of `Ctrl+C`.

No caller yet — the `sticky:` action follows.

Signed-off-by: zitro <94910351+0zitro@users.noreply.github.com>
`keystroke:Meta` can only tap Meta, and a tap is useless to a drag that
  has not started yet: by the time the pointer moves, the modifier is
  already up.
KDE's Meta+left-drag move and Meta+right-drag resize therefore had no
  reachable spelling in a profile.

`sticky:<combo>` is that spelling.
It carries the same payload a keystroke would but a different temporal
  envelope — the combo latches down and stays down until the action fires
  again, so any drag started in between carries it.
`ButtonAction::StickyModifier` is a distinct type rather than a flag on
  `Keystroke`, because pulse and latch are different operations on the
  same key set and nothing downstream should have to branch on a boolean
  to tell them apart.
Both the button path and the gesture-direction path reach it, so a swipe
  or a gesture click can latch a modifier as readily as a plain press.

The picker gains a `Modifiers` group holding `Alt`, `Ctrl`, `Meta` and
  `Shift`; a hand-written combo such as `sticky:Ctrl+Meta` has no row and
  so displays as its own payload, which still round-trips through a save.

Two things fell out of routing the dispatcher's stateless branches
  through `ActionExecutor::executeAction` instead of firing each by hand.
A `dbus:` binding on a button now reaches the bus rather than being
  dropped — the gesture path already honoured it — and it survives a UI
  save instead of being reset to `default` the next time any other button
  is edited.
`ButtonActionDispatcher` is left holding only the action types that need
  device state: `SmartShiftToggle`, `DpiCycle` and `GestureTrigger`.

Signed-off-by: zitro <94910351+0zitro@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant