docs/plans/2026-04-12-stateful-mouse-drag-session-design.md
Extend the existing stateful mouse-button mapping so held mappings behave like real mouse drags across apps. Keep the generic down/up session framework in InputProcessor, and add a dedicated mouse drag backend that only activates while a mapped mouse-button session is held.
The current implementation correctly maps mouse-button actions as true down/up pairs, but many apps do not treat the interaction as a drag. Safari provided the clearest evidence:
leftMouseDown -> leftMouseDragged* -> leftMouseUpleftMouseDown -> mouseMoved* -> leftMouseUpThat means the missing capability is not held-button state itself, but the drag-phase event stream.
The current stateful mapping already works for:
down/updown/upInputProcessor.clearActiveBindings()The missing piece is that movement remains outside the stateful session model, so apps only see ordinary mouseMoved instead of button-specific drag events.
InputProcessor remains responsible for:
trigger vs statefuldown/upThis layer stays generic and does not directly process movement events.
ActiveBindingSessionReplace the current lightweight active-action storage with an explicit session model:
struct ActiveBindingSession {
let triggerKey: TriggerKey
let action: ResolvedAction
let mouseSessionID: UUID?
}
This keeps the generic session framework intact while allowing mouse actions to attach backend-specific session state.
MouseDragSessionControllerThis new backend owns all drag-phase mouse behavior:
*MouseDragged typeThis controller is the only new component that knows about drag rewriting.
ShortcutExecutor as the integration pointShortcutExecutor remains the execution layer, but mouse-button actions become two-phase backend operations:
down
downup
upInputProcessor still calls a single execution entry point. Mouse-specific complexity stays behind the backend.
When a mapped mouse-button action receives down:
InputProcessor matches the binding.mouseButton(...)ShortcutExecutor requests a mouse drag session from MouseDragSessionControllerShortcutExecutor posts the synthetic mouse downInputProcessor stores an ActiveBindingSessionWhen the paired trigger receives up:
InputProcessor looks up the active sessionShortcutExecutor ends the drag sessionMouseDragSessionController removes that sessionShortcutExecutor posts the synthetic mouse upWhile at least one mapped mouse-button session is active, the motion tap listens for:
mouseMovedleftMouseDraggedrightMouseDraggedotherMouseDraggedIf there is no active synthetic mouse session, the event passes through unchanged.
If there is an active synthetic mouse session, the event is rewritten in place:
event.typemouseEventButtonNumber when needed for otherMouseDraggedNo additional synthetic movement event is posted.
The controller chooses an effective drag target using both real and synthetic state.
Synthetic target comes from active mapped mouse sessions and follows this priority:
leftrightother(buttonNumber)This matches observed system behavior where left-button drag dominates if multiple buttons are held.
Physical target comes from the original incoming event:
leftMouseDragged -> leftrightMouseDragged -> rightotherMouseDragged -> other(buttonNumber)mouseMoved -> noneThe rewritten event uses:
effective target = max(physical target, synthetic target)
This prevents us from downgrading a real left drag and lets synthetic left drag dominate lower-priority drag states.
The motion tap exists as a reusable object, but it is only enabled while synthetic mouse sessions are active.
Idle path cost:
The controller rewrites the current event object and returns it instead of:
This minimizes event volume, timing distortion, and invisible extra work.
Current InputProcessor matching scans all enabled bindings on each down. That is acceptable for low rates, but frequent clicks should not scale with total binding count.
ButtonUtils should maintain:
(event.type, code)Then InputProcessor only performs exact modifier/device checks on a small candidate list.
MouseDragSessionController should create its Interceptor once and toggle start()/stop() instead of repeatedly creating and destroying event taps for each click.
The worst failure mode is a stuck synthetic held mouse button. To prevent that:
InputProcessor.clearActiveBindings() remains the single release authorityThe drag backend must never try to recover a broken live session silently. It should clear state and wait for a fresh user action.
The main button tap should fully support mouse button sources:
leftMouseDownleftMouseUprightMouseDownrightMouseUpotherMouseDownotherMouseUpThis keeps the stateful framework complete for any mouse-button trigger source.
Mos/InputEvent/InputProcessor.swift
ActiveBindingSessionMos/Shortcut/ShortcutExecutor.swift
Mos/ButtonCore/ButtonUtils.swift
Mos/ButtonCore/ButtonCore.swift
Mos/Utils/Interceptor.swift
start()/stop() lifecycle remains sufficientMos/InputEvent/MouseDragSessionController.swift
left > right > otherUse monitor logs to confirm:
leftMouseDraggedleftMouseDraggedrightMouseDraggedotherMouseDragged with correct button numberThis design keeps the new complexity where it belongs:
InputProcessorShortcutExecutorThat gives us real drag behavior without permanently intercepting movement, without reposting extra events, and without turning the whole input stack into mouse-specific logic.