docs/plans/2026-04-12-stateful-mouse-drag-session.md
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Make mapped mouse-button actions produce real cross-app drag behavior by rewriting movement events into the correct *MouseDragged stream only while a synthetic mouse-button session is active.
Architecture: Keep InputProcessor as the generic stateful session manager, introduce explicit ActiveBindingSession data plus indexed binding lookup, and add a dedicated MouseDragSessionController that owns a reusable motion tap. The motion tap activates only during synthetic mouse sessions and rewrites in-flight move events instead of reposting new ones.
Tech Stack: Swift, AppKit, CoreGraphics CGEvent, existing Interceptor, ButtonCore, InputProcessor, ShortcutExecutor, and xcodebuild test
Files:
MosTests/MouseDragSessionControllerTests.swiftMosTests/InputProcessorTests.swiftStep 1: Write the failing tests
Add logic-level tests for:
func testSyntheticTargetPriority_leftBeatsRightAndOther()
func testEffectiveTarget_prefersPhysicalLeftOverSyntheticRight()
func testEffectiveTarget_upgradesMouseMovedToSyntheticLeftDragged()
func testSessionLifecycle_startsOnFirstMouseSession_andStopsOnLast()
func testButtonUtilsIndex_returnsOnlyMatchingTypeAndCodeCandidates()
Step 2: Run the focused tests to verify they fail
Run:
xcodebuild test -project Mos.xcodeproj -scheme Debug -destination 'platform=macOS' -only-testing:MosTests/MouseDragSessionControllerTests -only-testing:MosTests/InputProcessorTests CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=''
Expected: compile or test failure because the drag-session controller and indexed lookup do not exist yet.
Step 3: Commit nothing yet
Do not commit in red state.
ButtonUtilsFiles:
Mos/ButtonCore/ButtonUtils.swiftMos/InputEvent/InputProcessor.swiftMosTests/InputProcessorTests.swiftStep 1: Add a trigger-key cache
Add a lightweight key model in ButtonUtils:
struct ButtonBindingTriggerKey: Hashable {
let type: EventType
let code: UInt16
}
Maintain:
private var cachedBindings: [ButtonBinding] = []
private var cachedBindingsByTriggerKey: [ButtonBindingTriggerKey: [ButtonBinding]] = [:]
Step 2: Build the index when cache is refreshed
Populate both caches during getButtonBindings() and expose:
func getButtonBindings(for type: EventType, code: UInt16) -> [ButtonBinding]
Step 3: Update InputProcessor to use indexed candidates
Change down matching from full-list scan to:
let candidates = ButtonUtils.shared.getButtonBindings(for: event.type, code: event.code)
for binding in candidates where binding.isEnabled { ... }
Step 4: Run tests
Run:
xcodebuild test -project Mos.xcodeproj -scheme Debug -destination 'platform=macOS' -only-testing:MosTests/InputProcessorTests CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=''
Expected: the new binding-index tests pass and existing processor tests stay green.
Step 5: Commit
git add Mos/ButtonCore/ButtonUtils.swift Mos/InputEvent/InputProcessor.swift MosTests/InputProcessorTests.swift
git commit -m "perf(buttons): index bindings by trigger key"
Files:
Mos/InputEvent/InputProcessor.swiftMos/Shortcut/ShortcutExecutor.swiftMosTests/InputProcessorTests.swiftStep 1: Add ActiveBindingSession
Replace the current active map value:
private struct ActiveBindingSession {
let triggerKey: TriggerKey
let action: ResolvedAction
let mouseSessionID: UUID?
}
Step 2: Update down/up handling to store sessions
On stateful down:
On up:
Step 3: Keep clearActiveBindings() authoritative
Iterate sessions, release each stateful action, then clear modifier flags and all session state.
Step 4: Run tests
Run:
xcodebuild test -project Mos.xcodeproj -scheme Debug -destination 'platform=macOS' -only-testing:MosTests/InputProcessorTests CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=''
Expected: session-pairing tests remain green and new mouse-session storage behavior passes.
Step 5: Commit
git add Mos/InputEvent/InputProcessor.swift Mos/Shortcut/ShortcutExecutor.swift MosTests/InputProcessorTests.swift
git commit -m "refactor(input): store explicit active binding sessions"
MouseDragSessionController with pure selection logic firstFiles:
Mos/InputEvent/MouseDragSessionController.swiftMosTests/MouseDragSessionControllerTests.swiftStep 1: Add pure target models
Create:
enum SyntheticMouseTarget: Equatable {
case left
case right
case other(buttonNumber: Int64)
}
enum PhysicalMouseTarget: Equatable {
case none
case left
case right
case other(buttonNumber: Int64)
}
Add pure helpers:
static func dominantSyntheticTarget(from targets: [SyntheticMouseTarget]) -> SyntheticMouseTarget?
static func effectiveTarget(physical: PhysicalMouseTarget, synthetic: SyntheticMouseTarget?) -> SyntheticMouseTarget?
Step 2: Add session bookkeeping
Create a controller with:
final class MouseDragSessionController {
static let shared = MouseDragSessionController()
}
Track:
Do not wire the motion tap yet.
Step 3: Run tests
Run:
xcodebuild test -project Mos.xcodeproj -scheme Debug -destination 'platform=macOS' -only-testing:MosTests/MouseDragSessionControllerTests CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=''
Expected: priority and effective-target tests pass.
Step 4: Commit
git add Mos/InputEvent/MouseDragSessionController.swift MosTests/MouseDragSessionControllerTests.swift
git commit -m "feat(mouse): add drag target selection controller"
Files:
Mos/InputEvent/MouseDragSessionController.swiftMos/Utils/Interceptor.swiftMosTests/MouseDragSessionControllerTests.swiftStep 1: Wire a reusable Interceptor
Create a motion tap that listens for:
mouseMovedleftMouseDraggedrightMouseDraggedotherMouseDraggedThe controller should:
start() it on first active mouse sessionstop() it when the last active mouse session endsStep 2: Rewrite movement events in place
Implement:
func rewriteMotionEventIfNeeded(_ event: CGEvent) -> CGEvent
Rules:
event.typemouseEventButtonNumber for otherMouseDragged when neededStep 3: Hook controller restart safety
If the motion tap is disabled or restarted:
Step 4: Run tests
Run:
xcodebuild test -project Mos.xcodeproj -scheme Debug -destination 'platform=macOS' -only-testing:MosTests/MouseDragSessionControllerTests CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=''
Expected: lifecycle and rewrite tests pass.
Step 5: Commit
git add Mos/InputEvent/MouseDragSessionController.swift Mos/Utils/Interceptor.swift MosTests/MouseDragSessionControllerTests.swift
git commit -m "feat(mouse): rewrite movement events during synthetic drag sessions"
ShortcutExecutorFiles:
Mos/Shortcut/ShortcutExecutor.swiftMos/InputEvent/InputProcessor.swiftMosTests/InputProcessorTests.swiftStep 1: Add explicit mouse session begin/end helpers
Refactor mouse execution to return and consume session IDs:
func beginMouseButtonSession(_ kind: MouseButtonActionKind) -> UUID?
func endMouseButtonSession(id: UUID, kind: MouseButtonActionKind)
Use them from the stateful mouse-button path.
Step 2: Preserve ordering
On down:
On up:
Step 3: Ensure clearActiveBindings() ends backend sessions
Releasing an active mouse session must also disable the drag backend when it becomes empty.
Step 4: Run tests
Run:
xcodebuild test -project Mos.xcodeproj -scheme Debug -destination 'platform=macOS' -only-testing:MosTests/InputProcessorTests -only-testing:MosTests/MouseDragSessionControllerTests CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=''
Expected: integration tests pass.
Step 5: Commit
git add Mos/Shortcut/ShortcutExecutor.swift Mos/InputEvent/InputProcessor.swift MosTests/InputProcessorTests.swift MosTests/MouseDragSessionControllerTests.swift
git commit -m "feat(mouse): connect drag sessions to stateful mouse actions"
ButtonCoreFiles:
Mos/ButtonCore/ButtonCore.swiftMosTests/InputProcessorTests.swiftStep 1: Add missing mouse button source events
Ensure the main event mask covers:
leftMouseDownleftMouseUprightMouseDownrightMouseUpotherMouseDownotherMouseUpKeep keyboard passthrough logic unchanged.
Step 2: Keep fail-safe cleanup unified
On tap disable or shutdown, continue to call:
InputProcessor.shared.clearActiveBindings()
Step 3: Run tests
Run:
xcodebuild test -project Mos.xcodeproj -scheme Debug -destination 'platform=macOS' -only-testing:MosTests/InputProcessorTests CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=''
Expected: stateful up-pairing tests still pass for any supported mouse source.
Step 4: Commit
git add Mos/ButtonCore/ButtonCore.swift MosTests/InputProcessorTests.swift
git commit -m "fix(buttons): cover full mouse source down and up events"
Files:
docs/plans/2026-04-12-stateful-mouse-drag-session.mdStep 1: Run full automated verification
Run:
xcodebuild test -project Mos.xcodeproj -scheme Debug -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGN_IDENTITY=''
git diff --check
Expected:
Step 2: Record manual verification checklist in the plan
Append a checked or unchecked checklist for:
Step 3: Commit
git add docs/plans/2026-04-12-stateful-mouse-drag-session.md
git commit -m "docs(plan): record drag-session verification checklist"