docs/plans/2026-04-11-button-button-stateful-mapping-design.md
Add true down/up stateful mapping for actions in the existing mouse-button action category so a trigger button can map to another mouse button without collapsing into a one-shot click. Keep all non-mouse action categories on the current trigger-style execution path. Reuse the existing down/up pairing model already proven by custom bindings, while decoupling "stateful execution" from the custom:: encoding itself.
Today the button binding pipeline supports two different execution behaviors:
custom::<code>:<modifiers> already execute with explicit down/up phases.mouseLeftClick and mouseRightClick still execute as an immediate synthetic down + up pair inside one call.That means a mapping like "side button -> left mouse button" is treated as a synthetic click, not as a real held button state. The user-visible symptom is that button-button mapping cannot preserve held-state semantics.
down/up stateful execution for the existing mouse-button action category.activeBindings pairing pattern instead of building a second state system.MosInputEvent and MosInputProcessor to InputEvent and InputProcessor for clearer module ownership.mouseMoved, leftMouseDragged, rightMouseDragged, or otherMouseDragged.custom:: storage format.The current pipeline is:
ButtonCore converts physical CGEvent input into MosInputEvent.MosInputProcessor matches the event against ButtonBinding.triggerEvent.ShortcutExecutor executes the matched action.activeBindings is used only for flows that already need paired down/up release.This architecture is already close to what we need. The main issue is that "stateful" currently means "custom binding" rather than being a first-class execution mode.
Rename the input model and processor files and types:
Mos/InputEvent/MosInputEvent.swift -> Mos/InputEvent/InputEvent.swiftMos/InputEvent/MosInputProcessor.swift -> Mos/InputEvent/InputProcessor.swiftMosInputEvent -> InputEventMosInputPhase -> InputPhaseMosInputSource -> InputSourceMosInputDevice -> InputDeviceMosInputResult -> InputResultMosInputProcessor -> InputProcessorThis is a naming cleanup only. No behavior changes should be attached to the rename beyond reference updates.
Add a small execution-mode abstraction:
enum ActionExecutionMode {
case trigger
case stateful
}
This mode belongs to the action definition layer, not to the input event layer and not to the storage encoding itself.
Initial policy:
.stateful.trigger.statefulThis turns "stateful" into an execution property that multiple action types can share.
Before execution, parse a binding's output action into a single internal representation:
enum ResolvedAction {
case customKey(code: UInt16, modifiers: UInt64)
case mouseButton(kind: MouseButtonActionKind)
case systemShortcut(identifier: String)
case logiAction(identifier: String)
}
MouseButtonActionKind should map the current mouse-button category actions to semantic outputs:
enum MouseButtonActionKind {
case left
case right
case middle
case back
case forward
}
ShortcutExecutor should operate on ResolvedAction, not on a mix of ad-hoc string prefixes and special cases spread across the pipeline.
Replace the current "active trigger key -> binding" storage with a lightweight session object:
struct ActiveBindingSession {
let triggerKey: TriggerKey
let bindingId: UUID
let action: ResolvedAction
let executionMode: ActionExecutionMode
}
The active table remains keyed by trigger source:
[TriggerKey: ActiveBindingSession]
Why this change:
up handling does not need to re-parse strings or re-read menu definitions.InputProcessor responsible for pairingInputProcessor should remain the owner of:
down/up pairingBehavior by phase:
InputEvent against enabled bindings..trigger, execute immediately and do not record active state..stateful, execute the action's down, store an ActiveBindingSession, then consume.up, remove the session, then consume.This preserves the existing custom-binding release model and broadens it to mouse-button outputs.
activeModifierFlags should continue to be derived from active sessions, but only from sessions whose resolved action is a custom modifier key.
That means:
ShortcutExecutorMouse-button actions in the existing category should stop using one-shot click helpers. Instead they should use a phase-aware posting path:
down -> post synthetic mouse-down eventup -> post synthetic mouse-up eventThis should be handled through a dedicated helper, for example:
func executeMouseButton(_ kind: MouseButtonActionKind, phase: InputPhase)
Coordinate policy remains simple:
This keeps the behavior aligned with the current design boundary.
All existing non-mouse predefined actions continue to behave exactly as trigger actions:
downupThat keeps the existing UX and avoids accidental semantic changes to the rest of the shortcuts catalog.
down arrives in ButtonCore.ButtonCore builds an InputEvent.InputProcessor matches the binding..mouseButton(.left) with .stateful.ShortcutExecutor posts synthetic left-mouse down.InputProcessor stores an ActiveBindingSession.up arrives later.InputProcessor looks up the session.ShortcutExecutor posts synthetic left-mouse up.down arrives..systemShortcut("missionControl") with .trigger.ShortcutExecutor executes it immediately.up later passes through with no further work.down arrives..customKey(...) with .stateful.ShortcutExecutor posts the synthetic key down.InputProcessor stores the session.activeModifierFlags is recomputed from active sessions and injected into passthrough keyboard events.up, the synthetic custom key up is posted and the session is removed.This shows that custom bindings and stateful mouse-button bindings share the same session lifecycle but keep separate payload semantics.
The biggest correctness risk is a stuck synthetic held output. To prevent that, clearActiveBindings() should evolve into a real "release all active sessions" operation:
.stateful, execute their up.activeModifierFlags.This unified release path must be called when:
ButtonCore.disable() is calledInterceptor restartsThis strategy keeps release logic in one place and applies equally to custom-key stateful output and mouse-button stateful output.
The design keeps performance costs low:
up, because resolved actions are stored in the active session.This keeps the hot path close to the existing model while making the behavior more correct.
Primary files:
Mos/InputEvent/InputEvent.swiftMos/InputEvent/InputProcessor.swiftMos/Shortcut/SystemShortcut.swiftMos/Shortcut/ShortcutExecutor.swiftMos/ButtonCore/ButtonCore.swiftMos/LogitechHID/LogitechDeviceSession.swiftLikely tests:
MosTests/MosInputProcessorTests.swift renamed or replaced with MosTests/InputProcessorTests.swiftThe most important validation cases are:
down on source down and up on source updown/up pairs do not leave stale active sessionsactiveModifierFlags correctlyThe internal identifiers in the mouse-button category still use mouseLeftClick, mouseRightClick, and similar names. Their execution semantics will become stateful instead of one-shot click semantics. This is acceptable for the current iteration because:
If we later need to distinguish one-shot click from held-state output, we can add new identifiers and migrate the menu labeling separately.
Proceed with a small abstraction step:
MosInput* to Input*This keeps the design aligned with the current framework, minimizes branching, and leaves a clean extension path for future stateful categories.