feat(actions): sticky modifiers --- latch a modifier until pressed again - #152
Open
0zitro wants to merge 3 commits into
Open
feat(actions): sticky modifiers --- latch a modifier until pressed again#1520zitro wants to merge 3 commits into
0zitro wants to merge 3 commits into
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Ctrlmeans 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
Metaheld and resizes it when you right-drag withMetaheld, from anywhere on the surface, no title bar hunting. Bindkeystroke:Metato a side button, press it, then drag --- nothing moves.Metawent 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:
Shiftyou hold,Caps Lockyou 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.Press the button and
Metagoes 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 andMetacomes up.Anywhere a keystroke can go, a latch can go, gestures included:
The picker gains a Modifiers group with
Alt,Ctrl,MetaandShift. A hand-written combo likesticky:Ctrl+Metahas 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 Lockfor the letterA, and for the same reason: a held ordinary key is a key the compositor autorepeats, so a latched letter gives youaaaaaaaa.sticky:Ctrl+Cis therefore refused whole rather than latching theCtrland dropping theC, which would quietly do something other than what the line says.UinputInjector::parseModifierComboenforces this by refiningparseKeystrokeinstead of repeating its token table, so the two cannot drift apart on whatSupermeans.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 beforeUI_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 leavesMetadown 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 latchCtrl+Meta---Ctrljoins andMetastays down once; toggleCtrl+Metaagain and both release,Metacoming 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.cpppins that sequence.ButtonAction::StickyModifieris a distinct type, not a flag onKeystroke. 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.
ButtonActionDispatcherhad a branch per stateless action type, duplicating whatActionExecutor::executeActionalready 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-writtendbus:binding on a button now reaches the bus, where it was previously dropped even though the gesture path had always honoured it.ProfileOrchestratorcarried a switch over everyButtonActiontype producing the(actionType, actionName)pair the picker speaks --- the inverse of a mappingActionModelalready owned, so every new action type had to be taught to both places.ActionModelnow owns that direction viabuttonActionToTypeplus a uniform(actionType, payload)row lookup. This closed a latent gap:Shift wheel modeandDPI cycleused 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 adbus:binding survives a UI save instead of resetting todefaultthe 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 fix3c51495---feat(input): hold modifiers with a latch; the injector capability, no caller yet1704518---feat(actions): bind a button to a sticky modifier; thesticky:action end to endTesting
ctestgoes 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 forsticky:, and the domain-to-UI translation for everyButtonActiontype.Smoke-tested on hardware (MX Master 3S, KDE/Wayland) with
click=sticky:Metaon the gesture button: seven clean alternations,heldtoreleasedtoheld, one latched key each time, withMeta-drag move and resize both behaving.One interaction to be aware of
KWin opens the application launcher on a
Metapress-and-release with nothing in between, so toggling asticky:Metabinding 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 aMeta-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 tappingMetaon a keyboard, rather than anything the latch introduces.README.mdand the wiki's Architecture page are updated. There is noCHANGELOG.mdin the repo, so nothing to add there.