@lattices/cli 0.4.9 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -13
  3. package/{app → apps/mac}/Lattices.app/Contents/Info.plist +10 -2
  4. package/{app → apps/mac}/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/{app → apps/mac}/Package.swift +2 -1
  6. package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
  7. package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
  8. package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
  9. package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
  10. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +27 -0
  11. package/apps/mac/Sources/AppShell/AppDelegate.swift +189 -0
  12. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +25 -0
  13. package/{app → apps/mac}/Sources/AppShell/AppShellView.swift +18 -3
  14. package/{app → apps/mac}/Sources/AppShell/AppUpdater.swift +4 -3
  15. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +87 -0
  16. package/{app → apps/mac}/Sources/AppShell/LatticesRuntime.swift +43 -0
  17. package/{app → apps/mac}/Sources/AppShell/MainView.swift +116 -63
  18. package/apps/mac/Sources/AppShell/MenuBarController.swift +177 -0
  19. package/{app → apps/mac}/Sources/AppShell/OnboardingView.swift +72 -60
  20. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +366 -0
  21. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +70 -0
  22. package/{app → apps/mac}/Sources/AppShell/Preferences.swift +37 -2
  23. package/{app → apps/mac}/Sources/AppShell/SettingsView.swift +815 -156
  24. package/{app → apps/mac}/Sources/AppShell/SettingsWindow.swift +10 -0
  25. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +13 -0
  26. package/{app → apps/mac}/Sources/Core/Actions/HotkeyStore.swift +6 -1
  27. package/{app → apps/mac}/Sources/Core/Actions/IntentEngine.swift +2 -0
  28. package/{app → apps/mac}/Sources/Core/Daemon/DaemonServer.swift +5 -0
  29. package/{app → apps/mac}/Sources/Core/Daemon/LatticesApi.swift +365 -0
  30. package/{app → apps/mac}/Sources/Core/Desktop/DesktopModel.swift +1 -0
  31. package/{app → apps/mac}/Sources/Core/Desktop/OcrModel.swift +17 -13
  32. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +33 -0
  33. package/{app → apps/mac}/Sources/Core/Desktop/WindowDragSnapController.swift +18 -217
  34. package/{app → apps/mac}/Sources/Core/Desktop/WindowPreviewStore.swift +4 -5
  35. package/{app → apps/mac}/Sources/Core/Desktop/WindowTiler.swift +19 -13
  36. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +124 -0
  37. package/apps/mac/Sources/Core/Input/EventTapThread.swift +54 -0
  38. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +20 -0
  39. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +335 -0
  40. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +141 -0
  41. package/{app → apps/mac}/Sources/Core/Input/MouseGestureConfig.swift +155 -20
  42. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +2259 -0
  43. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +170 -0
  44. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +39 -0
  45. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +624 -0
  46. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +56 -0
  47. package/{app → apps/mac}/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +46 -27
  48. package/{app → apps/mac}/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +580 -162
  49. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +1240 -0
  50. package/{app → apps/mac}/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +11 -23
  51. package/{app → apps/mac}/Sources/Core/Pi/PiChatDock.swift +90 -43
  52. package/{app → apps/mac}/Sources/Core/Pi/PiChatSession.swift +676 -43
  53. package/{app → apps/mac}/Sources/Core/Pi/PiProviderSetupCallout.swift +5 -5
  54. package/{app → apps/mac}/Sources/Core/Pi/PiWorkspaceView.swift +93 -44
  55. package/apps/mac/Sources/Core/System/Capability.swift +79 -0
  56. package/{app → apps/mac}/Sources/Core/System/PermissionChecker.swift +43 -8
  57. package/{app → apps/mac}/Sources/Core/Voice/AudioProvider.swift +225 -56
  58. package/bin/handsoff-infer.ts +14 -5
  59. package/bin/handsoff-worker.ts +11 -7
  60. package/bin/infer.ts +406 -0
  61. package/bin/lattices-app.ts +57 -7
  62. package/bin/lattices-dev +40 -1
  63. package/bin/lattices.ts +1 -1
  64. package/docs/agent-execution-plan.md +9 -9
  65. package/docs/api.md +119 -0
  66. package/docs/app.md +1 -0
  67. package/docs/companion-deck.md +1 -1
  68. package/docs/gesture-customization-proposal.md +520 -0
  69. package/docs/mouse-gestures.md +79 -0
  70. package/docs/overview.md +2 -2
  71. package/docs/presentation-execution-review.md +9 -9
  72. package/docs/proposals/LAT-001-gesture-visual-customization.md +522 -0
  73. package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
  74. package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
  75. package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
  76. package/docs/reference/dewey.config.ts +74 -0
  77. package/docs/reference/install-agent.md +79 -0
  78. package/docs/repo-structure.md +100 -0
  79. package/docs/voice-error-model.md +7 -7
  80. package/docs/voice.md +18 -0
  81. package/package.json +23 -13
  82. package/swift/Package.swift +20 -0
  83. package/swift/Sources/DeckKit/DeckAction.swift +51 -0
  84. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +152 -0
  85. package/swift/Sources/DeckKit/DeckCockpit.swift +82 -0
  86. package/swift/Sources/DeckKit/DeckHost.swift +7 -0
  87. package/swift/Sources/DeckKit/DeckManifest.swift +145 -0
  88. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +533 -0
  89. package/swift/Sources/DeckKit/DeckTrackpad.swift +63 -0
  90. package/swift/Sources/DeckKit/DeckValue.swift +93 -0
  91. package/swift/Sources/DeckKit/DeckVoiceError.swift +88 -0
  92. package/swift/Tests/DeckKitTests/DeckKitTests.swift +286 -0
  93. package/app/Sources/AppShell/AppDelegate.swift +0 -408
  94. package/app/Sources/Core/Input/KeyboardRemapController.swift +0 -184
  95. package/app/Sources/Core/Input/KeyboardRemapStore.swift +0 -84
  96. package/app/Sources/Core/Input/MouseGestureController.swift +0 -1203
  97. package/app/Sources/Core/Input/MouseShortcutStore.swift +0 -107
  98. /package/{app → apps/mac}/Info.plist +0 -0
  99. /package/{app → apps/mac}/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  100. /package/{app → apps/mac}/Lattices.app/Contents/Resources/tap.wav +0 -0
  101. /package/{app → apps/mac}/Lattices.app/Contents/_CodeSignature/CodeResources +0 -0
  102. /package/{app → apps/mac}/Lattices.entitlements +0 -0
  103. /package/{app → apps/mac}/Resources/tap.wav +0 -0
  104. /package/{app → apps/mac}/Sources/AppShell/App.swift +0 -0
  105. /package/{app → apps/mac}/Sources/AppShell/CliActionLauncher.swift +0 -0
  106. /package/{app → apps/mac}/Sources/AppShell/HomeDashboardView.swift +0 -0
  107. /package/{app → apps/mac}/Sources/AppShell/KeyRecorderView.swift +0 -0
  108. /package/{app → apps/mac}/Sources/AppShell/MainWindow.swift +0 -0
  109. /package/{app → apps/mac}/Sources/Core/Actions/HotkeyManager.swift +0 -0
  110. /package/{app → apps/mac}/Sources/Core/Actions/IntentSchema.swift +0 -0
  111. /package/{app → apps/mac}/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -0
  112. /package/{app → apps/mac}/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -0
  113. /package/{app → apps/mac}/Sources/Core/Actions/Intents/FocusIntent.swift +0 -0
  114. /package/{app → apps/mac}/Sources/Core/Actions/Intents/HelpIntent.swift +0 -0
  115. /package/{app → apps/mac}/Sources/Core/Actions/Intents/KillIntent.swift +0 -0
  116. /package/{app → apps/mac}/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -0
  117. /package/{app → apps/mac}/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -0
  118. /package/{app → apps/mac}/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -0
  119. /package/{app → apps/mac}/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -0
  120. /package/{app → apps/mac}/Sources/Core/Actions/Intents/ScanIntent.swift +0 -0
  121. /package/{app → apps/mac}/Sources/Core/Actions/Intents/SearchIntent.swift +0 -0
  122. /package/{app → apps/mac}/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -0
  123. /package/{app → apps/mac}/Sources/Core/Actions/Intents/TileIntent.swift +0 -0
  124. /package/{app → apps/mac}/Sources/Core/Actions/PaletteCommand.swift +0 -0
  125. /package/{app → apps/mac}/Sources/Core/Actions/VoiceIntentResolver.swift +0 -0
  126. /package/{app → apps/mac}/Sources/Core/Companion/CompanionActivityLog.swift +0 -0
  127. /package/{app → apps/mac}/Sources/Core/Companion/CompanionKeyboardController.swift +0 -0
  128. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -0
  129. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -0
  130. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -0
  131. /package/{app → apps/mac}/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -0
  132. /package/{app → apps/mac}/Sources/Core/Companion/LatticesDeckHost.swift +0 -0
  133. /package/{app → apps/mac}/Sources/Core/Daemon/DaemonProtocol.swift +0 -0
  134. /package/{app → apps/mac}/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -0
  135. /package/{app → apps/mac}/Sources/Core/Desktop/AppTypeClassifier.swift +0 -0
  136. /package/{app → apps/mac}/Sources/Core/Desktop/DesktopModelTypes.swift +0 -0
  137. /package/{app → apps/mac}/Sources/Core/Desktop/InventoryManager.swift +0 -0
  138. /package/{app → apps/mac}/Sources/Core/Desktop/InventoryPath.swift +0 -0
  139. /package/{app → apps/mac}/Sources/Core/Desktop/MouseFinder.swift +0 -0
  140. /package/{app → apps/mac}/Sources/Core/Desktop/OcrStore.swift +0 -0
  141. /package/{app → apps/mac}/Sources/Core/Desktop/PlacementSpec.swift +0 -0
  142. /package/{app → apps/mac}/Sources/Core/Desktop/SessionWindowLocator.swift +0 -0
  143. /package/{app → apps/mac}/Sources/Core/Desktop/TilePickerView.swift +0 -0
  144. /package/{app → apps/mac}/Sources/Core/Desktop/WindowPreviewCard.swift +0 -0
  145. /package/{app → apps/mac}/Sources/Core/Desktop/WindowSelectionStore.swift +0 -0
  146. /package/{app → apps/mac}/Sources/Core/Input/KeyboardRemapConfig.swift +0 -0
  147. /package/{app → apps/mac}/Sources/Core/Input/MouseInputDeviceStore.swift +0 -0
  148. /package/{app → apps/mac}/Sources/Core/Input/MouseInputEventViewer.swift +0 -0
  149. /package/{app → apps/mac}/Sources/Core/Overlays/AppWindowShell.swift +0 -0
  150. /package/{app → apps/mac}/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -0
  151. /package/{app → apps/mac}/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -0
  152. /package/{app → apps/mac}/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -0
  153. /package/{app → apps/mac}/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -0
  154. /package/{app → apps/mac}/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -0
  155. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -0
  156. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -0
  157. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDController.swift +0 -0
  158. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -0
  159. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -0
  160. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -0
  161. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDState.swift +0 -0
  162. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -0
  163. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -0
  164. /package/{app → apps/mac}/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -0
  165. /package/{app → apps/mac}/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -0
  166. /package/{app → apps/mac}/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -0
  167. /package/{app → apps/mac}/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -0
  168. /package/{app → apps/mac}/Sources/Core/Overlays/OverlayPanelShell.swift +0 -0
  169. /package/{app → apps/mac}/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -0
  170. /package/{app → apps/mac}/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -0
  171. /package/{app → apps/mac}/Sources/Core/Pi/PiAuthPromptCard.swift +0 -0
  172. /package/{app → apps/mac}/Sources/Core/Pi/PiInstallCallout.swift +0 -0
  173. /package/{app → apps/mac}/Sources/Core/System/DiagnosticLog.swift +0 -0
  174. /package/{app → apps/mac}/Sources/Core/System/EventBus.swift +0 -0
  175. /package/{app → apps/mac}/Sources/Core/System/ProcessModel.swift +0 -0
  176. /package/{app → apps/mac}/Sources/Core/System/ProcessQuery.swift +0 -0
  177. /package/{app → apps/mac}/Sources/Core/System/SystemTelemetryMonitor.swift +0 -0
  178. /package/{app → apps/mac}/Sources/Core/Voice/AdvisorLearningStore.swift +0 -0
  179. /package/{app → apps/mac}/Sources/Core/Voice/AgentSession.swift +0 -0
  180. /package/{app → apps/mac}/Sources/Core/Voice/HandsOffSession.swift +0 -0
  181. /package/{app → apps/mac}/Sources/Core/Voice/VoiceChatView.swift +0 -0
  182. /package/{app → apps/mac}/Sources/Core/Voice/VoxClient.swift +0 -0
  183. /package/{app → apps/mac}/Sources/Core/Workspace/Project.swift +0 -0
  184. /package/{app → apps/mac}/Sources/Core/Workspace/ProjectScanner.swift +0 -0
  185. /package/{app → apps/mac}/Sources/Core/Workspace/SessionLayerStore.swift +0 -0
  186. /package/{app → apps/mac}/Sources/Core/Workspace/SessionManager.swift +0 -0
  187. /package/{app → apps/mac}/Sources/Core/Workspace/Terminal/Terminal.swift +0 -0
  188. /package/{app → apps/mac}/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -0
  189. /package/{app → apps/mac}/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -0
  190. /package/{app → apps/mac}/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -0
  191. /package/{app → apps/mac}/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -0
  192. /package/{app → apps/mac}/Sources/Core/Workspace/WorkspaceManager.swift +0 -0
  193. /package/{app → apps/mac}/Sources/UI/ActionRow.swift +0 -0
  194. /package/{app → apps/mac}/Sources/UI/OrphanRow.swift +0 -0
  195. /package/{app → apps/mac}/Sources/UI/ProjectRow.swift +0 -0
  196. /package/{app → apps/mac}/Sources/UI/TabGroupRow.swift +0 -0
  197. /package/{app → apps/mac}/Sources/UI/Theme.swift +0 -0
  198. /package/{app → apps/mac}/Tests/StageDragTests.swift +0 -0
  199. /package/{app → apps/mac}/Tests/StageJoinTests.swift +0 -0
  200. /package/{app → apps/mac}/Tests/StageManagerTests.swift +0 -0
  201. /package/{app → apps/mac}/Tests/StageTileTests.swift +0 -0
@@ -0,0 +1,2259 @@
1
+ import AppKit
2
+ import Combine
3
+ import CoreGraphics
4
+
5
+ private enum MouseGestureAccessory {
6
+ case mic
7
+ }
8
+
9
+ private enum MouseGestureOverlayStyle: Equatable {
10
+ case drawing
11
+ case thinLine
12
+ case thickLine
13
+ }
14
+
15
+ private enum MouseGestureVisualPhase: String {
16
+ case started
17
+ case updated
18
+ case recognized
19
+ case completed
20
+ }
21
+
22
+ /// Captured CGEvent fields safe to ferry across an async dispatch boundary.
23
+ /// CGEvent itself is reference-counted; the tap callback only borrows the
24
+ /// event for the duration of its return, so we copy what we need into a
25
+ /// value type before hopping to main.
26
+ private struct MouseEventSnapshot {
27
+ let location: CGPoint
28
+ let flags: CGEventFlags
29
+ let buttonNumber: Int64
30
+ }
31
+
32
+ private struct MouseGestureOverlayTheme {
33
+ let graphite: NSColor
34
+ let graphiteDark: NSColor
35
+ let accent: NSColor
36
+ let highlight: NSColor
37
+ let failure: NSColor
38
+
39
+ static let graffiti = MouseGestureOverlayTheme(
40
+ graphite: NSColor(calibratedRed: 0.66, green: 0.69, blue: 0.73, alpha: 1.0),
41
+ graphiteDark: NSColor(calibratedRed: 0.16, green: 0.17, blue: 0.19, alpha: 1.0),
42
+ accent: NSColor(calibratedRed: 0.34, green: 0.78, blue: 1.0, alpha: 1.0),
43
+ highlight: NSColor(calibratedRed: 0.82, green: 0.94, blue: 1.0, alpha: 1.0),
44
+ failure: NSColor(calibratedRed: 0.98, green: 0.52, blue: 0.42, alpha: 1.0)
45
+ )
46
+ }
47
+
48
+ final class MouseGestureController: ObservableObject {
49
+ static let shared = MouseGestureController()
50
+
51
+ /// Live state of the event-tap circuit breaker. SettingsView observes
52
+ /// this to surface "paused" / "disabled" status and a re-arm button.
53
+ @Published private(set) var breakerState: EventTapBreaker.State = .armed
54
+
55
+ private struct GestureOutcome {
56
+ let label: String
57
+ let success: Bool
58
+ let accessory: MouseGestureAccessory?
59
+ }
60
+
61
+ private final class GestureSession {
62
+ let buttonNumber: Int64
63
+ let startPoint: CGPoint
64
+ let overlay: MouseGestureOverlay
65
+ var currentPoint: CGPoint
66
+ var lockedDirection: MouseGestureDirection?
67
+ var pathPoints: [GesturePathPoint]
68
+ var visual: MouseShortcutVisualDefinition?
69
+
70
+ init(buttonNumber: Int64, startPoint: CGPoint, overlay: MouseGestureOverlay) {
71
+ self.buttonNumber = buttonNumber
72
+ self.startPoint = startPoint
73
+ self.overlay = overlay
74
+ self.currentPoint = startPoint
75
+ self.lockedDirection = nil
76
+ self.pathPoints = [
77
+ GesturePathPoint(point: startPoint, timestamp: Date().timeIntervalSinceReferenceDate)
78
+ ]
79
+ self.visual = nil
80
+ }
81
+
82
+ func recordPoint(_ point: CGPoint) {
83
+ if let last = pathPoints.last {
84
+ let dx = point.x - last.x
85
+ let dy = point.y - last.y
86
+ guard sqrt(dx * dx + dy * dy) >= 2 else { return }
87
+ }
88
+ pathPoints.append(GesturePathPoint(point: point, timestamp: Date().timeIntervalSinceReferenceDate))
89
+ }
90
+ }
91
+
92
+ private static let syntheticMarker: Int64 = 0x4C474D47
93
+
94
+ private var eventTap: CFMachPort?
95
+ private var runLoopSource: CFRunLoopSource?
96
+ private var session: GestureSession?
97
+ private var retainedOverlays: [ObjectIdentifier: MouseGestureOverlay] = [:]
98
+ private var staleSessionTimer: Timer?
99
+ private var subscriptions: Set<AnyCancellable> = []
100
+ private var installedObservers = false
101
+ private let shapeRecognizer = ShapeRecognizer()
102
+ private let breaker = EventTapBreaker(label: "MouseGesture")
103
+ private let budgetMeter = TapBudgetMeter(label: "MouseGesture")
104
+
105
+ private struct TapTrackingState {
106
+ let buttonNumber: Int64
107
+ let startPoint: CGPoint
108
+ let nativeClickPassthrough: Bool
109
+ let startedAt: CFAbsoluteTime
110
+ }
111
+
112
+ // Tap-thread-side mirror of "which button (if any) is currently being
113
+ // tracked as a gesture". The tap callback runs on EventTapThread; main
114
+ // owns the full GestureSession but the tap thread needs a fast,
115
+ // synchronous answer to "should I consume this drag/up event?". Lock
116
+ // protects cross-thread access (tap thread reads/writes; main writes
117
+ // via clearSession(clearTracking:) or processMouseDownConsume's bail path).
118
+ private let trackingLock = NSLock()
119
+ private var tapTrackingState: TapTrackingState?
120
+ private var lastTrackingStaleLogAt: CFAbsoluteTime = 0
121
+ private let maxTapThreadTrackingDuration: TimeInterval = 3.0
122
+
123
+ private func currentTrackingState() -> TapTrackingState? {
124
+ trackingLock.lock()
125
+ defer { trackingLock.unlock() }
126
+ let now = CFAbsoluteTimeGetCurrent()
127
+ if let state = tapTrackingState,
128
+ now - state.startedAt > maxTapThreadTrackingDuration {
129
+ let staleButton = state.buttonNumber
130
+ tapTrackingState = nil
131
+ if now - lastTrackingStaleLogAt > 1 {
132
+ lastTrackingStaleLogAt = now
133
+ DispatchQueue.main.async {
134
+ DiagnosticLog.shared.warn("MouseGesture: stale tap-side tracking cleared for button=\(staleButton)")
135
+ }
136
+ }
137
+ return nil
138
+ }
139
+ return tapTrackingState
140
+ }
141
+
142
+ private func currentTrackingButton() -> Int64? {
143
+ currentTrackingState()?.buttonNumber
144
+ }
145
+
146
+ private func setTrackingButton(
147
+ _ value: Int64?,
148
+ startPoint: CGPoint = .zero,
149
+ nativeClickPassthrough: Bool = false
150
+ ) {
151
+ trackingLock.lock()
152
+ if let value {
153
+ tapTrackingState = TapTrackingState(
154
+ buttonNumber: value,
155
+ startPoint: startPoint,
156
+ nativeClickPassthrough: nativeClickPassthrough,
157
+ startedAt: CFAbsoluteTimeGetCurrent()
158
+ )
159
+ } else {
160
+ tapTrackingState = nil
161
+ }
162
+ trackingLock.unlock()
163
+ }
164
+
165
+ private init() {
166
+ breaker.onStateChanged = { [weak self] newState in
167
+ self?.breakerState = newState
168
+ }
169
+ }
170
+
171
+ func start() {
172
+ installObserversIfNeeded()
173
+ refresh()
174
+ }
175
+
176
+ func stop() {
177
+ clearSession()
178
+ removeEventTap()
179
+ }
180
+
181
+ func resetForSystemInputBoundary(reason: String) {
182
+ dispatchPrecondition(condition: .onQueue(.main))
183
+ clearSession()
184
+ breaker.reset()
185
+ if let eventTap {
186
+ CGEvent.tapEnable(tap: eventTap, enable: true)
187
+ } else {
188
+ refresh()
189
+ }
190
+ DiagnosticLog.shared.warn("MouseGesture: reset for \(reason)")
191
+ }
192
+
193
+ static func resolveDirection(
194
+ delta: CGPoint,
195
+ threshold: CGFloat = 68,
196
+ axisBias: CGFloat = 1.2
197
+ ) -> MouseGestureDirection? {
198
+ let absX = abs(delta.x)
199
+ let absY = abs(delta.y)
200
+ guard max(absX, absY) >= threshold else { return nil }
201
+
202
+ if absX >= absY * axisBias {
203
+ return delta.x >= 0 ? .right : .left
204
+ }
205
+
206
+ if absY >= absX * axisBias {
207
+ return delta.y >= 0 ? .down : .up
208
+ }
209
+
210
+ return nil
211
+ }
212
+
213
+ private func installObserversIfNeeded() {
214
+ guard !installedObservers else { return }
215
+ installedObservers = true
216
+
217
+ Preferences.shared.$mouseGesturesEnabled
218
+ .receive(on: RunLoop.main)
219
+ .sink { [weak self] _ in self?.refresh() }
220
+ .store(in: &subscriptions)
221
+
222
+ PermissionChecker.shared.$accessibility
223
+ .receive(on: RunLoop.main)
224
+ .sink { [weak self] _ in self?.refresh() }
225
+ .store(in: &subscriptions)
226
+
227
+ MouseInputEventViewer.shared.$isCaptureActive
228
+ .receive(on: RunLoop.main)
229
+ .sink { [weak self] _ in self?.refresh() }
230
+ .store(in: &subscriptions)
231
+ }
232
+
233
+ private func refresh() {
234
+ let shouldCapture = MouseInputEventViewer.shared.isCaptureActive || Preferences.shared.mouseGesturesEnabled
235
+ guard shouldCapture, PermissionChecker.shared.accessibility else {
236
+ clearSession()
237
+ removeEventTap()
238
+ return
239
+ }
240
+
241
+ if eventTap == nil {
242
+ installEventTap()
243
+ } else if let eventTap {
244
+ CGEvent.tapEnable(tap: eventTap, enable: true)
245
+ }
246
+ }
247
+
248
+ /// Re-enable the tap after a breaker trip, clearing trip history.
249
+ /// Settings UI calls this when the user explicitly chooses to recover
250
+ /// from a `disabled` state.
251
+ func reArmAfterBreakerTrip() {
252
+ dispatchPrecondition(condition: .onQueue(.main))
253
+ breaker.reset()
254
+ if let tap = eventTap {
255
+ CGEvent.tapEnable(tap: tap, enable: true)
256
+ }
257
+ }
258
+
259
+ private func installEventTap() {
260
+ // Fresh install is a clean slate — drop any stale trip history so
261
+ // the new tap's first failure is judged on its own merits.
262
+ breaker.reset()
263
+
264
+ var mask = CGEventMask(0)
265
+ mask |= CGEventMask(1) << CGEventType.leftMouseDown.rawValue
266
+ mask |= CGEventMask(1) << CGEventType.leftMouseUp.rawValue
267
+ mask |= CGEventMask(1) << CGEventType.rightMouseDown.rawValue
268
+ mask |= CGEventMask(1) << CGEventType.rightMouseDragged.rawValue
269
+ mask |= CGEventMask(1) << CGEventType.rightMouseUp.rawValue
270
+ mask |= CGEventMask(1) << CGEventType.otherMouseDown.rawValue
271
+ mask |= CGEventMask(1) << CGEventType.otherMouseDragged.rawValue
272
+ mask |= CGEventMask(1) << CGEventType.otherMouseUp.rawValue
273
+
274
+ let tap = CGEvent.tapCreate(
275
+ tap: .cgSessionEventTap,
276
+ place: .headInsertEventTap,
277
+ options: .defaultTap,
278
+ eventsOfInterest: mask,
279
+ callback: Self.eventTapCallback,
280
+ userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
281
+ )
282
+
283
+ guard let tap else {
284
+ DiagnosticLog.shared.warn("MouseGesture: failed to install mouse shortcut event tap")
285
+ return
286
+ }
287
+
288
+ let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
289
+ eventTap = tap
290
+ runLoopSource = source
291
+
292
+ if let source {
293
+ EventTapThread.shared.add(source: source)
294
+ }
295
+ CGEvent.tapEnable(tap: tap, enable: true)
296
+ breaker.rearm = { [weak self] in
297
+ guard let self, let tap = self.eventTap else { return }
298
+ CGEvent.tapEnable(tap: tap, enable: true)
299
+ }
300
+ DiagnosticLog.shared.info("MouseGesture: mouse shortcut event tap installed")
301
+ }
302
+
303
+ private func removeEventTap() {
304
+ if let source = runLoopSource {
305
+ EventTapThread.shared.remove(source: source)
306
+ }
307
+ runLoopSource = nil
308
+ if let tap = eventTap {
309
+ CFMachPortInvalidate(tap)
310
+ }
311
+ eventTap = nil
312
+ }
313
+
314
+ private static let eventTapCallback: CGEventTapCallBack = { _, type, event, userInfo in
315
+ guard let userInfo else { return Unmanaged.passUnretained(event) }
316
+ let controller = Unmanaged<MouseGestureController>.fromOpaque(userInfo).takeUnretainedValue()
317
+ return controller.handleEvent(type: type, event: event)
318
+ }
319
+
320
+ private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
321
+ let started = CFAbsoluteTimeGetCurrent()
322
+ defer {
323
+ let elapsedMs = (CFAbsoluteTimeGetCurrent() - started) * 1000
324
+ budgetMeter.record(durationMs: elapsedMs)
325
+ }
326
+
327
+ if type == .tapDisabledByTimeout {
328
+ // OS killed the tap because a callback was too slow. Run through
329
+ // the breaker — it backs off in escalating cooldowns rather than
330
+ // re-enabling immediately and getting killed again.
331
+ breaker.recordTrip()
332
+ return Unmanaged.passUnretained(event)
333
+ }
334
+ if type == .tapDisabledByUserInput {
335
+ // User-driven disable (rare). Re-enable directly, no cooldown.
336
+ if let eventTap {
337
+ CGEvent.tapEnable(tap: eventTap, enable: true)
338
+ }
339
+ return Unmanaged.passUnretained(event)
340
+ }
341
+
342
+ if event.getIntegerValueField(.eventSourceUserData) == Self.syntheticMarker {
343
+ return Unmanaged.passUnretained(event)
344
+ }
345
+
346
+ if isEmergencyMouseReset(type: type, event: event) {
347
+ setTrackingButton(nil)
348
+ DispatchQueue.main.async { [weak self] in
349
+ self?.clearSession()
350
+ InputCaptureResetCenter.reset(reason: "Hyper mouse click")
351
+ }
352
+ return Unmanaged.passUnretained(event)
353
+ }
354
+
355
+ switch type {
356
+ case .leftMouseDown, .leftMouseUp:
357
+ return handlePassiveMouseButtonEvent(type: type, event: event)
358
+ case .rightMouseDown:
359
+ return handleMouseDown(event, buttonNumber: Int64(CGMouseButton.right.rawValue))
360
+ case .rightMouseDragged:
361
+ return handleMouseDragged(event, buttonNumber: Int64(CGMouseButton.right.rawValue))
362
+ case .rightMouseUp:
363
+ return handleMouseUp(event, buttonNumber: Int64(CGMouseButton.right.rawValue))
364
+ default:
365
+ break
366
+ }
367
+
368
+ let buttonNumber = event.getIntegerValueField(.mouseEventButtonNumber)
369
+ if buttonNumber < 2 {
370
+ return Unmanaged.passUnretained(event)
371
+ }
372
+
373
+ switch type {
374
+ case .otherMouseDown:
375
+ return handleMouseDown(event, buttonNumber: buttonNumber)
376
+ case .otherMouseDragged:
377
+ return handleMouseDragged(event, buttonNumber: buttonNumber)
378
+ case .otherMouseUp:
379
+ return handleMouseUp(event, buttonNumber: buttonNumber)
380
+ default:
381
+ return Unmanaged.passUnretained(event)
382
+ }
383
+ }
384
+
385
+ // MARK: - Tap-thread dispatch
386
+ //
387
+ // handle* methods run on EventTapThread. They compute the consume/pass
388
+ // verdict from cheap, thread-safe reads, capture the event into a
389
+ // MouseEventSnapshot, and hand the heavy work to main async — so a slow
390
+ // main thread never adds latency to mouse events at the head-insert tap.
391
+
392
+ private func handlePassiveMouseButtonEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
393
+ let snapshot = MouseEventSnapshot(
394
+ location: event.location,
395
+ flags: event.flags,
396
+ buttonNumber: event.getIntegerValueField(.mouseEventButtonNumber)
397
+ )
398
+ DispatchQueue.main.async { [weak self] in
399
+ self?.processPassiveMouseButton(type: type, snapshot: snapshot)
400
+ }
401
+ return Unmanaged.passUnretained(event)
402
+ }
403
+
404
+ private func isEmergencyMouseReset(type: CGEventType, event: CGEvent) -> Bool {
405
+ switch type {
406
+ case .leftMouseDown, .rightMouseDown, .otherMouseDown:
407
+ return event.flags.intersection(.latticesHyper) == .latticesHyper
408
+ default:
409
+ return false
410
+ }
411
+ }
412
+
413
+ private func processPassiveMouseButton(type: CGEventType, snapshot: MouseEventSnapshot) {
414
+ dispatchPrecondition(condition: .onQueue(.main))
415
+ guard MouseInputEventViewer.shared.isCaptureActive else { return }
416
+
417
+ let phase: String
418
+ switch type {
419
+ case .leftMouseDown, .rightMouseDown:
420
+ phase = "down"
421
+ case .leftMouseUp, .rightMouseUp:
422
+ phase = "up"
423
+ default:
424
+ return
425
+ }
426
+
427
+ recordObservedEvent(
428
+ phase: phase,
429
+ button: MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber)),
430
+ location: snapshot.location,
431
+ delta: .zero,
432
+ modifiers: snapshot.flags,
433
+ candidate: nil,
434
+ match: nil,
435
+ note: "pass-through primary button",
436
+ appInfo: currentAppInfo()
437
+ )
438
+ }
439
+
440
+ private func handleMouseDown(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
441
+ let snapshot = MouseEventSnapshot(
442
+ location: event.location,
443
+ flags: event.flags,
444
+ buttonNumber: buttonNumber
445
+ )
446
+ // NSScreen.screens reads are safe off-main; Preferences/Store snapshot
447
+ // reads are lock-protected (see MouseShortcutStore).
448
+ let button = MouseShortcutButton(rawButtonNumber: Int(buttonNumber))
449
+ let needsNativeClickCapture = MouseShortcutStore.shared.hasEnabledRule(button: button, kind: .click)
450
+ || MouseShortcutStore.shared.hasEnabledRule(button: button, kind: .shape)
451
+ let nativeClickPassthrough = buttonNumber >= 2 && !needsNativeClickCapture
452
+ let canRecognize = Preferences.shared.mouseGesturesEnabled
453
+ && MouseShortcutStore.shared.watchedButtonNumbers.contains(buttonNumber)
454
+ let onScreen = (screen(containing: snapshot.location) != nil)
455
+
456
+ if !onScreen {
457
+ DispatchQueue.main.async { [weak self] in
458
+ self?.processMouseDownPassthrough(snapshot: snapshot, reason: .offScreen)
459
+ }
460
+ return Unmanaged.passUnretained(event)
461
+ }
462
+ if !canRecognize {
463
+ DispatchQueue.main.async { [weak self] in
464
+ self?.processMouseDownPassthrough(snapshot: snapshot, reason: .notMapped)
465
+ }
466
+ return Unmanaged.passUnretained(event)
467
+ }
468
+
469
+ // Mark this button as actively tracked before the OS sees a follow-up
470
+ // drag/up — the tap thread reads this on subsequent events to decide
471
+ // whether to consume them.
472
+ setTrackingButton(
473
+ buttonNumber,
474
+ startPoint: snapshot.location,
475
+ nativeClickPassthrough: nativeClickPassthrough
476
+ )
477
+ DispatchQueue.main.async { [weak self] in
478
+ self?.processMouseDownConsume(
479
+ snapshot: snapshot,
480
+ nativeClickPassthrough: nativeClickPassthrough
481
+ )
482
+ }
483
+ return nativeClickPassthrough ? Unmanaged.passUnretained(event) : nil
484
+ }
485
+
486
+ private enum MouseDownPassthroughReason {
487
+ case offScreen
488
+ case notMapped
489
+ }
490
+
491
+ private func processMouseDownPassthrough(snapshot: MouseEventSnapshot, reason: MouseDownPassthroughReason) {
492
+ dispatchPrecondition(condition: .onQueue(.main))
493
+ let button = MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber))
494
+ let appInfo = currentAppInfo()
495
+ switch reason {
496
+ case .offScreen:
497
+ DiagnosticLog.shared.info("MouseGesture: ignored click at \(format(snapshot.location)) (off-screen)")
498
+ recordObservedEvent(
499
+ phase: "down",
500
+ button: button,
501
+ location: snapshot.location,
502
+ delta: .zero,
503
+ modifiers: snapshot.flags,
504
+ candidate: nil,
505
+ match: nil,
506
+ note: "off-screen",
507
+ appInfo: appInfo
508
+ )
509
+ clearSession()
510
+ case .notMapped:
511
+ recordObservedEvent(
512
+ phase: "down",
513
+ button: button,
514
+ location: snapshot.location,
515
+ delta: .zero,
516
+ modifiers: snapshot.flags,
517
+ candidate: nil,
518
+ match: nil,
519
+ note: "button not mapped",
520
+ appInfo: appInfo
521
+ )
522
+ }
523
+ }
524
+
525
+ private func processMouseDownConsume(snapshot: MouseEventSnapshot, nativeClickPassthrough: Bool) {
526
+ dispatchPrecondition(condition: .onQueue(.main))
527
+ MouseShortcutStore.shared.reloadIfNeeded()
528
+ let button = MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber))
529
+ let appInfo = currentAppInfo()
530
+
531
+ guard let screen = screen(containing: snapshot.location) else {
532
+ // Screens changed between tap-thread verdict and main; treat as
533
+ // off-screen and clear the tap-side tracking we eagerly set.
534
+ setTrackingButton(nil)
535
+ recordObservedEvent(
536
+ phase: "down",
537
+ button: button,
538
+ location: snapshot.location,
539
+ delta: .zero,
540
+ modifiers: snapshot.flags,
541
+ candidate: nil,
542
+ match: nil,
543
+ note: "off-screen (post-dispatch)",
544
+ appInfo: appInfo
545
+ )
546
+ clearSession()
547
+ return
548
+ }
549
+
550
+ clearSession(clearTracking: false)
551
+ let overlay = MouseGestureOverlay(screen: screen)
552
+ overlay.onDismiss = { [weak self, weak overlay] in
553
+ guard let self, let overlay else { return }
554
+ self.releaseOverlay(overlay)
555
+ }
556
+ let newSession = GestureSession(buttonNumber: snapshot.buttonNumber, startPoint: snapshot.location, overlay: overlay)
557
+ newSession.visual = MouseShortcutStore.shared.visualHint(for: button)
558
+ session = newSession
559
+ scheduleStaleSessionCleanup(for: newSession)
560
+ DiagnosticLog.shared.info("MouseGesture: began at \(format(snapshot.location)) button=\(snapshot.buttonNumber)")
561
+ recordObservedEvent(
562
+ phase: "down",
563
+ button: button,
564
+ location: snapshot.location,
565
+ delta: .zero,
566
+ modifiers: snapshot.flags,
567
+ candidate: nil,
568
+ match: nil,
569
+ note: nativeClickPassthrough ? "tracking; native click passes through" : "tracking",
570
+ appInfo: appInfo
571
+ )
572
+ }
573
+
574
+ private func handleMouseDragged(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
575
+ guard currentTrackingButton() == buttonNumber else {
576
+ return Unmanaged.passUnretained(event)
577
+ }
578
+ let snapshot = MouseEventSnapshot(
579
+ location: event.location,
580
+ flags: event.flags,
581
+ buttonNumber: buttonNumber
582
+ )
583
+ DispatchQueue.main.async { [weak self] in
584
+ self?.processMouseDragged(snapshot: snapshot)
585
+ }
586
+ return nil
587
+ }
588
+
589
+ private func processMouseDragged(snapshot: MouseEventSnapshot) {
590
+ dispatchPrecondition(condition: .onQueue(.main))
591
+ guard let session, session.buttonNumber == snapshot.buttonNumber else {
592
+ return
593
+ }
594
+ MouseShortcutStore.shared.reloadIfNeeded()
595
+
596
+ session.currentPoint = snapshot.location
597
+ session.recordPoint(snapshot.location)
598
+ scheduleStaleSessionCleanup(for: session)
599
+ let button = MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber))
600
+ let delta = CGPoint(
601
+ x: snapshot.location.x - session.startPoint.x,
602
+ y: snapshot.location.y - session.startPoint.y
603
+ )
604
+ let tuning = MouseShortcutStore.shared.tuning
605
+ let direction = Self.resolveDirection(delta: delta, threshold: tuning.dragThreshold, axisBias: tuning.axisBias)
606
+
607
+ if direction != session.lockedDirection {
608
+ session.lockedDirection = direction
609
+ if let direction {
610
+ let triggerEvent = MouseShortcutTriggerEvent(button: button, kind: .drag, direction: direction, device: nil)
611
+ let match = MouseShortcutStore.shared.match(for: triggerEvent)
612
+ DiagnosticLog.shared.info("MouseGesture: locked \(label(for: direction)) via \(triggerEvent.triggerName)")
613
+ recordObservedEvent(
614
+ phase: "drag",
615
+ button: button,
616
+ location: snapshot.location,
617
+ delta: delta,
618
+ modifiers: snapshot.flags,
619
+ candidate: triggerEvent.triggerName,
620
+ match: match,
621
+ note: match == nil ? "no rule" : "candidate",
622
+ appInfo: currentAppInfo()
623
+ )
624
+ }
625
+ }
626
+
627
+ if let direction {
628
+ let dominantDistance = max(abs(delta.x), abs(delta.y))
629
+ let previewProgress = previewProgress(
630
+ dominantDistance: dominantDistance,
631
+ threshold: tuning.dragThreshold
632
+ )
633
+ session.overlay.track(
634
+ origin: session.startPoint,
635
+ direction: direction,
636
+ label: nil,
637
+ style: overlayStyle(for: button),
638
+ visual: session.visual,
639
+ visualPhase: .updated,
640
+ shape: nil,
641
+ success: nil,
642
+ pathPoints: session.pathPoints,
643
+ progress: previewProgress
644
+ )
645
+ } else {
646
+ session.overlay.track(
647
+ origin: session.startPoint,
648
+ direction: nil,
649
+ label: nil,
650
+ style: overlayStyle(for: button),
651
+ visual: session.visual,
652
+ visualPhase: .updated,
653
+ shape: nil,
654
+ success: nil,
655
+ pathPoints: session.pathPoints,
656
+ progress: 0
657
+ )
658
+ }
659
+ }
660
+
661
+ private func handleMouseUp(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
662
+ let snapshot = MouseEventSnapshot(
663
+ location: event.location,
664
+ flags: event.flags,
665
+ buttonNumber: buttonNumber
666
+ )
667
+ guard let trackingState = currentTrackingState(),
668
+ trackingState.buttonNumber == buttonNumber else {
669
+ DispatchQueue.main.async { [weak self] in
670
+ self?.processMouseUpNoSession(snapshot: snapshot)
671
+ }
672
+ return Unmanaged.passUnretained(event)
673
+ }
674
+
675
+ if trackingState.nativeClickPassthrough {
676
+ let delta = CGPoint(
677
+ x: snapshot.location.x - trackingState.startPoint.x,
678
+ y: snapshot.location.y - trackingState.startPoint.y
679
+ )
680
+ let tuning = MouseShortcutStore.shared.tuning
681
+ let direction = Self.resolveDirection(delta: delta, threshold: tuning.dragThreshold, axisBias: tuning.axisBias)
682
+ if direction == nil {
683
+ setTrackingButton(nil)
684
+ DispatchQueue.main.async { [weak self] in
685
+ self?.processMouseUpNativeClickPassthrough(snapshot: snapshot)
686
+ }
687
+ return Unmanaged.passUnretained(event)
688
+ }
689
+ }
690
+
691
+ // Clear tap-side tracking so a subsequent drag/up for this button
692
+ // falls through.
693
+ setTrackingButton(nil)
694
+ DispatchQueue.main.async { [weak self] in
695
+ self?.processMouseUp(snapshot: snapshot)
696
+ }
697
+ return nil
698
+ }
699
+
700
+ private func processMouseUpNoSession(snapshot: MouseEventSnapshot) {
701
+ dispatchPrecondition(condition: .onQueue(.main))
702
+ recordObservedEvent(
703
+ phase: "up",
704
+ button: MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber)),
705
+ location: snapshot.location,
706
+ delta: .zero,
707
+ modifiers: snapshot.flags,
708
+ candidate: nil,
709
+ match: nil,
710
+ note: "no active session",
711
+ appInfo: currentAppInfo()
712
+ )
713
+ }
714
+
715
+ private func processMouseUpNativeClickPassthrough(snapshot: MouseEventSnapshot) {
716
+ dispatchPrecondition(condition: .onQueue(.main))
717
+ guard let activeSession = session,
718
+ activeSession.buttonNumber == snapshot.buttonNumber else {
719
+ recordObservedEvent(
720
+ phase: "up",
721
+ button: MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber)),
722
+ location: snapshot.location,
723
+ delta: .zero,
724
+ modifiers: snapshot.flags,
725
+ candidate: nil,
726
+ match: nil,
727
+ note: "native click passthrough",
728
+ appInfo: currentAppInfo()
729
+ )
730
+ return
731
+ }
732
+
733
+ let delta = CGPoint(
734
+ x: snapshot.location.x - activeSession.startPoint.x,
735
+ y: snapshot.location.y - activeSession.startPoint.y
736
+ )
737
+ recordObservedEvent(
738
+ phase: "up",
739
+ button: MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber)),
740
+ location: snapshot.location,
741
+ delta: delta,
742
+ modifiers: snapshot.flags,
743
+ candidate: nil,
744
+ match: nil,
745
+ note: "native click passthrough",
746
+ appInfo: currentAppInfo()
747
+ )
748
+ clearSession()
749
+ }
750
+
751
+ private func processMouseUp(snapshot: MouseEventSnapshot) {
752
+ dispatchPrecondition(condition: .onQueue(.main))
753
+ MouseShortcutStore.shared.reloadIfNeeded()
754
+ let button = MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber))
755
+ let appInfo = currentAppInfo()
756
+
757
+ guard let session, session.buttonNumber == snapshot.buttonNumber else {
758
+ // Session was cleared between the tap-thread dispatch and now.
759
+ return
760
+ }
761
+ session.currentPoint = snapshot.location
762
+ session.recordPoint(snapshot.location)
763
+
764
+ let delta = CGPoint(
765
+ x: snapshot.location.x - session.startPoint.x,
766
+ y: snapshot.location.y - session.startPoint.y
767
+ )
768
+ let tuning = MouseShortcutStore.shared.tuning
769
+ let direction = Self.resolveDirection(delta: delta, threshold: tuning.dragThreshold, axisBias: tuning.axisBias)
770
+ self.session = nil
771
+ staleSessionTimer?.invalidate()
772
+ staleSessionTimer = nil
773
+
774
+ let shapeResult = shapeRecognizer.recognize(points: session.pathPoints)
775
+ if let shape = shapeResult.shape {
776
+ let shapeTrigger = MouseShortcutTriggerEvent(button: button, kind: .shape, shape: shape)
777
+ let shapeMatch = MouseShortcutStore.shared.match(for: shapeTrigger)
778
+ if let shapeMatch {
779
+ let commitDirection = shapeResult.segments.last?.direction ?? direction ?? .right
780
+ let dismissBeforeAction = shouldDismissOverlayBeforeAction(match: shapeMatch)
781
+ if dismissBeforeAction {
782
+ session.overlay.dismiss(immediately: true)
783
+ }
784
+
785
+ DispatchQueue.main.async { [weak self] in
786
+ guard let self else { return }
787
+ if !dismissBeforeAction {
788
+ self.retainOverlay(session.overlay)
789
+ }
790
+ let outcome = self.performAction(match: shapeMatch, startPoint: session.startPoint)
791
+ DiagnosticLog.shared.info("MouseGesture: \(shape.displayName) -> \(outcome.label) -> \(outcome.success ? "ok" : "blocked")")
792
+ self.recordObservedEvent(
793
+ phase: "up",
794
+ button: button,
795
+ location: snapshot.location,
796
+ delta: delta,
797
+ modifiers: snapshot.flags,
798
+ candidate: shapeTrigger.triggerName,
799
+ match: shapeMatch,
800
+ note: "shape fired confidence=\(String(format: "%.2f", Double(shapeResult.confidence)))",
801
+ appInfo: appInfo
802
+ )
803
+ if !dismissBeforeAction {
804
+ session.overlay.commit(
805
+ origin: session.startPoint,
806
+ direction: commitDirection,
807
+ label: outcome.label,
808
+ success: outcome.success,
809
+ style: self.overlayStyle(for: button),
810
+ visual: shapeMatch.rule.visual ?? session.visual,
811
+ visualPhase: .completed,
812
+ shape: shape,
813
+ pathPoints: session.pathPoints,
814
+ accessory: outcome.accessory
815
+ )
816
+ }
817
+ }
818
+ return
819
+ }
820
+ }
821
+
822
+ guard let direction else {
823
+ let clickTrigger = MouseShortcutTriggerEvent(button: button, kind: .click, direction: nil, device: nil)
824
+ let clickMatch = MouseShortcutStore.shared.match(for: clickTrigger)
825
+ DiagnosticLog.shared.info("MouseGesture: released without a gesture at \(format(snapshot.location))")
826
+ recordObservedEvent(
827
+ phase: "up",
828
+ button: button,
829
+ location: snapshot.location,
830
+ delta: delta,
831
+ modifiers: snapshot.flags,
832
+ candidate: clickMatch != nil ? clickTrigger.triggerName : nil,
833
+ match: clickMatch,
834
+ note: clickMatch != nil ? "click action" : "replay click",
835
+ appInfo: appInfo
836
+ )
837
+ if let clickMatch {
838
+ DispatchQueue.main.async { [weak self] in
839
+ guard let self else { return }
840
+ session.overlay.dismiss(immediately: true)
841
+ let outcome = self.performAction(match: clickMatch, startPoint: session.startPoint)
842
+ DiagnosticLog.shared.info("MouseGesture: \(outcome.label) -> \(outcome.success ? "ok" : "blocked")")
843
+ }
844
+ } else {
845
+ DispatchQueue.main.async { [weak self] in
846
+ session.overlay.dismiss()
847
+ self?.replayMouseClick(
848
+ buttonNumber: snapshot.buttonNumber,
849
+ at: session.startPoint,
850
+ flags: snapshot.flags
851
+ )
852
+ }
853
+ }
854
+ return
855
+ }
856
+
857
+ let triggerEvent = MouseShortcutTriggerEvent(button: button, kind: .drag, direction: direction, device: nil)
858
+ let match = MouseShortcutStore.shared.match(for: triggerEvent)
859
+ let dismissBeforeAction = shouldDismissOverlayBeforeAction(match: match)
860
+ if dismissBeforeAction {
861
+ session.overlay.dismiss(immediately: true)
862
+ }
863
+
864
+ DispatchQueue.main.async { [weak self] in
865
+ guard let self else { return }
866
+ if !dismissBeforeAction {
867
+ self.retainOverlay(session.overlay)
868
+ }
869
+ let outcome = self.performAction(match: match, startPoint: session.startPoint)
870
+ DiagnosticLog.shared.info("MouseGesture: \(outcome.label) -> \(outcome.success ? "ok" : "blocked")")
871
+ self.recordObservedEvent(
872
+ phase: "up",
873
+ button: button,
874
+ location: snapshot.location,
875
+ delta: delta,
876
+ modifiers: snapshot.flags,
877
+ candidate: triggerEvent.triggerName,
878
+ match: match,
879
+ note: outcome.success ? "fired" : "blocked",
880
+ appInfo: appInfo
881
+ )
882
+ if !dismissBeforeAction {
883
+ session.overlay.commit(
884
+ origin: session.startPoint,
885
+ direction: direction,
886
+ label: outcome.label,
887
+ success: outcome.success,
888
+ style: self.overlayStyle(for: button),
889
+ visual: match?.rule.visual ?? session.visual,
890
+ visualPhase: .completed,
891
+ shape: nil,
892
+ pathPoints: session.pathPoints,
893
+ accessory: outcome.accessory
894
+ )
895
+ }
896
+ }
897
+ }
898
+
899
+ private func performAction(match: MouseShortcutMatchResult?, startPoint: CGPoint) -> GestureOutcome {
900
+ guard let match else {
901
+ return GestureOutcome(label: "No Shortcut Assigned", success: false, accessory: nil)
902
+ }
903
+
904
+ switch match.action.type {
905
+ case .spacePrevious:
906
+ guard WindowTiler.adjacentSpaceTarget(offset: -1, from: startPoint) != nil else {
907
+ return GestureOutcome(label: "No Previous Space", success: false, accessory: nil)
908
+ }
909
+ let switched = WindowTiler.switchToAdjacentSpace(offset: -1, from: startPoint)
910
+ return GestureOutcome(label: switched ? "Previous Space" : "Previous Space Blocked", success: switched, accessory: nil)
911
+ case .spaceNext:
912
+ guard WindowTiler.adjacentSpaceTarget(offset: 1, from: startPoint) != nil else {
913
+ return GestureOutcome(label: "No Next Space", success: false, accessory: nil)
914
+ }
915
+ let switched = WindowTiler.switchToAdjacentSpace(offset: 1, from: startPoint)
916
+ return GestureOutcome(label: switched ? "Next Space" : "Next Space Blocked", success: switched, accessory: nil)
917
+ case .screenMapToggle:
918
+ ScreenMapWindowController.shared.showScreenMapOverview()
919
+ return GestureOutcome(label: "Screen Map Overview", success: true, accessory: nil)
920
+ case .dictationStart:
921
+ let sent = sendDictationShortcut()
922
+ return GestureOutcome(
923
+ label: sent ? "Dictation" : "Permission Needed",
924
+ success: sent,
925
+ accessory: sent ? .mic : nil
926
+ )
927
+ case .shortcutSend:
928
+ let sent = sendShortcut(match.action.shortcut)
929
+ return GestureOutcome(
930
+ label: sent ? match.action.label : "Shortcut Blocked",
931
+ success: sent,
932
+ accessory: nil
933
+ )
934
+ case .appActivate:
935
+ let activated = activateApplication(named: match.action.app)
936
+ let appLabel = match.action.app?.trimmingCharacters(in: .whitespacesAndNewlines)
937
+ return GestureOutcome(
938
+ label: activated ? "\(appLabel?.isEmpty == false ? appLabel! : "App") Focused" : "App Activation Blocked",
939
+ success: activated,
940
+ accessory: nil
941
+ )
942
+ }
943
+ }
944
+
945
+ private func label(for direction: MouseGestureDirection) -> String {
946
+ switch direction {
947
+ case .left:
948
+ return "Previous Space"
949
+ case .right:
950
+ return "Next Space"
951
+ case .up:
952
+ return "Up"
953
+ case .down:
954
+ return "Screen Map Overview"
955
+ }
956
+ }
957
+
958
+ private func overlayStyle(for button: MouseShortcutButton) -> MouseGestureOverlayStyle {
959
+ switch button {
960
+ case .button4:
961
+ return .thinLine
962
+ case .button5:
963
+ return .thickLine
964
+ case .middle:
965
+ return .drawing
966
+ case .right, .number:
967
+ return .drawing
968
+ }
969
+ }
970
+
971
+ private func clearSession(clearTracking: Bool = true) {
972
+ staleSessionTimer?.invalidate()
973
+ staleSessionTimer = nil
974
+ session?.overlay.dismiss(immediately: true)
975
+ session = nil
976
+ // Keep tap-side tracking in sync with main-side session lifetime so a
977
+ // subsequent drag/up isn't consumed for a session that no longer
978
+ // exists.
979
+ if clearTracking {
980
+ setTrackingButton(nil)
981
+ }
982
+ }
983
+
984
+ private func replayMouseClick(buttonNumber: Int64, at point: CGPoint, flags: CGEventFlags) {
985
+ let events: [CGEventType]
986
+ if buttonNumber == Int64(CGMouseButton.right.rawValue) {
987
+ events = [.rightMouseDown, .rightMouseUp]
988
+ } else {
989
+ events = [.otherMouseDown, .otherMouseUp]
990
+ }
991
+ for type in events {
992
+ guard let mouseButton = CGMouseButton(rawValue: UInt32(buttonNumber)) else { continue }
993
+ guard let event = CGEvent(
994
+ mouseEventSource: nil,
995
+ mouseType: type,
996
+ mouseCursorPosition: point,
997
+ mouseButton: mouseButton
998
+ ) else { continue }
999
+
1000
+ event.setIntegerValueField(CGEventField.mouseEventButtonNumber, value: buttonNumber)
1001
+ event.setIntegerValueField(CGEventField.eventSourceUserData, value: Self.syntheticMarker)
1002
+ event.flags = flags
1003
+ event.post(tap: CGEventTapLocation.cghidEventTap)
1004
+ }
1005
+ }
1006
+
1007
+ private func screen(containing cgPoint: CGPoint) -> NSScreen? {
1008
+ let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
1009
+ let nsPoint = NSPoint(x: cgPoint.x, y: primaryHeight - cgPoint.y)
1010
+ return NSScreen.screens.first(where: { $0.frame.contains(nsPoint) }) ?? NSScreen.main ?? NSScreen.screens.first
1011
+ }
1012
+
1013
+ private func format(_ point: CGPoint) -> String {
1014
+ "\(Int(point.x)),\(Int(point.y))"
1015
+ }
1016
+
1017
+ private func shouldDismissOverlayBeforeAction(match _: MouseShortcutMatchResult?) -> Bool {
1018
+ true
1019
+ }
1020
+
1021
+ private func previewProgress(dominantDistance: CGFloat, threshold: CGFloat) -> CGFloat {
1022
+ guard dominantDistance > threshold else { return 0 }
1023
+ let overshoot = dominantDistance - threshold
1024
+ let normalized = min(1, max(0, overshoot / 90))
1025
+ return 0.32 + normalized * 0.68
1026
+ }
1027
+
1028
+ private func retainOverlay(_ overlay: MouseGestureOverlay) {
1029
+ retainedOverlays[ObjectIdentifier(overlay)] = overlay
1030
+ }
1031
+
1032
+ private func releaseOverlay(_ overlay: MouseGestureOverlay) {
1033
+ retainedOverlays.removeValue(forKey: ObjectIdentifier(overlay))
1034
+ }
1035
+
1036
+ private func scheduleStaleSessionCleanup(for session: GestureSession) {
1037
+ staleSessionTimer?.invalidate()
1038
+ let timer = Timer(timeInterval: 3.0, repeats: false) { [weak self, weak session] _ in
1039
+ guard let self, let session, self.session === session else { return }
1040
+ DiagnosticLog.shared.warn("MouseGesture: stale gesture session dismissed")
1041
+ self.clearSession()
1042
+ }
1043
+ staleSessionTimer = timer
1044
+ RunLoop.main.add(timer, forMode: .common)
1045
+ }
1046
+
1047
+ private func currentAppInfo() -> (name: String?, bundleId: String?) {
1048
+ let app = NSWorkspace.shared.frontmostApplication
1049
+ return (app?.localizedName, app?.bundleIdentifier)
1050
+ }
1051
+
1052
+ private func recordObservedEvent(
1053
+ phase: String,
1054
+ button: MouseShortcutButton,
1055
+ location: CGPoint,
1056
+ delta: CGPoint,
1057
+ modifiers: CGEventFlags,
1058
+ candidate: String?,
1059
+ match: MouseShortcutMatchResult?,
1060
+ note: String?,
1061
+ appInfo: (name: String?, bundleId: String?)
1062
+ ) {
1063
+ guard MouseInputEventViewer.shared.isCaptureActive else { return }
1064
+ let sourceState = Int(modifiers.rawValue)
1065
+ MouseInputEventViewer.shared.record(
1066
+ MouseShortcutObservedEvent(
1067
+ timestamp: Date(),
1068
+ phase: phase,
1069
+ buttonNumber: button.rawButtonNumber,
1070
+ location: location,
1071
+ delta: delta,
1072
+ modifiers: NSEvent.ModifierFlags(rawValue: UInt(modifiers.rawValue)),
1073
+ frontmostAppName: appInfo.name,
1074
+ frontmostBundleId: appInfo.bundleId,
1075
+ candidateTrigger: candidate,
1076
+ device: nil,
1077
+ matchedRuleSummary: match?.rule.summary,
1078
+ willFire: match != nil,
1079
+ note: note.map { "\($0) | flags=\(sourceState)" } ?? "flags=\(sourceState)"
1080
+ )
1081
+ )
1082
+ }
1083
+
1084
+ private func sendShortcut(_ shortcut: MouseShortcutKeyStroke?) -> Bool {
1085
+ guard let shortcut else { return false }
1086
+ if sendShortcutWithCGEvent(shortcut) {
1087
+ return true
1088
+ }
1089
+
1090
+ let modifiers = shortcut.modifiers.map(\.appleScriptToken).joined(separator: ", ")
1091
+ let command: String
1092
+
1093
+ if let keyCode = shortcut.keyCode {
1094
+ command = modifiers.isEmpty
1095
+ ? "key code \(keyCode)"
1096
+ : "key code \(keyCode) using {\(modifiers)}"
1097
+ } else if let key = shortcut.key {
1098
+ command = modifiers.isEmpty
1099
+ ? "keystroke \"\(key)\""
1100
+ : "keystroke \"\(key)\" using {\(modifiers)}"
1101
+ } else {
1102
+ return false
1103
+ }
1104
+
1105
+ let script = """
1106
+ tell application "System Events"
1107
+ \(command)
1108
+ end tell
1109
+ return "ok"
1110
+ """
1111
+ let result = ProcessQuery.shell(["/usr/bin/osascript", "-e", script])
1112
+ if result != "ok" {
1113
+ DiagnosticLog.shared.warn("MouseGesture: AppleScript shortcut send failed for \(shortcut.displayLabel)")
1114
+ }
1115
+ return result == "ok"
1116
+ }
1117
+
1118
+ private func sendDictationShortcut() -> Bool {
1119
+ sendShortcut(
1120
+ MouseShortcutKeyStroke(
1121
+ key: "a",
1122
+ keyCode: 0,
1123
+ modifiers: [.command, .shift]
1124
+ )
1125
+ )
1126
+ }
1127
+
1128
+ private func sendShortcutWithCGEvent(_ shortcut: MouseShortcutKeyStroke) -> Bool {
1129
+ guard let keyCode = shortcut.keyCode.map(CGKeyCode.init) ?? keyCode(for: shortcut.key) else {
1130
+ return false
1131
+ }
1132
+ guard let source = CGEventSource(stateID: .combinedSessionState),
1133
+ let down = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
1134
+ let up = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) else {
1135
+ DiagnosticLog.shared.warn("MouseGesture: CGEvent shortcut source unavailable for \(shortcut.displayLabel)")
1136
+ return false
1137
+ }
1138
+
1139
+ let flags = cgEventFlags(for: shortcut.modifiers)
1140
+ down.flags = flags
1141
+ up.flags = flags
1142
+ down.setIntegerValueField(.eventSourceUserData, value: Self.syntheticMarker)
1143
+ up.setIntegerValueField(.eventSourceUserData, value: Self.syntheticMarker)
1144
+ down.post(tap: .cghidEventTap)
1145
+ usleep(12_000)
1146
+ up.post(tap: .cghidEventTap)
1147
+ return true
1148
+ }
1149
+
1150
+ private func cgEventFlags(for modifiers: [MouseShortcutModifier]) -> CGEventFlags {
1151
+ var flags: CGEventFlags = []
1152
+ for modifier in modifiers {
1153
+ switch modifier {
1154
+ case .command:
1155
+ flags.insert(.maskCommand)
1156
+ case .option:
1157
+ flags.insert(.maskAlternate)
1158
+ case .control:
1159
+ flags.insert(.maskControl)
1160
+ case .shift:
1161
+ flags.insert(.maskShift)
1162
+ }
1163
+ }
1164
+ return flags
1165
+ }
1166
+
1167
+ private func keyCode(for key: String?) -> CGKeyCode? {
1168
+ guard let key = key?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
1169
+ !key.isEmpty else {
1170
+ return nil
1171
+ }
1172
+ let codes: [String: CGKeyCode] = [
1173
+ "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7,
1174
+ "c": 8, "v": 9, "b": 11, "q": 12, "w": 13, "e": 14, "r": 15,
1175
+ "y": 16, "t": 17, "1": 18, "2": 19, "3": 20, "4": 21, "6": 22,
1176
+ "5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29,
1177
+ "]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "enter": 36,
1178
+ "return": 36, "l": 37, "j": 38, "'": 39, "k": 40, ";": 41,
1179
+ "\\": 42, ",": 43, "/": 44, "n": 45, "m": 46, ".": 47, "tab": 48,
1180
+ "space": 49, "`": 50, "delete": 51, "backspace": 51, "escape": 53,
1181
+ "esc": 53, "left": 123, "right": 124, "down": 125, "up": 126,
1182
+ ]
1183
+ return codes[key]
1184
+ }
1185
+
1186
+ private func activateApplication(named appName: String?) -> Bool {
1187
+ guard let appName, !appName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
1188
+ return false
1189
+ }
1190
+
1191
+ if let running = NSWorkspace.shared.runningApplications.first(where: { app in
1192
+ app.localizedName?.localizedCaseInsensitiveCompare(appName) == .orderedSame
1193
+ || app.bundleIdentifier?.localizedCaseInsensitiveContains(appName) == true
1194
+ }) {
1195
+ return running.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
1196
+ }
1197
+
1198
+ let fileManager = FileManager.default
1199
+ let trimmedName = appName.trimmingCharacters(in: .whitespacesAndNewlines)
1200
+ let appFilenames = trimmedName.hasSuffix(".app") ? [trimmedName] : [trimmedName + ".app", trimmedName]
1201
+ let roots = [
1202
+ "/Applications",
1203
+ "/System/Applications",
1204
+ fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Applications").path,
1205
+ ]
1206
+
1207
+ for root in roots {
1208
+ for filename in appFilenames {
1209
+ let url = URL(fileURLWithPath: root).appendingPathComponent(filename)
1210
+ guard fileManager.fileExists(atPath: url.path) else { continue }
1211
+ NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration())
1212
+ return true
1213
+ }
1214
+ }
1215
+
1216
+ _ = ProcessQuery.shell(["/usr/bin/open", "-a", trimmedName])
1217
+ return true
1218
+ }
1219
+ }
1220
+
1221
+ private final class MouseGestureOverlay {
1222
+ private let committedHoldDuration: TimeInterval = 0.0
1223
+ private let fadeDuration: TimeInterval = 0.03
1224
+ private let accessoryCommittedHoldDuration: TimeInterval = 0.0
1225
+ private let accessoryAnimationDuration: TimeInterval = 0.10
1226
+
1227
+ private let screen: NSScreen
1228
+ private let window: NSWindow
1229
+ private let overlayView: MouseGestureOverlayView
1230
+ private var fadeTimer: Timer?
1231
+ var onDismiss: (() -> Void)?
1232
+
1233
+ init(screen: NSScreen) {
1234
+ self.screen = screen
1235
+ self.window = NSWindow(
1236
+ contentRect: screen.frame,
1237
+ styleMask: .borderless,
1238
+ backing: .buffered,
1239
+ defer: false
1240
+ )
1241
+ self.overlayView = MouseGestureOverlayView(frame: NSRect(origin: .zero, size: screen.frame.size))
1242
+
1243
+ window.isOpaque = false
1244
+ window.backgroundColor = .clear
1245
+ window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
1246
+ window.hasShadow = false
1247
+ window.ignoresMouseEvents = true
1248
+ window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
1249
+ window.contentView = overlayView
1250
+ window.orderFrontRegardless()
1251
+ }
1252
+
1253
+ func track(
1254
+ origin: CGPoint,
1255
+ direction: MouseGestureDirection?,
1256
+ label: String?,
1257
+ style: MouseGestureOverlayStyle,
1258
+ visual: MouseShortcutVisualDefinition?,
1259
+ visualPhase: MouseGestureVisualPhase,
1260
+ shape: GestureShapeLabel?,
1261
+ success: Bool?,
1262
+ pathPoints: [GesturePathPoint],
1263
+ progress: CGFloat
1264
+ ) {
1265
+ fadeTimer?.invalidate()
1266
+ window.alphaValue = 1
1267
+ let localPath = localPath(from: pathPoints)
1268
+ if direction != nil || localPath.count > 1 {
1269
+ overlayView.state = .tracking(
1270
+ origin: localPoint(from: origin),
1271
+ direction: direction,
1272
+ label: label,
1273
+ style: style,
1274
+ visual: visual,
1275
+ visualPhase: visualPhase,
1276
+ shape: shape,
1277
+ success: success,
1278
+ path: localPath,
1279
+ progress: progress
1280
+ )
1281
+ } else {
1282
+ overlayView.state = .idle
1283
+ }
1284
+ overlayView.needsDisplay = true
1285
+ }
1286
+
1287
+ func commit(
1288
+ origin: CGPoint,
1289
+ direction: MouseGestureDirection,
1290
+ label: String,
1291
+ success: Bool,
1292
+ style: MouseGestureOverlayStyle,
1293
+ visual: MouseShortcutVisualDefinition?,
1294
+ visualPhase: MouseGestureVisualPhase,
1295
+ shape: GestureShapeLabel?,
1296
+ pathPoints: [GesturePathPoint],
1297
+ accessory: MouseGestureAccessory?
1298
+ ) {
1299
+ fadeTimer?.invalidate()
1300
+ window.alphaValue = 1
1301
+ overlayView.state = .committed(
1302
+ origin: localPoint(from: origin),
1303
+ direction: direction,
1304
+ label: label,
1305
+ success: success,
1306
+ style: style,
1307
+ visual: visual,
1308
+ visualPhase: visualPhase,
1309
+ shape: shape,
1310
+ path: localPath(from: pathPoints),
1311
+ accessory: accessory,
1312
+ accessoryAnimationDuration: accessoryAnimationDuration
1313
+ )
1314
+ overlayView.needsDisplay = true
1315
+
1316
+ let postReplayHoldDuration = accessory == nil ? committedHoldDuration : accessoryCommittedHoldDuration
1317
+ let totalVisibleDuration = overlayView.replayLeadInDuration + postReplayHoldDuration
1318
+ let timer = Timer(timeInterval: totalVisibleDuration, repeats: false) { [weak self] _ in
1319
+ self?.dismiss()
1320
+ }
1321
+ fadeTimer = timer
1322
+ RunLoop.main.add(timer, forMode: .common)
1323
+ }
1324
+
1325
+ func dismiss(immediately: Bool = false) {
1326
+ fadeTimer?.invalidate()
1327
+ fadeTimer = nil
1328
+
1329
+ if immediately {
1330
+ window.orderOut(nil)
1331
+ finishDismissal()
1332
+ return
1333
+ }
1334
+
1335
+ NSAnimationContext.runAnimationGroup({ ctx in
1336
+ ctx.duration = fadeDuration
1337
+ window.animator().alphaValue = 0
1338
+ }, completionHandler: { [weak self] in
1339
+ self?.window.orderOut(nil)
1340
+ self?.finishDismissal()
1341
+ })
1342
+ }
1343
+
1344
+ private func localPoint(from cgPoint: CGPoint) -> CGPoint {
1345
+ let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height
1346
+ let nsY = primaryHeight - cgPoint.y
1347
+ return CGPoint(x: cgPoint.x - screen.frame.minX, y: nsY - screen.frame.minY)
1348
+ }
1349
+
1350
+ private func localPath(from pathPoints: [GesturePathPoint]) -> [CGPoint] {
1351
+ pathPoints.map { localPoint(from: $0.cgPoint) }
1352
+ }
1353
+
1354
+ private func finishDismissal() {
1355
+ let callback = onDismiss
1356
+ onDismiss = nil
1357
+ callback?()
1358
+ }
1359
+ }
1360
+
1361
+ private final class MouseGestureOverlayView: NSView {
1362
+ private let theme = MouseGestureOverlayTheme.graffiti
1363
+
1364
+ enum State {
1365
+ case idle
1366
+ case tracking(
1367
+ origin: CGPoint,
1368
+ direction: MouseGestureDirection?,
1369
+ label: String?,
1370
+ style: MouseGestureOverlayStyle,
1371
+ visual: MouseShortcutVisualDefinition?,
1372
+ visualPhase: MouseGestureVisualPhase,
1373
+ shape: GestureShapeLabel?,
1374
+ success: Bool?,
1375
+ path: [CGPoint],
1376
+ progress: CGFloat
1377
+ )
1378
+ case committed(
1379
+ origin: CGPoint,
1380
+ direction: MouseGestureDirection,
1381
+ label: String,
1382
+ success: Bool,
1383
+ style: MouseGestureOverlayStyle,
1384
+ visual: MouseShortcutVisualDefinition?,
1385
+ visualPhase: MouseGestureVisualPhase,
1386
+ shape: GestureShapeLabel?,
1387
+ path: [CGPoint],
1388
+ accessory: MouseGestureAccessory?,
1389
+ accessoryAnimationDuration: TimeInterval
1390
+ )
1391
+ }
1392
+
1393
+ var state: State = .idle {
1394
+ didSet {
1395
+ updateArrowAnimation(from: oldValue, to: state)
1396
+ updateAccessoryAnimation()
1397
+ }
1398
+ }
1399
+ private var accessoryAnimationTimer: Timer?
1400
+ private var accessoryAnimationStartedAt: Date?
1401
+ private var accessoryAnimationDuration: TimeInterval = 0
1402
+ private var arrowAnimationTimer: Timer?
1403
+ private var arrowAnimationStartedAt: Date?
1404
+ private var arrowAnimationDuration: TimeInterval = 0
1405
+ private var committedStartProgress: CGFloat = 0
1406
+ private var accessoryAnimationDelay: TimeInterval = 0
1407
+ private let committedArrowAnimationDuration: TimeInterval = 0.06
1408
+ private let arrowAnimationDelay: TimeInterval = 0.012
1409
+ private let labelRevealThreshold: CGFloat = 0.8
1410
+
1411
+ var replayLeadInDuration: TimeInterval {
1412
+ if committedStartProgress >= labelRevealThreshold {
1413
+ return 0
1414
+ }
1415
+ let remainingProgress = max(0, 1 - committedStartProgress)
1416
+ return arrowAnimationDelay + committedArrowAnimationDuration * remainingProgress
1417
+ }
1418
+
1419
+ override var isFlipped: Bool {
1420
+ false
1421
+ }
1422
+
1423
+ override func viewDidMoveToWindow() {
1424
+ super.viewDidMoveToWindow()
1425
+ if window == nil {
1426
+ arrowAnimationTimer?.invalidate()
1427
+ arrowAnimationTimer = nil
1428
+ accessoryAnimationTimer?.invalidate()
1429
+ accessoryAnimationTimer = nil
1430
+ }
1431
+ }
1432
+
1433
+ override func draw(_ dirtyRect: NSRect) {
1434
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
1435
+ ctx.clear(bounds)
1436
+
1437
+ switch state {
1438
+ case .idle:
1439
+ break
1440
+ case .tracking(let origin, let direction, let label, let style, let visual, let visualPhase, let shape, let success, let path, let progress):
1441
+ drawOrigin(at: origin, in: ctx, alpha: 0.88)
1442
+ if path.count > 1 {
1443
+ drawGesturePath(
1444
+ path,
1445
+ fallbackOrigin: origin,
1446
+ direction: direction,
1447
+ label: label,
1448
+ success: true,
1449
+ committed: false,
1450
+ style: style,
1451
+ accessory: nil,
1452
+ progressOverride: progress,
1453
+ in: ctx
1454
+ )
1455
+ drawVisualPOCIfNeeded(
1456
+ visual,
1457
+ phase: visualPhase,
1458
+ shape: shape,
1459
+ success: success,
1460
+ points: path,
1461
+ label: label,
1462
+ in: ctx
1463
+ )
1464
+ } else if let direction {
1465
+ drawArrow(
1466
+ from: origin,
1467
+ direction: direction,
1468
+ label: label,
1469
+ success: true,
1470
+ committed: false,
1471
+ style: style,
1472
+ accessory: nil,
1473
+ progressOverride: progress,
1474
+ in: ctx
1475
+ )
1476
+ }
1477
+ case .committed(let origin, let direction, let label, let success, let style, let visual, let visualPhase, let shape, let path, let accessory, _):
1478
+ drawOrigin(at: origin, in: ctx, alpha: 1.0)
1479
+ if path.count > 1 {
1480
+ drawGesturePath(
1481
+ path,
1482
+ fallbackOrigin: origin,
1483
+ direction: direction,
1484
+ label: label,
1485
+ success: success,
1486
+ committed: true,
1487
+ style: style,
1488
+ accessory: accessory,
1489
+ progressOverride: nil,
1490
+ in: ctx
1491
+ )
1492
+ drawVisualPOCIfNeeded(
1493
+ visual,
1494
+ phase: visualPhase,
1495
+ shape: shape,
1496
+ success: success,
1497
+ points: path,
1498
+ label: label,
1499
+ in: ctx
1500
+ )
1501
+ } else {
1502
+ drawArrow(
1503
+ from: origin,
1504
+ direction: direction,
1505
+ label: label,
1506
+ success: success,
1507
+ committed: true,
1508
+ style: style,
1509
+ accessory: accessory,
1510
+ progressOverride: nil,
1511
+ in: ctx
1512
+ )
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ private func drawOrigin(at point: CGPoint, in ctx: CGContext, alpha: CGFloat) {
1518
+ ctx.setFillColor(NSColor(calibratedRed: 0.48, green: 0.76, blue: 1.0, alpha: alpha * 0.18).cgColor)
1519
+ ctx.fillEllipse(in: CGRect(x: point.x - 18, y: point.y - 18, width: 36, height: 36))
1520
+
1521
+ ctx.setFillColor(NSColor(calibratedRed: 0.62, green: 0.84, blue: 1.0, alpha: alpha * 0.95).cgColor)
1522
+ ctx.fillEllipse(in: CGRect(x: point.x - 5, y: point.y - 5, width: 10, height: 10))
1523
+ }
1524
+
1525
+ private func drawArrow(
1526
+ from origin: CGPoint,
1527
+ direction: MouseGestureDirection,
1528
+ label: String?,
1529
+ success: Bool,
1530
+ committed: Bool,
1531
+ style: MouseGestureOverlayStyle,
1532
+ accessory: MouseGestureAccessory?,
1533
+ progressOverride: CGFloat?,
1534
+ in ctx: CGContext
1535
+ ) {
1536
+ let baseLength: CGFloat = 118
1537
+ let arrowProgress = progressOverride ?? currentArrowProgress(committed: committed)
1538
+ let clampedProgress = min(1, max(0, arrowProgress))
1539
+ let length = baseLength * (committed ? max(0.14, clampedProgress) : (0.34 + 0.66 * clampedProgress))
1540
+ let vector = arrowVector(for: direction, length: length)
1541
+ let end = CGPoint(x: origin.x + vector.x, y: origin.y + vector.y)
1542
+ let accent = success
1543
+ ? NSColor(calibratedRed: 0.45, green: 0.80, blue: 1.0, alpha: 1.0)
1544
+ : NSColor(calibratedRed: 0.98, green: 0.52, blue: 0.42, alpha: 1.0)
1545
+ let strokeAlpha = committed ? 0.92 : (0.48 + 0.44 * clampedProgress)
1546
+ let glowAlpha = committed ? 0.2 : (0.08 + 0.08 * clampedProgress)
1547
+ let metrics = arrowMetrics(for: style)
1548
+
1549
+ ctx.saveGState()
1550
+ ctx.setLineCap(.round)
1551
+
1552
+ let glowPath = CGMutablePath()
1553
+ glowPath.move(to: origin)
1554
+ glowPath.addLine(to: end)
1555
+ ctx.addPath(glowPath)
1556
+ ctx.setLineWidth(metrics.glowWidth)
1557
+ ctx.setStrokeColor(accent.withAlphaComponent(glowAlpha).cgColor)
1558
+ ctx.strokePath()
1559
+
1560
+ let linePath = CGMutablePath()
1561
+ linePath.move(to: origin)
1562
+ linePath.addLine(to: end)
1563
+ ctx.addPath(linePath)
1564
+ ctx.setLineWidth(metrics.lineWidth)
1565
+ ctx.setStrokeColor(accent.withAlphaComponent(strokeAlpha).cgColor)
1566
+ ctx.strokePath()
1567
+
1568
+ drawArrowHead(at: end, direction: direction, color: accent, size: metrics.headSize)
1569
+ if let label, (!committed || clampedProgress >= labelRevealThreshold) {
1570
+ drawLabel(label, from: origin, to: end, direction: direction, color: accent)
1571
+ }
1572
+ if committed, let accessory, clampedProgress >= labelRevealThreshold {
1573
+ drawAccessory(accessory, from: origin, to: end, direction: direction, color: accent, in: ctx)
1574
+ }
1575
+ ctx.restoreGState()
1576
+ }
1577
+
1578
+ private func drawGesturePath(
1579
+ _ points: [CGPoint],
1580
+ fallbackOrigin: CGPoint,
1581
+ direction: MouseGestureDirection?,
1582
+ label: String?,
1583
+ success: Bool,
1584
+ committed: Bool,
1585
+ style: MouseGestureOverlayStyle,
1586
+ accessory: MouseGestureAccessory?,
1587
+ progressOverride: CGFloat?,
1588
+ in ctx: CGContext
1589
+ ) {
1590
+ guard points.count > 1 else { return }
1591
+ let accent = success ? theme.accent : theme.failure
1592
+ let stroke = success ? theme.graphite : theme.failure
1593
+ let metrics = arrowMetrics(for: style)
1594
+ let pathProgress = progressOverride ?? currentArrowProgress(committed: committed)
1595
+ let clampedProgress = min(1, max(0, pathProgress))
1596
+ let strokeAlpha = committed ? 0.92 : (0.48 + 0.44 * clampedProgress)
1597
+ let glowAlpha = committed ? 0.28 : (0.12 + 0.12 * clampedProgress)
1598
+ let visiblePoints = visiblePathPoints(points, progress: committed ? clampedProgress : 1)
1599
+ guard visiblePoints.count > 1 else { return }
1600
+
1601
+ let path = smoothedGesturePath(from: visiblePoints)
1602
+
1603
+ ctx.saveGState()
1604
+ ctx.setLineCap(.round)
1605
+ ctx.setLineJoin(.round)
1606
+
1607
+ drawGestureGuideDots(around: visiblePoints, accent: accent, in: ctx)
1608
+
1609
+ ctx.addPath(path)
1610
+ ctx.setLineWidth(metrics.glowWidth + 10)
1611
+ ctx.setStrokeColor(theme.graphiteDark.withAlphaComponent(committed ? 0.44 : 0.30).cgColor)
1612
+ ctx.strokePath()
1613
+
1614
+ ctx.addPath(path)
1615
+ ctx.setLineWidth(metrics.glowWidth)
1616
+ ctx.setStrokeColor(accent.withAlphaComponent(glowAlpha).cgColor)
1617
+ ctx.strokePath()
1618
+
1619
+ ctx.addPath(path)
1620
+ ctx.setLineWidth(metrics.lineWidth)
1621
+ ctx.setStrokeColor(stroke.withAlphaComponent(strokeAlpha).cgColor)
1622
+ ctx.strokePath()
1623
+
1624
+ ctx.addPath(path)
1625
+ ctx.setLineWidth(max(1, metrics.lineWidth * 0.28))
1626
+ ctx.setStrokeColor((success ? theme.highlight : accent).withAlphaComponent(success ? strokeAlpha * 0.62 : strokeAlpha).cgColor)
1627
+ ctx.strokePath()
1628
+
1629
+ let end = visiblePoints.last ?? fallbackOrigin
1630
+ let resolvedDirection = direction ?? pathDirection(from: visiblePoints)
1631
+ if let resolvedDirection {
1632
+ drawArrowHead(at: end, direction: resolvedDirection, color: stroke, size: metrics.headSize)
1633
+ if let label, (!committed || clampedProgress >= labelRevealThreshold) {
1634
+ drawLabel(label, from: fallbackOrigin, to: end, direction: resolvedDirection, color: accent)
1635
+ }
1636
+ if committed, let accessory, clampedProgress >= labelRevealThreshold {
1637
+ drawAccessory(accessory, from: fallbackOrigin, to: end, direction: resolvedDirection, color: accent, in: ctx)
1638
+ }
1639
+ }
1640
+ ctx.restoreGState()
1641
+ }
1642
+
1643
+ private func drawGestureGuideDots(around points: [CGPoint], accent: NSColor, in ctx: CGContext) {
1644
+ guard !points.isEmpty else { return }
1645
+ let minX = (points.map(\.x).min() ?? 0) - 34
1646
+ let maxX = (points.map(\.x).max() ?? 0) + 34
1647
+ let minY = (points.map(\.y).min() ?? 0) - 34
1648
+ let maxY = (points.map(\.y).max() ?? 0) + 34
1649
+ let spacing: CGFloat = 34
1650
+ let dotRadius: CGFloat = 2.2
1651
+ let startX = floor(minX / spacing) * spacing
1652
+ let startY = floor(minY / spacing) * spacing
1653
+
1654
+ var y = startY
1655
+ while y <= maxY {
1656
+ var x = startX
1657
+ while x <= maxX {
1658
+ let point = CGPoint(x: x, y: y)
1659
+ let distance = nearestDistance(from: point, to: points)
1660
+ let closeness = max(0, 1 - min(distance / 96, 1))
1661
+ let alpha = 0.08 + closeness * 0.20
1662
+ let radius = dotRadius + closeness * 1.2
1663
+ ctx.setFillColor(accent.withAlphaComponent(alpha).cgColor)
1664
+ ctx.fillEllipse(in: CGRect(x: x - radius, y: y - radius, width: radius * 2, height: radius * 2))
1665
+ x += spacing
1666
+ }
1667
+ y += spacing
1668
+ }
1669
+ }
1670
+
1671
+ private func nearestDistance(from point: CGPoint, to points: [CGPoint]) -> CGFloat {
1672
+ points.reduce(CGFloat.greatestFiniteMagnitude) { nearest, candidate in
1673
+ let dx = point.x - candidate.x
1674
+ let dy = point.y - candidate.y
1675
+ return min(nearest, sqrt(dx * dx + dy * dy))
1676
+ }
1677
+ }
1678
+
1679
+ private func drawVisualPOCIfNeeded(
1680
+ _ visual: MouseShortcutVisualDefinition?,
1681
+ phase: MouseGestureVisualPhase,
1682
+ shape: GestureShapeLabel?,
1683
+ success: Bool?,
1684
+ points: [CGPoint],
1685
+ label: String?,
1686
+ in ctx: CGContext
1687
+ ) {
1688
+ guard let visual, visual.isLottiePOC, let end = points.last else { return }
1689
+ let marker = visual.marker(phase: phase.rawValue, shape: shape, success: success) ?? fallbackMarker(phase: phase, success: success)
1690
+ let previous = points.dropLast().last ?? end
1691
+ let velocity = CGPoint(x: end.x - previous.x, y: end.y - previous.y)
1692
+ let speed = min(1, sqrt(velocity.x * velocity.x + velocity.y * velocity.y) / 42)
1693
+ let anchor = CGPoint(x: end.x + 28, y: end.y + 22 - speed * 8)
1694
+ drawLottieCatPOC(marker: marker, at: anchor, velocity: velocity, label: label, in: ctx)
1695
+ }
1696
+
1697
+ private func fallbackMarker(phase: MouseGestureVisualPhase, success: Bool?) -> String {
1698
+ switch phase {
1699
+ case .started:
1700
+ return "curious"
1701
+ case .updated:
1702
+ return "follow"
1703
+ case .recognized:
1704
+ return "pounce"
1705
+ case .completed:
1706
+ return success == false ? "confused" : "celebrate"
1707
+ }
1708
+ }
1709
+
1710
+ private func drawLottieCatPOC(
1711
+ marker: String,
1712
+ at center: CGPoint,
1713
+ velocity: CGPoint,
1714
+ label: String?,
1715
+ in ctx: CGContext
1716
+ ) {
1717
+ let mood = marker.lowercased()
1718
+ let bodyColor = NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 0.92)
1719
+ let faceColor = theme.highlight.withAlphaComponent(0.94)
1720
+ let accent = mood.contains("confused") ? theme.failure : theme.accent
1721
+ let tilt = max(-0.34, min(0.34, velocity.x / 160))
1722
+ let hop: CGFloat = mood.contains("pounce") || mood.contains("celebrate") ? 7 : 0
1723
+ let headCenter = CGPoint(x: center.x, y: center.y + hop)
1724
+ let headRadius: CGFloat = mood.contains("pounce") ? 16 : 14
1725
+
1726
+ ctx.saveGState()
1727
+ ctx.translateBy(x: headCenter.x, y: headCenter.y)
1728
+ ctx.rotate(by: tilt)
1729
+ ctx.translateBy(x: -headCenter.x, y: -headCenter.y)
1730
+
1731
+ ctx.setFillColor(theme.graphiteDark.withAlphaComponent(0.24).cgColor)
1732
+ ctx.fillEllipse(in: CGRect(x: headCenter.x - 22, y: headCenter.y - 18, width: 44, height: 36))
1733
+
1734
+ let leftEar = CGMutablePath()
1735
+ leftEar.move(to: CGPoint(x: headCenter.x - 12, y: headCenter.y + 9))
1736
+ leftEar.addLine(to: CGPoint(x: headCenter.x - 7, y: headCenter.y + 25))
1737
+ leftEar.addLine(to: CGPoint(x: headCenter.x - 1, y: headCenter.y + 11))
1738
+ leftEar.closeSubpath()
1739
+ ctx.addPath(leftEar)
1740
+ ctx.setFillColor(bodyColor.cgColor)
1741
+ ctx.fillPath()
1742
+
1743
+ let rightEar = CGMutablePath()
1744
+ rightEar.move(to: CGPoint(x: headCenter.x + 12, y: headCenter.y + 9))
1745
+ rightEar.addLine(to: CGPoint(x: headCenter.x + 7, y: headCenter.y + 25))
1746
+ rightEar.addLine(to: CGPoint(x: headCenter.x + 1, y: headCenter.y + 11))
1747
+ rightEar.closeSubpath()
1748
+ ctx.addPath(rightEar)
1749
+ ctx.setFillColor(bodyColor.cgColor)
1750
+ ctx.fillPath()
1751
+
1752
+ ctx.setFillColor(bodyColor.cgColor)
1753
+ ctx.fillEllipse(in: CGRect(x: headCenter.x - headRadius, y: headCenter.y - headRadius, width: headRadius * 2, height: headRadius * 2))
1754
+ ctx.setStrokeColor(accent.withAlphaComponent(0.72).cgColor)
1755
+ ctx.setLineWidth(1.4)
1756
+ ctx.strokeEllipse(in: CGRect(x: headCenter.x - headRadius, y: headCenter.y - headRadius, width: headRadius * 2, height: headRadius * 2))
1757
+
1758
+ let eyeY = headCenter.y + 2
1759
+ let blink = mood.contains("pounce") || mood.contains("celebrate")
1760
+ drawCatEye(at: CGPoint(x: headCenter.x - 5, y: eyeY), blink: blink, color: faceColor, in: ctx)
1761
+ drawCatEye(at: CGPoint(x: headCenter.x + 5, y: eyeY), blink: blink, color: faceColor, in: ctx)
1762
+
1763
+ ctx.setStrokeColor(faceColor.withAlphaComponent(0.82).cgColor)
1764
+ ctx.setLineWidth(1)
1765
+ let mouth = CGMutablePath()
1766
+ mouth.move(to: CGPoint(x: headCenter.x - 3, y: headCenter.y - 5))
1767
+ mouth.addQuadCurve(to: CGPoint(x: headCenter.x + 3, y: headCenter.y - 5), control: CGPoint(x: headCenter.x, y: headCenter.y - (mood.contains("confused") ? 2 : 8)))
1768
+ ctx.addPath(mouth)
1769
+ ctx.strokePath()
1770
+
1771
+ if mood.contains("celebrate"), let label {
1772
+ drawCatToast(label, near: CGPoint(x: headCenter.x + 18, y: headCenter.y + 18), color: accent)
1773
+ }
1774
+
1775
+ ctx.restoreGState()
1776
+ }
1777
+
1778
+ private func drawCatEye(at point: CGPoint, blink: Bool, color: NSColor, in ctx: CGContext) {
1779
+ ctx.setStrokeColor(color.cgColor)
1780
+ ctx.setFillColor(color.cgColor)
1781
+ if blink {
1782
+ ctx.setLineWidth(1.4)
1783
+ let path = CGMutablePath()
1784
+ path.move(to: CGPoint(x: point.x - 2.4, y: point.y))
1785
+ path.addLine(to: CGPoint(x: point.x + 2.4, y: point.y))
1786
+ ctx.addPath(path)
1787
+ ctx.strokePath()
1788
+ } else {
1789
+ ctx.fillEllipse(in: CGRect(x: point.x - 1.7, y: point.y - 1.7, width: 3.4, height: 3.4))
1790
+ }
1791
+ }
1792
+
1793
+ private func drawCatToast(_ label: String, near point: CGPoint, color: NSColor) {
1794
+ let shortLabel = label.replacingOccurrences(of: " Focused", with: "!")
1795
+ let font = NSFont.monospacedSystemFont(ofSize: 9, weight: .bold)
1796
+ let attributed = NSAttributedString(
1797
+ string: shortLabel,
1798
+ attributes: [
1799
+ .font: font,
1800
+ .foregroundColor: theme.highlight.withAlphaComponent(0.96),
1801
+ ]
1802
+ )
1803
+ let size = attributed.size()
1804
+ let rect = CGRect(x: point.x, y: point.y, width: size.width + 12, height: size.height + 7)
1805
+ let bubble = NSBezierPath(roundedRect: rect, xRadius: 8, yRadius: 8)
1806
+ theme.graphiteDark.withAlphaComponent(0.72).setFill()
1807
+ bubble.fill()
1808
+ color.withAlphaComponent(0.5).setStroke()
1809
+ bubble.lineWidth = 1
1810
+ bubble.stroke()
1811
+ attributed.draw(in: CGRect(x: rect.minX + 6, y: rect.minY + 3.5, width: size.width, height: size.height))
1812
+ }
1813
+
1814
+ private func smoothedGesturePath(from points: [CGPoint]) -> CGPath {
1815
+ let path = CGMutablePath()
1816
+ guard let first = points.first else { return path }
1817
+ path.move(to: first)
1818
+
1819
+ guard points.count > 2 else {
1820
+ if let last = points.last {
1821
+ path.addLine(to: last)
1822
+ }
1823
+ return path
1824
+ }
1825
+
1826
+ for index in 0..<(points.count - 1) {
1827
+ let previous = points[max(index - 1, 0)]
1828
+ let current = points[index]
1829
+ let next = points[index + 1]
1830
+ let nextNext = points[min(index + 2, points.count - 1)]
1831
+ let tension: CGFloat = 0.34
1832
+ let control1 = CGPoint(
1833
+ x: current.x + (next.x - previous.x) * tension,
1834
+ y: current.y + (next.y - previous.y) * tension
1835
+ )
1836
+ let control2 = CGPoint(
1837
+ x: next.x - (nextNext.x - current.x) * tension,
1838
+ y: next.y - (nextNext.y - current.y) * tension
1839
+ )
1840
+ path.addCurve(to: next, control1: control1, control2: control2)
1841
+ }
1842
+ return path
1843
+ }
1844
+
1845
+ private func visiblePathPoints(_ points: [CGPoint], progress: CGFloat) -> [CGPoint] {
1846
+ guard progress < 1, points.count > 2 else { return points }
1847
+ let clamped = min(1, max(0.04, progress))
1848
+ let count = max(2, Int(ceil(CGFloat(points.count) * clamped)))
1849
+ return Array(points.prefix(count))
1850
+ }
1851
+
1852
+ private func pathDirection(from points: [CGPoint]) -> MouseGestureDirection? {
1853
+ guard points.count >= 2 else { return nil }
1854
+ let window = points.suffix(min(6, points.count))
1855
+ guard let first = window.first, let last = window.last else { return nil }
1856
+ let delta = CGPoint(x: last.x - first.x, y: last.y - first.y)
1857
+ return MouseGestureController.resolveDirection(delta: delta, threshold: 4, axisBias: 1.0)
1858
+ }
1859
+
1860
+ private func arrowMetrics(for style: MouseGestureOverlayStyle) -> (lineWidth: CGFloat, glowWidth: CGFloat, headSize: CGFloat) {
1861
+ switch style {
1862
+ case .thinLine:
1863
+ return (2.4, 7, 10)
1864
+ case .thickLine:
1865
+ return (8.5, 20, 18)
1866
+ case .drawing:
1867
+ return (5, 16, 15)
1868
+ }
1869
+ }
1870
+
1871
+ private func drawArrowHead(at end: CGPoint, direction: MouseGestureDirection, color: NSColor, size: CGFloat) {
1872
+ let path = NSBezierPath()
1873
+
1874
+ switch direction {
1875
+ case .left:
1876
+ path.move(to: CGPoint(x: end.x - size, y: end.y))
1877
+ path.line(to: CGPoint(x: end.x + size * 0.2, y: end.y + size * 0.72))
1878
+ path.line(to: CGPoint(x: end.x + size * 0.2, y: end.y - size * 0.72))
1879
+ case .right:
1880
+ path.move(to: CGPoint(x: end.x + size, y: end.y))
1881
+ path.line(to: CGPoint(x: end.x - size * 0.2, y: end.y + size * 0.72))
1882
+ path.line(to: CGPoint(x: end.x - size * 0.2, y: end.y - size * 0.72))
1883
+ case .up:
1884
+ path.move(to: CGPoint(x: end.x, y: end.y + size))
1885
+ path.line(to: CGPoint(x: end.x - size * 0.72, y: end.y - size * 0.2))
1886
+ path.line(to: CGPoint(x: end.x + size * 0.72, y: end.y - size * 0.2))
1887
+ case .down:
1888
+ path.move(to: CGPoint(x: end.x, y: end.y - size))
1889
+ path.line(to: CGPoint(x: end.x - size * 0.72, y: end.y + size * 0.2))
1890
+ path.line(to: CGPoint(x: end.x + size * 0.72, y: end.y + size * 0.2))
1891
+ }
1892
+
1893
+ path.close()
1894
+ color.withAlphaComponent(0.96).setFill()
1895
+ path.fill()
1896
+ }
1897
+
1898
+ private func drawLabel(_ label: String, from origin: CGPoint, to end: CGPoint, direction: MouseGestureDirection, color: NSColor) {
1899
+ let display = labelComponents(for: label)
1900
+ let titleFont = NSFont.systemFont(ofSize: 15, weight: .heavy)
1901
+ let kickerFont = NSFont.monospacedSystemFont(ofSize: 8, weight: .bold)
1902
+ let titleAttributes: [NSAttributedString.Key: Any] = [
1903
+ .font: titleFont,
1904
+ .foregroundColor: theme.highlight.withAlphaComponent(0.98),
1905
+ ]
1906
+ let kickerAttributes: [NSAttributedString.Key: Any] = [
1907
+ .font: kickerFont,
1908
+ .foregroundColor: color.withAlphaComponent(0.84),
1909
+ ]
1910
+ let title = NSAttributedString(string: display.title, attributes: titleAttributes)
1911
+ let kicker = display.kicker.map { NSAttributedString(string: $0, attributes: kickerAttributes) }
1912
+ let titleSize = title.size()
1913
+ let kickerSize = kicker?.size() ?? .zero
1914
+ let gap: CGFloat = kicker == nil ? 0 : 2
1915
+ let textSize = CGSize(
1916
+ width: max(titleSize.width, kickerSize.width),
1917
+ height: titleSize.height + gap + kickerSize.height
1918
+ )
1919
+ let paddingX: CGFloat = 14
1920
+ let paddingY: CGFloat = 8
1921
+ let tickWidth: CGFloat = 6
1922
+ let bubbleSize = CGSize(
1923
+ width: textSize.width + paddingX * 2 + tickWidth,
1924
+ height: textSize.height + paddingY * 2
1925
+ )
1926
+ let bubbleOrigin = labelOrigin(from: origin, to: end, direction: direction, bubbleSize: bubbleSize)
1927
+ let rect = CGRect(
1928
+ x: bubbleOrigin.x,
1929
+ y: bubbleOrigin.y,
1930
+ width: bubbleSize.width,
1931
+ height: bubbleSize.height
1932
+ )
1933
+
1934
+ ctxSaveForLabel(rotationDegrees: -4, around: CGPoint(x: rect.midX, y: rect.midY))
1935
+
1936
+ let shadowRect = rect.insetBy(dx: -5, dy: -5)
1937
+ let shadow = NSBezierPath(roundedRect: shadowRect, xRadius: 15, yRadius: 15)
1938
+ theme.graphiteDark.withAlphaComponent(0.24).setFill()
1939
+ shadow.fill()
1940
+
1941
+ let bg = NSBezierPath(roundedRect: rect, xRadius: 13, yRadius: 13)
1942
+ theme.graphiteDark.withAlphaComponent(0.82).setFill()
1943
+ bg.fill()
1944
+
1945
+ let border = NSBezierPath(roundedRect: rect, xRadius: 13, yRadius: 13)
1946
+ theme.graphite.withAlphaComponent(0.46).setStroke()
1947
+ border.lineWidth = 1.2
1948
+ border.stroke()
1949
+
1950
+ let tickRect = CGRect(
1951
+ x: rect.minX + 8,
1952
+ y: rect.midY - 7,
1953
+ width: 3,
1954
+ height: 14
1955
+ )
1956
+ let tick = NSBezierPath(roundedRect: tickRect, xRadius: 1.5, yRadius: 1.5)
1957
+ color.withAlphaComponent(0.82).setFill()
1958
+ tick.fill()
1959
+
1960
+ let titleRect = CGRect(
1961
+ x: rect.minX + paddingX + tickWidth,
1962
+ y: rect.minY + paddingY + kickerSize.height + gap,
1963
+ width: titleSize.width,
1964
+ height: titleSize.height
1965
+ )
1966
+ title.draw(in: titleRect)
1967
+
1968
+ if let kicker {
1969
+ let kickerRect = CGRect(
1970
+ x: rect.minX + paddingX + tickWidth + 1,
1971
+ y: rect.minY + paddingY,
1972
+ width: kickerSize.width,
1973
+ height: kickerSize.height
1974
+ )
1975
+ kicker.draw(in: kickerRect)
1976
+ }
1977
+
1978
+ NSGraphicsContext.current?.cgContext.restoreGState()
1979
+ }
1980
+
1981
+ private func labelComponents(for label: String) -> (title: String, kicker: String?) {
1982
+ if label.hasSuffix(" Focused") {
1983
+ return (String(label.dropLast(" Focused".count)), "FOCUSED")
1984
+ }
1985
+ return (label, nil)
1986
+ }
1987
+
1988
+ private func ctxSaveForLabel(rotationDegrees: CGFloat, around center: CGPoint) {
1989
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
1990
+ ctx.saveGState()
1991
+ ctx.translateBy(x: center.x, y: center.y)
1992
+ ctx.rotate(by: rotationDegrees * .pi / 180)
1993
+ ctx.translateBy(x: -center.x, y: -center.y)
1994
+ }
1995
+
1996
+ private func labelOrigin(from origin: CGPoint, to end: CGPoint, direction: MouseGestureDirection, bubbleSize: CGSize) -> CGPoint {
1997
+ let midpoint = CGPoint(x: (origin.x + end.x) / 2, y: (origin.y + end.y) / 2)
1998
+ let proposedOrigin: CGPoint
1999
+
2000
+ switch direction {
2001
+ case .left, .right:
2002
+ proposedOrigin = CGPoint(x: midpoint.x - bubbleSize.width / 2, y: midpoint.y + 18)
2003
+ case .up, .down:
2004
+ proposedOrigin = CGPoint(x: midpoint.x + 20, y: midpoint.y - bubbleSize.height / 2)
2005
+ }
2006
+
2007
+ let minX: CGFloat = 12
2008
+ let minY: CGFloat = 12
2009
+ let maxX = max(minX, bounds.width - bubbleSize.width - 12)
2010
+ let maxY = max(minY, bounds.height - bubbleSize.height - 12)
2011
+
2012
+ return CGPoint(
2013
+ x: min(max(proposedOrigin.x, minX), maxX),
2014
+ y: min(max(proposedOrigin.y, minY), maxY)
2015
+ )
2016
+ }
2017
+
2018
+ private func arrowVector(for direction: MouseGestureDirection, length: CGFloat) -> CGPoint {
2019
+ switch direction {
2020
+ case .left:
2021
+ return CGPoint(x: -length, y: 0)
2022
+ case .right:
2023
+ return CGPoint(x: length, y: 0)
2024
+ case .up:
2025
+ return CGPoint(x: 0, y: length)
2026
+ case .down:
2027
+ return CGPoint(x: 0, y: -length)
2028
+ }
2029
+ }
2030
+
2031
+ private func updateArrowAnimation(from oldState: State, to newState: State) {
2032
+ let oldDirection = stateDirection(from: oldState)
2033
+ let newDirection = stateDirection(from: newState)
2034
+ let oldCommitted = isCommitted(state: oldState)
2035
+ let newCommitted = isCommitted(state: newState)
2036
+
2037
+ if newCommitted, newDirection != nil {
2038
+ let shouldRestart = oldDirection != newDirection || !oldCommitted
2039
+ if shouldRestart {
2040
+ let previousProgress = trackingProgress(from: oldState)
2041
+ committedStartProgress = max(0, min(1, previousProgress ?? 0))
2042
+ if committedStartProgress >= 0.94 {
2043
+ arrowAnimationTimer?.invalidate()
2044
+ arrowAnimationTimer = nil
2045
+ arrowAnimationStartedAt = nil
2046
+ arrowAnimationDuration = 0
2047
+ committedStartProgress = 1
2048
+ } else {
2049
+ startArrowAnimation(duration: committedArrowAnimationDuration)
2050
+ }
2051
+ }
2052
+ return
2053
+ }
2054
+
2055
+ arrowAnimationTimer?.invalidate()
2056
+ arrowAnimationTimer = nil
2057
+ arrowAnimationStartedAt = nil
2058
+ arrowAnimationDuration = 0
2059
+ committedStartProgress = 0
2060
+ }
2061
+
2062
+ private func startArrowAnimation(duration: TimeInterval) {
2063
+ arrowAnimationTimer?.invalidate()
2064
+ arrowAnimationStartedAt = Date()
2065
+ arrowAnimationDuration = duration
2066
+
2067
+ let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in
2068
+ guard let self else {
2069
+ timer.invalidate()
2070
+ return
2071
+ }
2072
+ self.needsDisplay = true
2073
+ let elapsed = Date().timeIntervalSince(self.arrowAnimationStartedAt ?? Date())
2074
+ if elapsed >= self.replayLeadInDuration {
2075
+ timer.invalidate()
2076
+ self.arrowAnimationTimer = nil
2077
+ }
2078
+ }
2079
+ arrowAnimationTimer = timer
2080
+ RunLoop.main.add(timer, forMode: .common)
2081
+ }
2082
+
2083
+ private func currentArrowProgress(committed: Bool) -> CGFloat {
2084
+ guard committed,
2085
+ let startedAt = arrowAnimationStartedAt,
2086
+ arrowAnimationDuration > 0 else {
2087
+ return committed ? (committedStartProgress > 0 ? committedStartProgress : 1) : 1
2088
+ }
2089
+
2090
+ let delayedElapsed = Date().timeIntervalSince(startedAt) - arrowAnimationDelay
2091
+ guard delayedElapsed > 0 else { return committedStartProgress }
2092
+ let normalized = min(1, max(0, delayedElapsed / arrowAnimationDuration))
2093
+ let animated = easeOut(normalized)
2094
+ return committedStartProgress + (1 - committedStartProgress) * animated
2095
+ }
2096
+
2097
+ private func updateAccessoryAnimation() {
2098
+ accessoryAnimationTimer?.invalidate()
2099
+ accessoryAnimationTimer = nil
2100
+ accessoryAnimationStartedAt = nil
2101
+ accessoryAnimationDuration = 0
2102
+ accessoryAnimationDelay = 0
2103
+
2104
+ if case .committed(_, _, _, _, _, _, _, _, _, let accessory, let duration) = state, accessory != nil {
2105
+ accessoryAnimationStartedAt = Date()
2106
+ accessoryAnimationDuration = duration
2107
+ accessoryAnimationDelay = replayLeadInDuration * 0.86
2108
+ let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in
2109
+ guard let self else {
2110
+ timer.invalidate()
2111
+ return
2112
+ }
2113
+ self.needsDisplay = true
2114
+ let elapsed = Date().timeIntervalSince(self.accessoryAnimationStartedAt ?? Date())
2115
+ if elapsed >= self.accessoryAnimationDelay + self.accessoryAnimationDuration {
2116
+ timer.invalidate()
2117
+ self.accessoryAnimationTimer = nil
2118
+ }
2119
+ }
2120
+ accessoryAnimationTimer = timer
2121
+ RunLoop.main.add(timer, forMode: .common)
2122
+ }
2123
+ }
2124
+
2125
+ private func drawAccessory(
2126
+ _ accessory: MouseGestureAccessory,
2127
+ from origin: CGPoint,
2128
+ to end: CGPoint,
2129
+ direction: MouseGestureDirection,
2130
+ color: NSColor,
2131
+ in ctx: CGContext
2132
+ ) {
2133
+ guard let startedAt = accessoryAnimationStartedAt, accessoryAnimationDuration > 0 else { return }
2134
+ let delayedElapsed = Date().timeIntervalSince(startedAt) - accessoryAnimationDelay
2135
+ guard delayedElapsed > 0 else { return }
2136
+ let progress = min(1, max(0, delayedElapsed / accessoryAnimationDuration))
2137
+ let scale = 0.82 + 0.18 * easeOut(progress)
2138
+ let fadeStart: CGFloat = 0.58
2139
+ let alphaProgress = progress <= fadeStart ? 1 : 1 - ((progress - fadeStart) / (1 - fadeStart))
2140
+ let alpha = max(0, min(1, alphaProgress))
2141
+ guard alpha > 0 else { return }
2142
+
2143
+ let center = accessoryCenter(from: end, direction: direction)
2144
+ let badgeDiameter: CGFloat = 34 * scale
2145
+ let badgeRect = CGRect(
2146
+ x: center.x - badgeDiameter / 2,
2147
+ y: center.y - badgeDiameter / 2,
2148
+ width: badgeDiameter,
2149
+ height: badgeDiameter
2150
+ )
2151
+
2152
+ let badge = NSBezierPath(ovalIn: badgeRect)
2153
+ NSColor.black.withAlphaComponent(0.46 * alpha).setFill()
2154
+ badge.fill()
2155
+
2156
+ color.withAlphaComponent(0.32 * alpha).setStroke()
2157
+ badge.lineWidth = 1
2158
+ badge.stroke()
2159
+
2160
+ switch accessory {
2161
+ case .mic:
2162
+ drawMicGlyph(in: badgeRect.insetBy(dx: badgeDiameter * 0.26, dy: badgeDiameter * 0.2), color: color.withAlphaComponent(0.96 * alpha), in: ctx)
2163
+ }
2164
+ }
2165
+
2166
+ private func accessoryCenter(from end: CGPoint, direction: MouseGestureDirection) -> CGPoint {
2167
+ switch direction {
2168
+ case .up:
2169
+ return CGPoint(x: end.x, y: end.y + 34)
2170
+ case .down:
2171
+ return CGPoint(x: end.x, y: end.y - 34)
2172
+ case .left:
2173
+ return CGPoint(x: end.x - 34, y: end.y)
2174
+ case .right:
2175
+ return CGPoint(x: end.x + 34, y: end.y)
2176
+ }
2177
+ }
2178
+
2179
+ private func drawMicGlyph(in rect: CGRect, color: NSColor, in ctx: CGContext) {
2180
+ ctx.saveGState()
2181
+ color.setStroke()
2182
+ color.withAlphaComponent(0.22).setFill()
2183
+
2184
+ let bodyWidth = rect.width * 0.42
2185
+ let bodyHeight = rect.height * 0.54
2186
+ let bodyRect = CGRect(
2187
+ x: rect.midX - bodyWidth / 2,
2188
+ y: rect.maxY - bodyHeight,
2189
+ width: bodyWidth,
2190
+ height: bodyHeight
2191
+ )
2192
+ let body = NSBezierPath(roundedRect: bodyRect, xRadius: bodyWidth / 2, yRadius: bodyWidth / 2)
2193
+ body.lineWidth = 1.6
2194
+ body.fill()
2195
+ body.stroke()
2196
+
2197
+ let stem = NSBezierPath()
2198
+ stem.move(to: CGPoint(x: rect.midX, y: bodyRect.minY))
2199
+ stem.line(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.24))
2200
+ stem.lineWidth = 1.8
2201
+ stem.lineCapStyle = .round
2202
+ stem.stroke()
2203
+
2204
+ let arcRect = CGRect(
2205
+ x: rect.midX - rect.width * 0.28,
2206
+ y: rect.minY + rect.height * 0.18,
2207
+ width: rect.width * 0.56,
2208
+ height: rect.height * 0.42
2209
+ )
2210
+ let arc = NSBezierPath()
2211
+ arc.appendArc(
2212
+ withCenter: CGPoint(x: arcRect.midX, y: arcRect.midY + arcRect.height * 0.08),
2213
+ radius: arcRect.width / 2,
2214
+ startAngle: 200,
2215
+ endAngle: -20,
2216
+ clockwise: true
2217
+ )
2218
+ arc.lineWidth = 1.6
2219
+ arc.lineCapStyle = .round
2220
+ arc.stroke()
2221
+
2222
+ let base = NSBezierPath()
2223
+ base.move(to: CGPoint(x: rect.midX - rect.width * 0.22, y: rect.minY + rect.height * 0.14))
2224
+ base.line(to: CGPoint(x: rect.midX + rect.width * 0.22, y: rect.minY + rect.height * 0.14))
2225
+ base.lineWidth = 1.6
2226
+ base.lineCapStyle = .round
2227
+ base.stroke()
2228
+ ctx.restoreGState()
2229
+ }
2230
+
2231
+ private func easeOut(_ t: CGFloat) -> CGFloat {
2232
+ 1 - pow(1 - t, 3)
2233
+ }
2234
+
2235
+ private func stateDirection(from state: State) -> MouseGestureDirection? {
2236
+ switch state {
2237
+ case .idle:
2238
+ return nil
2239
+ case .tracking(_, let direction, _, _, _, _, _, _, _, _):
2240
+ return direction
2241
+ case .committed(_, let direction, _, _, _, _, _, _, _, _, _):
2242
+ return direction
2243
+ }
2244
+ }
2245
+
2246
+ private func isCommitted(state: State) -> Bool {
2247
+ if case .committed = state {
2248
+ return true
2249
+ }
2250
+ return false
2251
+ }
2252
+
2253
+ private func trackingProgress(from state: State) -> CGFloat? {
2254
+ if case .tracking(_, _, _, _, _, _, _, _, _, let progress) = state {
2255
+ return progress
2256
+ }
2257
+ return nil
2258
+ }
2259
+ }