@lattices/cli 0.4.13 → 0.5.0

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