@lattices/cli 0.4.14 → 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 +60 -1
  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 -2583
  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,2583 +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
- if trackingState.nativeClickPassthrough, direction == nil {
585
- return Unmanaged.passUnretained(event)
586
- }
587
- return nil
588
- }
589
-
590
- private func processMouseDragged(snapshot: MouseEventSnapshot) {
591
- dispatchPrecondition(condition: .onQueue(.main))
592
- guard let session, session.buttonNumber == snapshot.buttonNumber else {
593
- return
594
- }
595
- MouseShortcutStore.shared.reloadIfNeeded()
596
-
597
- session.currentPoint = snapshot.location
598
- session.recordPoint(snapshot.location)
599
- scheduleStaleSessionCleanup(for: session)
600
- let button = MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber))
601
- let delta = CGPoint(
602
- x: snapshot.location.x - session.startPoint.x,
603
- y: snapshot.location.y - session.startPoint.y
604
- )
605
- let tuning = MouseShortcutStore.shared.tuning
606
- let direction = Self.resolveDirection(delta: delta, threshold: tuning.dragThreshold, axisBias: tuning.axisBias)
607
-
608
- if direction != session.lockedDirection {
609
- session.lockedDirection = direction
610
- if let direction {
611
- let triggerEvent = MouseShortcutTriggerEvent(button: button, kind: .drag, direction: direction, device: nil)
612
- let match = MouseShortcutStore.shared.match(for: triggerEvent)
613
- DiagnosticLog.shared.info("MouseGesture: locked \(label(for: direction)) via \(triggerEvent.triggerName)")
614
- recordObservedEvent(
615
- phase: "drag",
616
- button: button,
617
- location: snapshot.location,
618
- delta: delta,
619
- modifiers: snapshot.flags,
620
- candidate: triggerEvent.triggerName,
621
- match: match,
622
- note: match == nil ? "no rule" : "candidate",
623
- appInfo: currentAppInfo()
624
- )
625
- }
626
- }
627
-
628
- if let direction {
629
- let dominantDistance = max(abs(delta.x), abs(delta.y))
630
- let previewProgress = previewProgress(
631
- dominantDistance: dominantDistance,
632
- threshold: tuning.dragThreshold
633
- )
634
- session.overlay.track(
635
- origin: session.startPoint,
636
- direction: direction,
637
- label: nil,
638
- style: overlayStyle(for: button),
639
- visual: session.visual,
640
- visualPhase: .updated,
641
- shape: nil,
642
- success: nil,
643
- pathPoints: session.pathPoints,
644
- progress: previewProgress
645
- )
646
- } else {
647
- session.overlay.track(
648
- origin: session.startPoint,
649
- direction: nil,
650
- label: nil,
651
- style: overlayStyle(for: button),
652
- visual: session.visual,
653
- visualPhase: .updated,
654
- shape: nil,
655
- success: nil,
656
- pathPoints: session.pathPoints,
657
- progress: 0
658
- )
659
- }
660
- }
661
-
662
- private func handleMouseUp(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
663
- let snapshot = MouseEventSnapshot(
664
- location: event.location,
665
- flags: event.flags,
666
- buttonNumber: buttonNumber
667
- )
668
- guard let trackingState = currentTrackingState(),
669
- trackingState.buttonNumber == buttonNumber else {
670
- DispatchQueue.main.async { [weak self] in
671
- self?.processMouseUpNoSession(snapshot: snapshot)
672
- }
673
- return Unmanaged.passUnretained(event)
674
- }
675
-
676
- if trackingState.nativeClickPassthrough {
677
- let delta = CGPoint(
678
- x: snapshot.location.x - trackingState.startPoint.x,
679
- y: snapshot.location.y - trackingState.startPoint.y
680
- )
681
- let direction = Self.resolveDirection(
682
- delta: delta,
683
- threshold: trackingState.dragThreshold,
684
- axisBias: trackingState.axisBias
685
- )
686
- if direction == nil {
687
- setTrackingButton(nil)
688
- DispatchQueue.main.async { [weak self] in
689
- self?.processMouseUpNativeClickPassthrough(snapshot: snapshot)
690
- }
691
- return Unmanaged.passUnretained(event)
692
- }
693
- }
694
-
695
- // Clear tap-side tracking so a subsequent drag/up for this button
696
- // falls through.
697
- setTrackingButton(nil)
698
- DispatchQueue.main.async { [weak self] in
699
- self?.processMouseUp(snapshot: snapshot)
700
- }
701
- return nil
702
- }
703
-
704
- private func processMouseUpNoSession(snapshot: MouseEventSnapshot) {
705
- dispatchPrecondition(condition: .onQueue(.main))
706
- recordObservedEvent(
707
- phase: "up",
708
- button: MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber)),
709
- location: snapshot.location,
710
- delta: .zero,
711
- modifiers: snapshot.flags,
712
- candidate: nil,
713
- match: nil,
714
- note: "no active session",
715
- appInfo: currentAppInfo()
716
- )
717
- }
718
-
719
- private func processMouseUpNativeClickPassthrough(snapshot: MouseEventSnapshot) {
720
- dispatchPrecondition(condition: .onQueue(.main))
721
- guard let activeSession = session,
722
- activeSession.buttonNumber == snapshot.buttonNumber else {
723
- recordObservedEvent(
724
- phase: "up",
725
- button: MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber)),
726
- location: snapshot.location,
727
- delta: .zero,
728
- modifiers: snapshot.flags,
729
- candidate: nil,
730
- match: nil,
731
- note: "native click passthrough",
732
- appInfo: currentAppInfo()
733
- )
734
- return
735
- }
736
-
737
- let delta = CGPoint(
738
- x: snapshot.location.x - activeSession.startPoint.x,
739
- y: snapshot.location.y - activeSession.startPoint.y
740
- )
741
- recordObservedEvent(
742
- phase: "up",
743
- button: MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber)),
744
- location: snapshot.location,
745
- delta: delta,
746
- modifiers: snapshot.flags,
747
- candidate: nil,
748
- match: nil,
749
- note: "native click passthrough",
750
- appInfo: currentAppInfo()
751
- )
752
- clearSession()
753
- }
754
-
755
- private func processMouseUp(snapshot: MouseEventSnapshot) {
756
- dispatchPrecondition(condition: .onQueue(.main))
757
- MouseShortcutStore.shared.reloadIfNeeded()
758
- let button = MouseShortcutButton(rawButtonNumber: Int(snapshot.buttonNumber))
759
- let appInfo = currentAppInfo()
760
-
761
- guard let session, session.buttonNumber == snapshot.buttonNumber else {
762
- // Session was cleared between the tap-thread dispatch and now.
763
- return
764
- }
765
- session.currentPoint = snapshot.location
766
- session.recordPoint(snapshot.location)
767
-
768
- let delta = CGPoint(
769
- x: snapshot.location.x - session.startPoint.x,
770
- y: snapshot.location.y - session.startPoint.y
771
- )
772
- let tuning = MouseShortcutStore.shared.tuning
773
- let direction = Self.resolveDirection(delta: delta, threshold: tuning.dragThreshold, axisBias: tuning.axisBias)
774
- self.session = nil
775
- staleSessionTimer?.invalidate()
776
- staleSessionTimer = nil
777
-
778
- let shapeResult = shapeRecognizer.recognize(points: session.pathPoints)
779
- if let shape = shapeResult.shape {
780
- let shapeTrigger = MouseShortcutTriggerEvent(button: button, kind: .shape, shape: shape)
781
- let shapeMatch = MouseShortcutStore.shared.match(for: shapeTrigger)
782
- if let shapeMatch {
783
- let commitDirection = shapeResult.segments.last?.direction ?? direction ?? .right
784
- let dismissBeforeAction = shouldDismissOverlayBeforeAction(match: shapeMatch)
785
- if dismissBeforeAction {
786
- session.overlay.dismiss(immediately: true)
787
- }
788
-
789
- DispatchQueue.main.async { [weak self] in
790
- guard let self else { return }
791
- if !dismissBeforeAction {
792
- self.retainOverlay(session.overlay)
793
- }
794
- let outcome = self.performAction(match: shapeMatch, startPoint: session.startPoint)
795
- DiagnosticLog.shared.info("MouseGesture: \(shape.displayName) -> \(outcome.label) -> \(outcome.success ? "ok" : "blocked")")
796
- self.recordObservedEvent(
797
- phase: "up",
798
- button: button,
799
- location: snapshot.location,
800
- delta: delta,
801
- modifiers: snapshot.flags,
802
- candidate: shapeTrigger.triggerName,
803
- match: shapeMatch,
804
- note: "shape fired confidence=\(String(format: "%.2f", Double(shapeResult.confidence)))",
805
- appInfo: appInfo
806
- )
807
- if !dismissBeforeAction {
808
- session.overlay.commit(
809
- origin: session.startPoint,
810
- direction: commitDirection,
811
- label: outcome.label,
812
- success: outcome.success,
813
- style: self.overlayStyle(for: button),
814
- visual: shapeMatch.rule.visual ?? session.visual,
815
- visualPhase: .completed,
816
- shape: shape,
817
- pathPoints: session.pathPoints,
818
- accessory: outcome.accessory
819
- )
820
- }
821
- }
822
- return
823
- }
824
- }
825
-
826
- guard let direction else {
827
- let clickTrigger = MouseShortcutTriggerEvent(button: button, kind: .click, direction: nil, device: nil)
828
- let clickMatch = MouseShortcutStore.shared.match(for: clickTrigger)
829
- DiagnosticLog.shared.info("MouseGesture: released without a gesture at \(format(snapshot.location))")
830
- recordObservedEvent(
831
- phase: "up",
832
- button: button,
833
- location: snapshot.location,
834
- delta: delta,
835
- modifiers: snapshot.flags,
836
- candidate: clickMatch != nil ? clickTrigger.triggerName : nil,
837
- match: clickMatch,
838
- note: clickMatch != nil ? "click action" : "replay click",
839
- appInfo: appInfo
840
- )
841
- if let clickMatch {
842
- DispatchQueue.main.async { [weak self] in
843
- guard let self else { return }
844
- session.overlay.dismiss(immediately: true)
845
- let outcome = self.performAction(match: clickMatch, startPoint: session.startPoint)
846
- DiagnosticLog.shared.info("MouseGesture: \(outcome.label) -> \(outcome.success ? "ok" : "blocked")")
847
- }
848
- } else {
849
- DispatchQueue.main.async { [weak self] in
850
- session.overlay.dismiss()
851
- self?.replayMouseClick(
852
- buttonNumber: snapshot.buttonNumber,
853
- at: session.startPoint,
854
- flags: snapshot.flags
855
- )
856
- }
857
- }
858
- return
859
- }
860
-
861
- let triggerEvent = MouseShortcutTriggerEvent(button: button, kind: .drag, direction: direction, device: nil)
862
- let match = MouseShortcutStore.shared.match(for: triggerEvent)
863
- let dismissBeforeAction = shouldDismissOverlayBeforeAction(match: match)
864
- if dismissBeforeAction {
865
- session.overlay.dismiss(immediately: true)
866
- }
867
-
868
- DispatchQueue.main.async { [weak self] in
869
- guard let self else { return }
870
- if !dismissBeforeAction {
871
- self.retainOverlay(session.overlay)
872
- }
873
- let outcome = self.performAction(match: match, startPoint: session.startPoint)
874
- DiagnosticLog.shared.info("MouseGesture: \(outcome.label) -> \(outcome.success ? "ok" : "blocked")")
875
- self.recordObservedEvent(
876
- phase: "up",
877
- button: button,
878
- location: snapshot.location,
879
- delta: delta,
880
- modifiers: snapshot.flags,
881
- candidate: triggerEvent.triggerName,
882
- match: match,
883
- note: outcome.success ? "fired" : "blocked",
884
- appInfo: appInfo
885
- )
886
- if !dismissBeforeAction {
887
- session.overlay.commit(
888
- origin: session.startPoint,
889
- direction: direction,
890
- label: outcome.label,
891
- success: outcome.success,
892
- style: self.overlayStyle(for: button),
893
- visual: match?.rule.visual ?? session.visual,
894
- visualPhase: .completed,
895
- shape: nil,
896
- pathPoints: session.pathPoints,
897
- accessory: outcome.accessory
898
- )
899
- }
900
- }
901
- }
902
-
903
- private func performAction(match: MouseShortcutMatchResult?, startPoint: CGPoint) -> GestureOutcome {
904
- guard let match else {
905
- return GestureOutcome(label: "No Shortcut Assigned", success: false, accessory: nil)
906
- }
907
-
908
- switch match.action.type {
909
- case .spacePrevious:
910
- guard WindowTiler.adjacentSpaceTarget(offset: -1, from: startPoint) != nil else {
911
- return GestureOutcome(label: "No Previous Space", success: false, accessory: nil)
912
- }
913
- let switched = WindowTiler.switchToAdjacentSpace(offset: -1, from: startPoint)
914
- return GestureOutcome(label: switched ? "Previous Space" : "Previous Space Blocked", success: switched, accessory: nil)
915
- case .spaceNext:
916
- guard WindowTiler.adjacentSpaceTarget(offset: 1, from: startPoint) != nil else {
917
- return GestureOutcome(label: "No Next Space", success: false, accessory: nil)
918
- }
919
- let switched = WindowTiler.switchToAdjacentSpace(offset: 1, from: startPoint)
920
- return GestureOutcome(label: switched ? "Next Space" : "Next Space Blocked", success: switched, accessory: nil)
921
- case .screenMapToggle:
922
- ScreenMapWindowController.shared.showScreenMapOverview()
923
- return GestureOutcome(label: "Screen Map Overview", success: true, accessory: nil)
924
- case .dictationStart:
925
- let sent = sendDictationShortcut()
926
- return GestureOutcome(
927
- label: sent ? "Dictation" : "Permission Needed",
928
- success: sent,
929
- accessory: sent ? .mic : nil
930
- )
931
- case .shortcutSend:
932
- let sent = sendShortcut(match.action.shortcut)
933
- return GestureOutcome(
934
- label: sent ? match.action.label : "Shortcut Blocked",
935
- success: sent,
936
- accessory: nil
937
- )
938
- case .appActivate:
939
- let activated = activateApplication(named: match.action.app)
940
- let appLabel = match.action.app?.trimmingCharacters(in: .whitespacesAndNewlines)
941
- return GestureOutcome(
942
- label: activated ? "\(appLabel?.isEmpty == false ? appLabel! : "App") Focused" : "App Activation Blocked",
943
- success: activated,
944
- accessory: nil
945
- )
946
- }
947
- }
948
-
949
- private func label(for direction: MouseGestureDirection) -> String {
950
- switch direction {
951
- case .left:
952
- return "Previous Space"
953
- case .right:
954
- return "Next Space"
955
- case .up:
956
- return "Up"
957
- case .down:
958
- return "Screen Map Overview"
959
- }
960
- }
961
-
962
- private func overlayStyle(for button: MouseShortcutButton) -> MouseGestureOverlayStyle {
963
- switch button {
964
- case .button4:
965
- return .thinLine
966
- case .button5:
967
- return .thickLine
968
- case .middle:
969
- return .drawing
970
- case .right, .number:
971
- return .drawing
972
- }
973
- }
974
-
975
- private func clearSession(clearTracking: Bool = true) {
976
- staleSessionTimer?.invalidate()
977
- staleSessionTimer = nil
978
- session?.overlay.dismiss(immediately: true)
979
- session = nil
980
- // Keep tap-side tracking in sync with main-side session lifetime so a
981
- // subsequent drag/up isn't consumed for a session that no longer
982
- // exists.
983
- if clearTracking {
984
- setTrackingButton(nil)
985
- }
986
- }
987
-
988
- private func replayMouseClick(buttonNumber: Int64, at point: CGPoint, flags: CGEventFlags) {
989
- for type in [CGEventType.otherMouseDown, .otherMouseUp] {
990
- guard let mouseButton = CGMouseButton(rawValue: UInt32(buttonNumber)) else { continue }
991
- guard let event = CGEvent(
992
- mouseEventSource: nil,
993
- mouseType: type,
994
- mouseCursorPosition: point,
995
- mouseButton: mouseButton
996
- ) else { continue }
997
-
998
- event.setIntegerValueField(CGEventField.mouseEventButtonNumber, value: buttonNumber)
999
- event.setIntegerValueField(CGEventField.eventSourceUserData, value: Self.syntheticMarker)
1000
- event.flags = flags
1001
- event.post(tap: CGEventTapLocation.cghidEventTap)
1002
- }
1003
- }
1004
-
1005
- private func postSyntheticMouseUp(buttonNumber: Int64, at point: CGPoint, flags: CGEventFlags) {
1006
- guard let mouseButton = CGMouseButton(rawValue: UInt32(buttonNumber)),
1007
- let event = CGEvent(
1008
- mouseEventSource: nil,
1009
- mouseType: .otherMouseUp,
1010
- mouseCursorPosition: point,
1011
- mouseButton: mouseButton
1012
- ) else {
1013
- return
1014
- }
1015
-
1016
- event.setIntegerValueField(CGEventField.mouseEventButtonNumber, value: buttonNumber)
1017
- event.setIntegerValueField(CGEventField.eventSourceUserData, value: Self.syntheticMarker)
1018
- event.flags = flags
1019
- event.post(tap: CGEventTapLocation.cghidEventTap)
1020
- }
1021
-
1022
- private func screen(containing cgPoint: CGPoint) -> NSScreen? {
1023
- let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
1024
- let nsPoint = NSPoint(x: cgPoint.x, y: primaryHeight - cgPoint.y)
1025
- return NSScreen.screens.first(where: { $0.frame.contains(nsPoint) }) ?? NSScreen.main ?? NSScreen.screens.first
1026
- }
1027
-
1028
- private func format(_ point: CGPoint) -> String {
1029
- "\(Int(point.x)),\(Int(point.y))"
1030
- }
1031
-
1032
- private func shouldDismissOverlayBeforeAction(match: MouseShortcutMatchResult?) -> Bool {
1033
- switch match?.action.type {
1034
- case .shortcutSend, .appActivate:
1035
- return match?.rule.visual == nil
1036
- case .spacePrevious, .spaceNext, .screenMapToggle, .dictationStart, .none:
1037
- return true
1038
- }
1039
- }
1040
-
1041
- private func previewProgress(dominantDistance: CGFloat, threshold: CGFloat) -> CGFloat {
1042
- guard dominantDistance > threshold else { return 0 }
1043
- let overshoot = dominantDistance - threshold
1044
- let normalized = min(1, max(0, overshoot / 90))
1045
- return 0.32 + normalized * 0.68
1046
- }
1047
-
1048
- private func retainOverlay(_ overlay: MouseGestureOverlay) {
1049
- retainedOverlays[ObjectIdentifier(overlay)] = overlay
1050
- }
1051
-
1052
- private func releaseOverlay(_ overlay: MouseGestureOverlay) {
1053
- retainedOverlays.removeValue(forKey: ObjectIdentifier(overlay))
1054
- }
1055
-
1056
- private func scheduleStaleSessionCleanup(for session: GestureSession) {
1057
- staleSessionTimer?.invalidate()
1058
- let timer = Timer(timeInterval: 3.0, repeats: false) { [weak self, weak session] _ in
1059
- guard let self, let session, self.session === session else { return }
1060
- DiagnosticLog.shared.warn("MouseGesture: stale gesture session dismissed")
1061
- self.clearSession()
1062
- }
1063
- staleSessionTimer = timer
1064
- RunLoop.main.add(timer, forMode: .common)
1065
- }
1066
-
1067
- private func currentAppInfo() -> (name: String?, bundleId: String?) {
1068
- let app = NSWorkspace.shared.frontmostApplication
1069
- return (app?.localizedName, app?.bundleIdentifier)
1070
- }
1071
-
1072
- private func recordObservedEvent(
1073
- phase: String,
1074
- button: MouseShortcutButton,
1075
- location: CGPoint,
1076
- delta: CGPoint,
1077
- modifiers: CGEventFlags,
1078
- candidate: String?,
1079
- match: MouseShortcutMatchResult?,
1080
- note: String?,
1081
- appInfo: (name: String?, bundleId: String?)
1082
- ) {
1083
- guard MouseInputEventViewer.shared.isCaptureActive else { return }
1084
- let sourceState = Int(modifiers.rawValue)
1085
- MouseInputEventViewer.shared.record(
1086
- MouseShortcutObservedEvent(
1087
- timestamp: Date(),
1088
- phase: phase,
1089
- buttonNumber: button.rawButtonNumber,
1090
- location: location,
1091
- delta: delta,
1092
- modifiers: NSEvent.ModifierFlags(rawValue: UInt(modifiers.rawValue)),
1093
- frontmostAppName: appInfo.name,
1094
- frontmostBundleId: appInfo.bundleId,
1095
- candidateTrigger: candidate,
1096
- device: nil,
1097
- matchedRuleSummary: match?.rule.summary,
1098
- willFire: match != nil,
1099
- note: note.map { "\($0) | flags=\(sourceState)" } ?? "flags=\(sourceState)"
1100
- )
1101
- )
1102
- }
1103
-
1104
- private func sendShortcut(_ shortcut: MouseShortcutKeyStroke?) -> Bool {
1105
- guard let shortcut else { return false }
1106
- if sendShortcutWithCGEvent(shortcut) {
1107
- return true
1108
- }
1109
-
1110
- let modifiers = shortcut.modifiers.map(\.appleScriptToken).joined(separator: ", ")
1111
- let command: String
1112
-
1113
- if let keyCode = shortcut.keyCode {
1114
- command = modifiers.isEmpty
1115
- ? "key code \(keyCode)"
1116
- : "key code \(keyCode) using {\(modifiers)}"
1117
- } else if let key = shortcut.key {
1118
- command = modifiers.isEmpty
1119
- ? "keystroke \"\(key)\""
1120
- : "keystroke \"\(key)\" using {\(modifiers)}"
1121
- } else {
1122
- return false
1123
- }
1124
-
1125
- let script = """
1126
- tell application "System Events"
1127
- \(command)
1128
- end tell
1129
- return "ok"
1130
- """
1131
- let result = ProcessQuery.shell(["/usr/bin/osascript", "-e", script])
1132
- if result != "ok" {
1133
- DiagnosticLog.shared.warn("MouseGesture: AppleScript shortcut send failed for \(shortcut.displayLabel)")
1134
- }
1135
- return result == "ok"
1136
- }
1137
-
1138
- private func sendDictationShortcut() -> Bool {
1139
- sendShortcut(
1140
- MouseShortcutKeyStroke(
1141
- key: "a",
1142
- keyCode: 0,
1143
- modifiers: [.command, .shift]
1144
- )
1145
- )
1146
- }
1147
-
1148
- private func sendShortcutWithCGEvent(_ shortcut: MouseShortcutKeyStroke) -> Bool {
1149
- guard let keyCode = shortcut.keyCode.map(CGKeyCode.init) ?? keyCode(for: shortcut.key) else {
1150
- return false
1151
- }
1152
- guard let source = CGEventSource(stateID: .combinedSessionState),
1153
- let down = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
1154
- let up = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) else {
1155
- DiagnosticLog.shared.warn("MouseGesture: CGEvent shortcut source unavailable for \(shortcut.displayLabel)")
1156
- return false
1157
- }
1158
-
1159
- let flags = cgEventFlags(for: shortcut.modifiers)
1160
- down.flags = flags
1161
- up.flags = flags
1162
- down.setIntegerValueField(.eventSourceUserData, value: Self.syntheticMarker)
1163
- up.setIntegerValueField(.eventSourceUserData, value: Self.syntheticMarker)
1164
- down.post(tap: .cghidEventTap)
1165
- usleep(12_000)
1166
- up.post(tap: .cghidEventTap)
1167
- return true
1168
- }
1169
-
1170
- private func cgEventFlags(for modifiers: [MouseShortcutModifier]) -> CGEventFlags {
1171
- var flags: CGEventFlags = []
1172
- for modifier in modifiers {
1173
- switch modifier {
1174
- case .command:
1175
- flags.insert(.maskCommand)
1176
- case .option:
1177
- flags.insert(.maskAlternate)
1178
- case .control:
1179
- flags.insert(.maskControl)
1180
- case .shift:
1181
- flags.insert(.maskShift)
1182
- }
1183
- }
1184
- return flags
1185
- }
1186
-
1187
- private func keyCode(for key: String?) -> CGKeyCode? {
1188
- guard let key = key?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
1189
- !key.isEmpty else {
1190
- return nil
1191
- }
1192
- let codes: [String: CGKeyCode] = [
1193
- "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7,
1194
- "c": 8, "v": 9, "b": 11, "q": 12, "w": 13, "e": 14, "r": 15,
1195
- "y": 16, "t": 17, "1": 18, "2": 19, "3": 20, "4": 21, "6": 22,
1196
- "5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29,
1197
- "]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "enter": 36,
1198
- "return": 36, "l": 37, "j": 38, "'": 39, "k": 40, ";": 41,
1199
- "\\": 42, ",": 43, "/": 44, "n": 45, "m": 46, ".": 47, "tab": 48,
1200
- "space": 49, "`": 50, "delete": 51, "backspace": 51, "escape": 53,
1201
- "esc": 53, "left": 123, "right": 124, "down": 125, "up": 126,
1202
- ]
1203
- return codes[key]
1204
- }
1205
-
1206
- private func activateApplication(named appName: String?) -> Bool {
1207
- guard let appName, !appName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
1208
- return false
1209
- }
1210
-
1211
- if let running = NSWorkspace.shared.runningApplications.first(where: { app in
1212
- app.localizedName?.localizedCaseInsensitiveCompare(appName) == .orderedSame
1213
- || app.bundleIdentifier?.localizedCaseInsensitiveContains(appName) == true
1214
- }) {
1215
- return running.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
1216
- }
1217
-
1218
- let fileManager = FileManager.default
1219
- let trimmedName = appName.trimmingCharacters(in: .whitespacesAndNewlines)
1220
- let appFilenames = trimmedName.hasSuffix(".app") ? [trimmedName] : [trimmedName + ".app", trimmedName]
1221
- let roots = [
1222
- "/Applications",
1223
- "/System/Applications",
1224
- fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Applications").path,
1225
- ]
1226
-
1227
- for root in roots {
1228
- for filename in appFilenames {
1229
- let url = URL(fileURLWithPath: root).appendingPathComponent(filename)
1230
- guard fileManager.fileExists(atPath: url.path) else { continue }
1231
- NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration())
1232
- return true
1233
- }
1234
- }
1235
-
1236
- _ = ProcessQuery.shell(["/usr/bin/open", "-a", trimmedName])
1237
- return true
1238
- }
1239
- }
1240
-
1241
- private final class MouseGestureOverlay {
1242
- private let committedHoldDuration: TimeInterval = 0.0
1243
- private let fadeDuration: TimeInterval = 0.03
1244
- private let accessoryCommittedHoldDuration: TimeInterval = 0.0
1245
- private let accessoryAnimationDuration: TimeInterval = 0.10
1246
-
1247
- private let screen: NSScreen
1248
- private let window: NSWindow
1249
- private let overlayView: MouseGestureOverlayView
1250
- private var fadeTimer: Timer?
1251
- var onDismiss: (() -> Void)?
1252
-
1253
- init(screen: NSScreen) {
1254
- self.screen = screen
1255
- self.window = NSWindow(
1256
- contentRect: screen.frame,
1257
- styleMask: .borderless,
1258
- backing: .buffered,
1259
- defer: false
1260
- )
1261
- self.overlayView = MouseGestureOverlayView(frame: NSRect(origin: .zero, size: screen.frame.size))
1262
-
1263
- window.isOpaque = false
1264
- window.backgroundColor = .clear
1265
- window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
1266
- window.hasShadow = false
1267
- window.ignoresMouseEvents = true
1268
- window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
1269
- window.contentView = overlayView
1270
- window.orderFrontRegardless()
1271
- }
1272
-
1273
- func track(
1274
- origin: CGPoint,
1275
- direction: MouseGestureDirection?,
1276
- label: String?,
1277
- style: MouseGestureOverlayStyle,
1278
- visual: MouseShortcutVisualDefinition?,
1279
- visualPhase: MouseGestureVisualPhase,
1280
- shape: GestureShapeLabel?,
1281
- success: Bool?,
1282
- pathPoints: [GesturePathPoint],
1283
- progress: CGFloat
1284
- ) {
1285
- fadeTimer?.invalidate()
1286
- window.alphaValue = 1
1287
- let localPath = localPath(from: pathPoints)
1288
- if direction != nil || localPath.count > 1 {
1289
- overlayView.state = .tracking(
1290
- origin: localPoint(from: origin),
1291
- direction: direction,
1292
- label: label,
1293
- style: style,
1294
- visual: visual,
1295
- visualPhase: visualPhase,
1296
- shape: shape,
1297
- success: success,
1298
- path: localPath,
1299
- progress: progress
1300
- )
1301
- } else {
1302
- overlayView.state = .idle
1303
- }
1304
- overlayView.needsDisplay = true
1305
- }
1306
-
1307
- func commit(
1308
- origin: CGPoint,
1309
- direction: MouseGestureDirection,
1310
- label: String,
1311
- success: Bool,
1312
- style: MouseGestureOverlayStyle,
1313
- visual: MouseShortcutVisualDefinition?,
1314
- visualPhase: MouseGestureVisualPhase,
1315
- shape: GestureShapeLabel?,
1316
- pathPoints: [GesturePathPoint],
1317
- accessory: MouseGestureAccessory?
1318
- ) {
1319
- fadeTimer?.invalidate()
1320
- window.alphaValue = 1
1321
- overlayView.state = .committed(
1322
- origin: localPoint(from: origin),
1323
- direction: direction,
1324
- label: label,
1325
- success: success,
1326
- style: style,
1327
- visual: visual,
1328
- visualPhase: visualPhase,
1329
- shape: shape,
1330
- path: localPath(from: pathPoints),
1331
- accessory: accessory,
1332
- accessoryAnimationDuration: accessoryAnimationDuration
1333
- )
1334
- overlayView.needsDisplay = true
1335
-
1336
- let postReplayHoldDuration = accessory == nil ? committedHoldDuration : accessoryCommittedHoldDuration
1337
- let totalVisibleDuration = overlayView.replayLeadInDuration + postReplayHoldDuration
1338
- let timer = Timer(timeInterval: totalVisibleDuration, repeats: false) { [weak self] _ in
1339
- self?.dismiss()
1340
- }
1341
- fadeTimer = timer
1342
- RunLoop.main.add(timer, forMode: .common)
1343
- }
1344
-
1345
- func dismiss(immediately: Bool = false) {
1346
- fadeTimer?.invalidate()
1347
- fadeTimer = nil
1348
-
1349
- if immediately {
1350
- window.orderOut(nil)
1351
- finishDismissal()
1352
- return
1353
- }
1354
-
1355
- NSAnimationContext.runAnimationGroup({ ctx in
1356
- ctx.duration = fadeDuration
1357
- window.animator().alphaValue = 0
1358
- }, completionHandler: { [weak self] in
1359
- self?.window.orderOut(nil)
1360
- self?.finishDismissal()
1361
- })
1362
- }
1363
-
1364
- private func localPoint(from cgPoint: CGPoint) -> CGPoint {
1365
- let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height
1366
- let nsY = primaryHeight - cgPoint.y
1367
- return CGPoint(x: cgPoint.x - screen.frame.minX, y: nsY - screen.frame.minY)
1368
- }
1369
-
1370
- private func localPath(from pathPoints: [GesturePathPoint]) -> [CGPoint] {
1371
- pathPoints.map { localPoint(from: $0.cgPoint) }
1372
- }
1373
-
1374
- private func finishDismissal() {
1375
- let callback = onDismiss
1376
- onDismiss = nil
1377
- callback?()
1378
- }
1379
- }
1380
-
1381
- private final class MouseGestureOverlayView: NSView {
1382
- private let theme = MouseGestureOverlayTheme.graffiti
1383
-
1384
- enum State {
1385
- case idle
1386
- case tracking(
1387
- origin: CGPoint,
1388
- direction: MouseGestureDirection?,
1389
- label: String?,
1390
- style: MouseGestureOverlayStyle,
1391
- visual: MouseShortcutVisualDefinition?,
1392
- visualPhase: MouseGestureVisualPhase,
1393
- shape: GestureShapeLabel?,
1394
- success: Bool?,
1395
- path: [CGPoint],
1396
- progress: CGFloat
1397
- )
1398
- case committed(
1399
- origin: CGPoint,
1400
- direction: MouseGestureDirection,
1401
- label: String,
1402
- success: Bool,
1403
- style: MouseGestureOverlayStyle,
1404
- visual: MouseShortcutVisualDefinition?,
1405
- visualPhase: MouseGestureVisualPhase,
1406
- shape: GestureShapeLabel?,
1407
- path: [CGPoint],
1408
- accessory: MouseGestureAccessory?,
1409
- accessoryAnimationDuration: TimeInterval
1410
- )
1411
- }
1412
-
1413
- var state: State = .idle {
1414
- didSet {
1415
- updateArrowAnimation(from: oldValue, to: state)
1416
- updateAccessoryAnimation()
1417
- }
1418
- }
1419
- private var accessoryAnimationTimer: Timer?
1420
- private var accessoryAnimationStartedAt: Date?
1421
- private var accessoryAnimationDuration: TimeInterval = 0
1422
- private var arrowAnimationTimer: Timer?
1423
- private var arrowAnimationStartedAt: Date?
1424
- private var arrowAnimationDuration: TimeInterval = 0
1425
- private var committedStartProgress: CGFloat = 0
1426
- private var accessoryAnimationDelay: TimeInterval = 0
1427
- private let committedArrowAnimationDuration: TimeInterval = 0.06
1428
- private let matrixCompletionAnimationDuration: TimeInterval = 0.30
1429
- private let arrowAnimationDelay: TimeInterval = 0.012
1430
- private let labelRevealThreshold: CGFloat = 0.8
1431
-
1432
- var replayLeadInDuration: TimeInterval {
1433
- if case .committed(_, _, _, _, _, let visual, _, let shape, let path, _, _) = state,
1434
- shape != nil,
1435
- path.count > 1,
1436
- shouldDrawMatrixCompletion(visual) {
1437
- return matrixCompletionAnimationDuration + 0.05
1438
- }
1439
- if committedStartProgress >= labelRevealThreshold {
1440
- return 0
1441
- }
1442
- let remainingProgress = max(0, 1 - committedStartProgress)
1443
- return arrowAnimationDelay + committedArrowAnimationDuration * remainingProgress
1444
- }
1445
-
1446
- override var isFlipped: Bool {
1447
- false
1448
- }
1449
-
1450
- override func viewDidMoveToWindow() {
1451
- super.viewDidMoveToWindow()
1452
- if window == nil {
1453
- arrowAnimationTimer?.invalidate()
1454
- arrowAnimationTimer = nil
1455
- accessoryAnimationTimer?.invalidate()
1456
- accessoryAnimationTimer = nil
1457
- }
1458
- }
1459
-
1460
- override func draw(_ dirtyRect: NSRect) {
1461
- guard let ctx = NSGraphicsContext.current?.cgContext else { return }
1462
- ctx.clear(bounds)
1463
-
1464
- switch state {
1465
- case .idle:
1466
- break
1467
- case .tracking(let origin, let direction, let label, let style, let visual, let visualPhase, let shape, let success, let path, let progress):
1468
- drawOrigin(at: origin, in: ctx, alpha: 0.88)
1469
- if path.count > 1 {
1470
- drawGesturePath(
1471
- path,
1472
- fallbackOrigin: origin,
1473
- direction: direction,
1474
- label: label,
1475
- success: true,
1476
- committed: false,
1477
- style: style,
1478
- accessory: nil,
1479
- progressOverride: progress,
1480
- in: ctx
1481
- )
1482
- drawVisualPOCIfNeeded(
1483
- visual,
1484
- phase: visualPhase,
1485
- shape: shape,
1486
- success: success,
1487
- points: path,
1488
- label: label,
1489
- in: ctx
1490
- )
1491
- } else if let direction {
1492
- drawArrow(
1493
- from: origin,
1494
- direction: direction,
1495
- label: label,
1496
- success: true,
1497
- committed: false,
1498
- style: style,
1499
- accessory: nil,
1500
- progressOverride: progress,
1501
- in: ctx
1502
- )
1503
- }
1504
- case .committed(let origin, let direction, let label, let success, let style, let visual, let visualPhase, let shape, let path, let accessory, _):
1505
- if path.count > 1 {
1506
- if shape != nil, shouldDrawMatrixCompletion(visual) {
1507
- drawMatrixGestureCompletion(
1508
- path,
1509
- label: label,
1510
- success: success,
1511
- direction: direction,
1512
- accessory: accessory,
1513
- in: ctx
1514
- )
1515
- } else {
1516
- drawOrigin(at: origin, in: ctx, alpha: 1.0)
1517
- drawGesturePath(
1518
- path,
1519
- fallbackOrigin: origin,
1520
- direction: direction,
1521
- label: label,
1522
- success: success,
1523
- committed: true,
1524
- style: style,
1525
- accessory: accessory,
1526
- progressOverride: nil,
1527
- in: ctx
1528
- )
1529
- }
1530
- drawVisualPOCIfNeeded(
1531
- visual,
1532
- phase: visualPhase,
1533
- shape: shape,
1534
- success: success,
1535
- points: path,
1536
- label: label,
1537
- in: ctx
1538
- )
1539
- } else {
1540
- drawOrigin(at: origin, in: ctx, alpha: 1.0)
1541
- drawArrow(
1542
- from: origin,
1543
- direction: direction,
1544
- label: label,
1545
- success: success,
1546
- committed: true,
1547
- style: style,
1548
- accessory: accessory,
1549
- progressOverride: nil,
1550
- in: ctx
1551
- )
1552
- }
1553
- }
1554
- }
1555
-
1556
- private func drawOrigin(at point: CGPoint, in ctx: CGContext, alpha: CGFloat) {
1557
- ctx.setFillColor(NSColor(calibratedRed: 0.48, green: 0.76, blue: 1.0, alpha: alpha * 0.18).cgColor)
1558
- ctx.fillEllipse(in: CGRect(x: point.x - 18, y: point.y - 18, width: 36, height: 36))
1559
-
1560
- ctx.setFillColor(NSColor(calibratedRed: 0.62, green: 0.84, blue: 1.0, alpha: alpha * 0.95).cgColor)
1561
- ctx.fillEllipse(in: CGRect(x: point.x - 5, y: point.y - 5, width: 10, height: 10))
1562
- }
1563
-
1564
- private func drawArrow(
1565
- from origin: CGPoint,
1566
- direction: MouseGestureDirection,
1567
- label: String?,
1568
- success: Bool,
1569
- committed: Bool,
1570
- style: MouseGestureOverlayStyle,
1571
- accessory: MouseGestureAccessory?,
1572
- progressOverride: CGFloat?,
1573
- in ctx: CGContext
1574
- ) {
1575
- let baseLength: CGFloat = 118
1576
- let arrowProgress = progressOverride ?? currentArrowProgress(committed: committed)
1577
- let clampedProgress = min(1, max(0, arrowProgress))
1578
- let length = baseLength * (committed ? max(0.14, clampedProgress) : (0.34 + 0.66 * clampedProgress))
1579
- let vector = arrowVector(for: direction, length: length)
1580
- let end = CGPoint(x: origin.x + vector.x, y: origin.y + vector.y)
1581
- let accent = success
1582
- ? NSColor(calibratedRed: 0.45, green: 0.80, blue: 1.0, alpha: 1.0)
1583
- : NSColor(calibratedRed: 0.98, green: 0.52, blue: 0.42, alpha: 1.0)
1584
- let strokeAlpha = committed ? 0.92 : (0.48 + 0.44 * clampedProgress)
1585
- let glowAlpha = committed ? 0.2 : (0.08 + 0.08 * clampedProgress)
1586
- let metrics = arrowMetrics(for: style)
1587
-
1588
- ctx.saveGState()
1589
- ctx.setLineCap(.round)
1590
-
1591
- let glowPath = CGMutablePath()
1592
- glowPath.move(to: origin)
1593
- glowPath.addLine(to: end)
1594
- ctx.addPath(glowPath)
1595
- ctx.setLineWidth(metrics.glowWidth)
1596
- ctx.setStrokeColor(accent.withAlphaComponent(glowAlpha).cgColor)
1597
- ctx.strokePath()
1598
-
1599
- let linePath = CGMutablePath()
1600
- linePath.move(to: origin)
1601
- linePath.addLine(to: end)
1602
- ctx.addPath(linePath)
1603
- ctx.setLineWidth(metrics.lineWidth)
1604
- ctx.setStrokeColor(accent.withAlphaComponent(strokeAlpha).cgColor)
1605
- ctx.strokePath()
1606
-
1607
- drawArrowHead(at: end, direction: direction, color: accent, size: metrics.headSize)
1608
- if let label, (!committed || clampedProgress >= labelRevealThreshold) {
1609
- drawLabel(label, from: origin, to: end, direction: direction, color: accent)
1610
- }
1611
- if committed, let accessory, clampedProgress >= labelRevealThreshold {
1612
- drawAccessory(accessory, from: origin, to: end, direction: direction, color: accent, in: ctx)
1613
- }
1614
- ctx.restoreGState()
1615
- }
1616
-
1617
- private func drawGesturePath(
1618
- _ points: [CGPoint],
1619
- fallbackOrigin: CGPoint,
1620
- direction: MouseGestureDirection?,
1621
- label: String?,
1622
- success: Bool,
1623
- committed: Bool,
1624
- style: MouseGestureOverlayStyle,
1625
- accessory: MouseGestureAccessory?,
1626
- progressOverride: CGFloat?,
1627
- in ctx: CGContext
1628
- ) {
1629
- guard points.count > 1 else { return }
1630
- let accent = success ? theme.accent : theme.failure
1631
- let stroke = success ? theme.graphite : theme.failure
1632
- let metrics = arrowMetrics(for: style)
1633
- let pathProgress = progressOverride ?? currentArrowProgress(committed: committed)
1634
- let clampedProgress = min(1, max(0, pathProgress))
1635
- let strokeAlpha = committed ? 0.92 : (0.48 + 0.44 * clampedProgress)
1636
- let glowAlpha = committed ? 0.28 : (0.12 + 0.12 * clampedProgress)
1637
- let visiblePoints = visiblePathPoints(points, progress: committed ? clampedProgress : 1)
1638
- guard visiblePoints.count > 1 else { return }
1639
-
1640
- let path = smoothedGesturePath(from: visiblePoints)
1641
-
1642
- ctx.saveGState()
1643
- ctx.setLineCap(.round)
1644
- ctx.setLineJoin(.round)
1645
-
1646
- drawGestureGuideDots(around: visiblePoints, accent: accent, in: ctx)
1647
-
1648
- ctx.addPath(path)
1649
- ctx.setLineWidth(metrics.glowWidth + 10)
1650
- ctx.setStrokeColor(theme.graphiteDark.withAlphaComponent(committed ? 0.44 : 0.30).cgColor)
1651
- ctx.strokePath()
1652
-
1653
- ctx.addPath(path)
1654
- ctx.setLineWidth(metrics.glowWidth)
1655
- ctx.setStrokeColor(accent.withAlphaComponent(glowAlpha).cgColor)
1656
- ctx.strokePath()
1657
-
1658
- ctx.addPath(path)
1659
- ctx.setLineWidth(metrics.lineWidth)
1660
- ctx.setStrokeColor(stroke.withAlphaComponent(strokeAlpha).cgColor)
1661
- ctx.strokePath()
1662
-
1663
- ctx.addPath(path)
1664
- ctx.setLineWidth(max(1, metrics.lineWidth * 0.28))
1665
- ctx.setStrokeColor((success ? theme.highlight : accent).withAlphaComponent(success ? strokeAlpha * 0.62 : strokeAlpha).cgColor)
1666
- ctx.strokePath()
1667
-
1668
- let end = visiblePoints.last ?? fallbackOrigin
1669
- let resolvedDirection = direction ?? pathDirection(from: visiblePoints)
1670
- if let resolvedDirection {
1671
- drawArrowHead(at: end, direction: resolvedDirection, color: stroke, size: metrics.headSize)
1672
- if let label, (!committed || clampedProgress >= labelRevealThreshold) {
1673
- drawLabel(label, from: fallbackOrigin, to: end, direction: resolvedDirection, color: accent)
1674
- }
1675
- if committed, let accessory, clampedProgress >= labelRevealThreshold {
1676
- drawAccessory(accessory, from: fallbackOrigin, to: end, direction: resolvedDirection, color: accent, in: ctx)
1677
- }
1678
- }
1679
- ctx.restoreGState()
1680
- }
1681
-
1682
- private func drawMatrixGestureCompletion(
1683
- _ points: [CGPoint],
1684
- label: String,
1685
- success: Bool,
1686
- direction: MouseGestureDirection,
1687
- accessory: MouseGestureAccessory?,
1688
- in ctx: CGContext
1689
- ) {
1690
- guard points.count > 1 else { return }
1691
- let progress = min(1, max(0, currentArrowProgress(committed: true)))
1692
- let cleanedPoints = cleanedMatrixGesturePoints(points)
1693
- let matrixRect = matrixGestureRect(for: cleanedPoints)
1694
- let transformedPoints = transformGesturePoints(cleanedPoints, into: matrixRect.insetBy(dx: 16, dy: 16))
1695
- let visiblePoints = visiblePathPoints(transformedPoints, progress: progress)
1696
- let accent = success ? theme.accent : theme.failure
1697
- let activeCells = matrixCellsTouched(by: visiblePoints, in: matrixRect)
1698
- let pulsePoint = visiblePoints.last ?? transformedPoints.last ?? CGPoint(x: matrixRect.midX, y: matrixRect.midY)
1699
- let completionAlpha = min(1, max(0, (progress - 0.68) / 0.22))
1700
-
1701
- ctx.saveGState()
1702
-
1703
- let halo = NSBezierPath(roundedRect: matrixRect.insetBy(dx: -9, dy: -9), xRadius: 16, yRadius: 16)
1704
- theme.graphiteDark.withAlphaComponent(0.20 + 0.14 * completionAlpha).setFill()
1705
- halo.fill()
1706
-
1707
- drawMatrixCells(
1708
- in: matrixRect,
1709
- activeCells: activeCells,
1710
- completionAlpha: completionAlpha,
1711
- accent: accent,
1712
- success: success,
1713
- context: ctx
1714
- )
1715
-
1716
- if visiblePoints.count > 1 {
1717
- let replayPath = smoothedGesturePath(from: visiblePoints)
1718
- ctx.addPath(replayPath)
1719
- ctx.setLineCap(.round)
1720
- ctx.setLineJoin(.round)
1721
- ctx.setLineWidth(14)
1722
- ctx.setStrokeColor(accent.withAlphaComponent(0.16 + 0.16 * completionAlpha).cgColor)
1723
- ctx.strokePath()
1724
-
1725
- ctx.addPath(replayPath)
1726
- ctx.setLineWidth(5)
1727
- ctx.setStrokeColor(accent.withAlphaComponent(0.72).cgColor)
1728
- ctx.strokePath()
1729
-
1730
- ctx.addPath(replayPath)
1731
- ctx.setLineWidth(1.8)
1732
- ctx.setStrokeColor(theme.highlight.withAlphaComponent(0.86).cgColor)
1733
- ctx.strokePath()
1734
- }
1735
-
1736
- let pulseRadius = 8 + 10 * easeOut(progress)
1737
- ctx.setFillColor(accent.withAlphaComponent(0.18 * (1 - completionAlpha * 0.45)).cgColor)
1738
- ctx.fillEllipse(in: CGRect(
1739
- x: pulsePoint.x - pulseRadius,
1740
- y: pulsePoint.y - pulseRadius,
1741
- width: pulseRadius * 2,
1742
- height: pulseRadius * 2
1743
- ))
1744
- ctx.setFillColor(theme.highlight.withAlphaComponent(0.96).cgColor)
1745
- ctx.fillEllipse(in: CGRect(x: pulsePoint.x - 3, y: pulsePoint.y - 3, width: 6, height: 6))
1746
-
1747
- if completionAlpha > 0.1 {
1748
- drawMatrixConfirmationGlyph(in: matrixRect, alpha: completionAlpha, accent: accent, context: ctx)
1749
- }
1750
-
1751
- if progress >= 0.76 {
1752
- let labelAlpha = min(1, max(0, (progress - 0.76) / 0.18))
1753
- drawMatrixLabel(label, near: matrixRect, alpha: labelAlpha, accent: accent)
1754
- }
1755
-
1756
- if let accessory, progress >= labelRevealThreshold {
1757
- drawAccessory(accessory, from: CGPoint(x: matrixRect.midX, y: matrixRect.midY), to: pulsePoint, direction: direction, color: accent, in: ctx)
1758
- }
1759
-
1760
- ctx.restoreGState()
1761
- }
1762
-
1763
- private func drawMatrixCells(
1764
- in rect: CGRect,
1765
- activeCells: Set<Int>,
1766
- completionAlpha: CGFloat,
1767
- accent: NSColor,
1768
- success: Bool,
1769
- context ctx: CGContext
1770
- ) {
1771
- let cellSize: CGFloat = 13
1772
- let gap: CGFloat = 7
1773
- let gridSize = cellSize * 3 + gap * 2
1774
- let startX = rect.midX - gridSize / 2
1775
- let startY = rect.midY - gridSize / 2
1776
- let logoCells: Set<Int> = [0, 3, 6, 7, 8]
1777
-
1778
- for row in 0..<3 {
1779
- for col in 0..<3 {
1780
- let idx = row * 3 + col
1781
- let x = startX + CGFloat(col) * (cellSize + gap)
1782
- let y = startY + CGFloat(row) * (cellSize + gap)
1783
- let cellRect = CGRect(x: x, y: y, width: cellSize, height: cellSize)
1784
- let isActive = activeCells.contains(idx)
1785
- let isLogoCell = logoCells.contains(idx)
1786
- let baseAlpha: CGFloat = isLogoCell ? 0.20 : 0.11
1787
- let activeAlpha: CGFloat = success ? 0.88 : 0.70
1788
- let snapAlpha = isLogoCell ? completionAlpha * 0.36 : 0
1789
- let fillAlpha = max(baseAlpha + snapAlpha, isActive ? activeAlpha : baseAlpha)
1790
- let fill = isActive ? accent : theme.highlight
1791
-
1792
- let glow = NSBezierPath(roundedRect: cellRect.insetBy(dx: -4, dy: -4), xRadius: 6, yRadius: 6)
1793
- accent.withAlphaComponent(isActive ? 0.14 : snapAlpha * 0.16).setFill()
1794
- glow.fill()
1795
-
1796
- let cell = NSBezierPath(roundedRect: cellRect, xRadius: 3, yRadius: 3)
1797
- fill.withAlphaComponent(fillAlpha).setFill()
1798
- cell.fill()
1799
- }
1800
- }
1801
- }
1802
-
1803
- private func drawMatrixConfirmationGlyph(in rect: CGRect, alpha: CGFloat, accent: NSColor, context ctx: CGContext) {
1804
- let path = CGMutablePath()
1805
- let x0 = rect.midX + 23
1806
- let y0 = rect.midY + 22
1807
- path.move(to: CGPoint(x: x0, y: y0))
1808
- path.addLine(to: CGPoint(x: x0, y: rect.midY - 24))
1809
- path.addLine(to: CGPoint(x: rect.midX - 23, y: rect.midY - 24))
1810
-
1811
- ctx.addPath(path)
1812
- ctx.setLineCap(.round)
1813
- ctx.setLineJoin(.round)
1814
- ctx.setLineWidth(3)
1815
- ctx.setStrokeColor(accent.withAlphaComponent(0.54 * alpha).cgColor)
1816
- ctx.strokePath()
1817
-
1818
- let arrow = NSBezierPath()
1819
- let end = CGPoint(x: rect.midX - 23, y: rect.midY - 24)
1820
- arrow.move(to: end)
1821
- arrow.line(to: CGPoint(x: end.x + 8, y: end.y + 6))
1822
- arrow.move(to: end)
1823
- arrow.line(to: CGPoint(x: end.x + 8, y: end.y - 6))
1824
- accent.withAlphaComponent(0.70 * alpha).setStroke()
1825
- arrow.lineWidth = 2
1826
- arrow.lineCapStyle = .round
1827
- arrow.stroke()
1828
- }
1829
-
1830
- private func drawMatrixLabel(_ label: String, near rect: CGRect, alpha: CGFloat, accent: NSColor) {
1831
- let font = NSFont.monospacedSystemFont(ofSize: 9, weight: .bold)
1832
- let display = labelComponents(for: label).title
1833
- let attributed = NSAttributedString(
1834
- string: display,
1835
- attributes: [
1836
- .font: font,
1837
- .foregroundColor: theme.highlight.withAlphaComponent(0.94 * alpha),
1838
- ]
1839
- )
1840
- let size = attributed.size()
1841
- let bubbleRect = CGRect(
1842
- x: rect.midX - (size.width + 16) / 2,
1843
- y: rect.minY - size.height - 13,
1844
- width: size.width + 16,
1845
- height: size.height + 7
1846
- )
1847
- let bubble = NSBezierPath(roundedRect: bubbleRect, xRadius: 8, yRadius: 8)
1848
- theme.graphiteDark.withAlphaComponent(0.78 * alpha).setFill()
1849
- bubble.fill()
1850
- accent.withAlphaComponent(0.38 * alpha).setStroke()
1851
- bubble.lineWidth = 1
1852
- bubble.stroke()
1853
- attributed.draw(in: CGRect(
1854
- x: bubbleRect.minX + 8,
1855
- y: bubbleRect.minY + 3.5,
1856
- width: size.width,
1857
- height: size.height
1858
- ))
1859
- }
1860
-
1861
- private func cleanedMatrixGesturePoints(_ points: [CGPoint]) -> [CGPoint] {
1862
- let simplified = simplifyGesturePoints(points, minimumDistance: 5)
1863
- guard simplified.count > 2 else { return simplified }
1864
-
1865
- var cleaned: [CGPoint] = []
1866
- for index in simplified.indices {
1867
- let previous = simplified[max(index - 1, simplified.startIndex)]
1868
- let current = simplified[index]
1869
- let next = simplified[min(index + 1, simplified.index(before: simplified.endIndex))]
1870
- cleaned.append(CGPoint(
1871
- x: (previous.x + current.x * 2 + next.x) / 4,
1872
- y: (previous.y + current.y * 2 + next.y) / 4
1873
- ))
1874
- }
1875
- return cleaned
1876
- }
1877
-
1878
- private func simplifyGesturePoints(_ points: [CGPoint], minimumDistance: CGFloat) -> [CGPoint] {
1879
- guard var last = points.first else { return [] }
1880
- var result = [last]
1881
- for point in points.dropFirst() {
1882
- let dx = point.x - last.x
1883
- let dy = point.y - last.y
1884
- if sqrt(dx * dx + dy * dy) >= minimumDistance {
1885
- result.append(point)
1886
- last = point
1887
- }
1888
- }
1889
- if let final = points.last, result.last != final {
1890
- result.append(final)
1891
- }
1892
- return result
1893
- }
1894
-
1895
- private func matrixGestureRect(for points: [CGPoint]) -> CGRect {
1896
- let end = points.last ?? CGPoint(x: bounds.midX, y: bounds.midY)
1897
- let size = CGSize(width: 88, height: 88)
1898
- var origin = CGPoint(x: end.x + 24, y: end.y + 18)
1899
-
1900
- if origin.x + size.width > bounds.width - 12 {
1901
- origin.x = end.x - size.width - 24
1902
- }
1903
- if origin.y + size.height > bounds.height - 12 {
1904
- origin.y = end.y - size.height - 18
1905
- }
1906
- origin.x = min(max(origin.x, 12), max(12, bounds.width - size.width - 12))
1907
- origin.y = min(max(origin.y, 12), max(12, bounds.height - size.height - 12))
1908
-
1909
- return CGRect(origin: origin, size: size)
1910
- }
1911
-
1912
- private func transformGesturePoints(_ points: [CGPoint], into rect: CGRect) -> [CGPoint] {
1913
- guard !points.isEmpty else { return [] }
1914
- let minX = points.map(\.x).min() ?? 0
1915
- let maxX = points.map(\.x).max() ?? minX
1916
- let minY = points.map(\.y).min() ?? 0
1917
- let maxY = points.map(\.y).max() ?? minY
1918
- let sourceWidth = max(maxX - minX, 1)
1919
- let sourceHeight = max(maxY - minY, 1)
1920
- let scale = min(rect.width / sourceWidth, rect.height / sourceHeight)
1921
- let scaledWidth = sourceWidth * scale
1922
- let scaledHeight = sourceHeight * scale
1923
- let offsetX = rect.midX - scaledWidth / 2
1924
- let offsetY = rect.midY - scaledHeight / 2
1925
-
1926
- return points.map { point in
1927
- CGPoint(
1928
- x: offsetX + (point.x - minX) * scale,
1929
- y: offsetY + (point.y - minY) * scale
1930
- )
1931
- }
1932
- }
1933
-
1934
- private func matrixCellsTouched(by points: [CGPoint], in rect: CGRect) -> Set<Int> {
1935
- guard !points.isEmpty else { return [] }
1936
- let cellSize: CGFloat = 13
1937
- let gap: CGFloat = 7
1938
- let gridSize = cellSize * 3 + gap * 2
1939
- let startX = rect.midX - gridSize / 2
1940
- let startY = rect.midY - gridSize / 2
1941
- let step = cellSize + gap
1942
-
1943
- var touched = Set<Int>()
1944
- for point in points {
1945
- let col = min(2, max(0, Int(round((point.x - startX - cellSize / 2) / step))))
1946
- let row = min(2, max(0, Int(round((point.y - startY - cellSize / 2) / step))))
1947
- touched.insert(row * 3 + col)
1948
- }
1949
- return touched
1950
- }
1951
-
1952
- private func drawGestureGuideDots(around points: [CGPoint], accent: NSColor, in ctx: CGContext) {
1953
- guard !points.isEmpty else { return }
1954
- let minX = (points.map(\.x).min() ?? 0) - 34
1955
- let maxX = (points.map(\.x).max() ?? 0) + 34
1956
- let minY = (points.map(\.y).min() ?? 0) - 34
1957
- let maxY = (points.map(\.y).max() ?? 0) + 34
1958
- let spacing: CGFloat = 34
1959
- let dotRadius: CGFloat = 2.2
1960
- let startX = floor(minX / spacing) * spacing
1961
- let startY = floor(minY / spacing) * spacing
1962
-
1963
- var y = startY
1964
- while y <= maxY {
1965
- var x = startX
1966
- while x <= maxX {
1967
- let point = CGPoint(x: x, y: y)
1968
- let distance = nearestDistance(from: point, to: points)
1969
- let closeness = max(0, 1 - min(distance / 96, 1))
1970
- let alpha = 0.08 + closeness * 0.20
1971
- let radius = dotRadius + closeness * 1.2
1972
- ctx.setFillColor(accent.withAlphaComponent(alpha).cgColor)
1973
- ctx.fillEllipse(in: CGRect(x: x - radius, y: y - radius, width: radius * 2, height: radius * 2))
1974
- x += spacing
1975
- }
1976
- y += spacing
1977
- }
1978
- }
1979
-
1980
- private func nearestDistance(from point: CGPoint, to points: [CGPoint]) -> CGFloat {
1981
- points.reduce(CGFloat.greatestFiniteMagnitude) { nearest, candidate in
1982
- let dx = point.x - candidate.x
1983
- let dy = point.y - candidate.y
1984
- return min(nearest, sqrt(dx * dx + dy * dy))
1985
- }
1986
- }
1987
-
1988
- private func drawVisualPOCIfNeeded(
1989
- _ visual: MouseShortcutVisualDefinition?,
1990
- phase: MouseGestureVisualPhase,
1991
- shape: GestureShapeLabel?,
1992
- success: Bool?,
1993
- points: [CGPoint],
1994
- label: String?,
1995
- in ctx: CGContext
1996
- ) {
1997
- guard let visual, visual.isLottiePOC, let end = points.last else { return }
1998
- let marker = visual.marker(phase: phase.rawValue, shape: shape, success: success) ?? fallbackMarker(phase: phase, success: success)
1999
- let previous = points.dropLast().last ?? end
2000
- let velocity = CGPoint(x: end.x - previous.x, y: end.y - previous.y)
2001
- let speed = min(1, sqrt(velocity.x * velocity.x + velocity.y * velocity.y) / 42)
2002
- let anchor = CGPoint(x: end.x + 28, y: end.y + 22 - speed * 8)
2003
- drawLottieCatPOC(marker: marker, at: anchor, velocity: velocity, label: label, in: ctx)
2004
- }
2005
-
2006
- private func fallbackMarker(phase: MouseGestureVisualPhase, success: Bool?) -> String {
2007
- switch phase {
2008
- case .started:
2009
- return "curious"
2010
- case .updated:
2011
- return "follow"
2012
- case .recognized:
2013
- return "pounce"
2014
- case .completed:
2015
- return success == false ? "confused" : "celebrate"
2016
- }
2017
- }
2018
-
2019
- private func drawLottieCatPOC(
2020
- marker: String,
2021
- at center: CGPoint,
2022
- velocity: CGPoint,
2023
- label: String?,
2024
- in ctx: CGContext
2025
- ) {
2026
- let mood = marker.lowercased()
2027
- let bodyColor = NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 0.92)
2028
- let faceColor = theme.highlight.withAlphaComponent(0.94)
2029
- let accent = mood.contains("confused") ? theme.failure : theme.accent
2030
- let tilt = max(-0.34, min(0.34, velocity.x / 160))
2031
- let hop: CGFloat = mood.contains("pounce") || mood.contains("celebrate") ? 7 : 0
2032
- let headCenter = CGPoint(x: center.x, y: center.y + hop)
2033
- let headRadius: CGFloat = mood.contains("pounce") ? 16 : 14
2034
-
2035
- ctx.saveGState()
2036
- ctx.translateBy(x: headCenter.x, y: headCenter.y)
2037
- ctx.rotate(by: tilt)
2038
- ctx.translateBy(x: -headCenter.x, y: -headCenter.y)
2039
-
2040
- ctx.setFillColor(theme.graphiteDark.withAlphaComponent(0.24).cgColor)
2041
- ctx.fillEllipse(in: CGRect(x: headCenter.x - 22, y: headCenter.y - 18, width: 44, height: 36))
2042
-
2043
- let leftEar = CGMutablePath()
2044
- leftEar.move(to: CGPoint(x: headCenter.x - 12, y: headCenter.y + 9))
2045
- leftEar.addLine(to: CGPoint(x: headCenter.x - 7, y: headCenter.y + 25))
2046
- leftEar.addLine(to: CGPoint(x: headCenter.x - 1, y: headCenter.y + 11))
2047
- leftEar.closeSubpath()
2048
- ctx.addPath(leftEar)
2049
- ctx.setFillColor(bodyColor.cgColor)
2050
- ctx.fillPath()
2051
-
2052
- let rightEar = CGMutablePath()
2053
- rightEar.move(to: CGPoint(x: headCenter.x + 12, y: headCenter.y + 9))
2054
- rightEar.addLine(to: CGPoint(x: headCenter.x + 7, y: headCenter.y + 25))
2055
- rightEar.addLine(to: CGPoint(x: headCenter.x + 1, y: headCenter.y + 11))
2056
- rightEar.closeSubpath()
2057
- ctx.addPath(rightEar)
2058
- ctx.setFillColor(bodyColor.cgColor)
2059
- ctx.fillPath()
2060
-
2061
- ctx.setFillColor(bodyColor.cgColor)
2062
- ctx.fillEllipse(in: CGRect(x: headCenter.x - headRadius, y: headCenter.y - headRadius, width: headRadius * 2, height: headRadius * 2))
2063
- ctx.setStrokeColor(accent.withAlphaComponent(0.72).cgColor)
2064
- ctx.setLineWidth(1.4)
2065
- ctx.strokeEllipse(in: CGRect(x: headCenter.x - headRadius, y: headCenter.y - headRadius, width: headRadius * 2, height: headRadius * 2))
2066
-
2067
- let eyeY = headCenter.y + 2
2068
- let blink = mood.contains("pounce") || mood.contains("celebrate")
2069
- drawCatEye(at: CGPoint(x: headCenter.x - 5, y: eyeY), blink: blink, color: faceColor, in: ctx)
2070
- drawCatEye(at: CGPoint(x: headCenter.x + 5, y: eyeY), blink: blink, color: faceColor, in: ctx)
2071
-
2072
- ctx.setStrokeColor(faceColor.withAlphaComponent(0.82).cgColor)
2073
- ctx.setLineWidth(1)
2074
- let mouth = CGMutablePath()
2075
- mouth.move(to: CGPoint(x: headCenter.x - 3, y: headCenter.y - 5))
2076
- 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)))
2077
- ctx.addPath(mouth)
2078
- ctx.strokePath()
2079
-
2080
- if mood.contains("celebrate"), let label {
2081
- drawCatToast(label, near: CGPoint(x: headCenter.x + 18, y: headCenter.y + 18), color: accent)
2082
- }
2083
-
2084
- ctx.restoreGState()
2085
- }
2086
-
2087
- private func drawCatEye(at point: CGPoint, blink: Bool, color: NSColor, in ctx: CGContext) {
2088
- ctx.setStrokeColor(color.cgColor)
2089
- ctx.setFillColor(color.cgColor)
2090
- if blink {
2091
- ctx.setLineWidth(1.4)
2092
- let path = CGMutablePath()
2093
- path.move(to: CGPoint(x: point.x - 2.4, y: point.y))
2094
- path.addLine(to: CGPoint(x: point.x + 2.4, y: point.y))
2095
- ctx.addPath(path)
2096
- ctx.strokePath()
2097
- } else {
2098
- ctx.fillEllipse(in: CGRect(x: point.x - 1.7, y: point.y - 1.7, width: 3.4, height: 3.4))
2099
- }
2100
- }
2101
-
2102
- private func drawCatToast(_ label: String, near point: CGPoint, color: NSColor) {
2103
- let shortLabel = label.replacingOccurrences(of: " Focused", with: "!")
2104
- let font = NSFont.monospacedSystemFont(ofSize: 9, weight: .bold)
2105
- let attributed = NSAttributedString(
2106
- string: shortLabel,
2107
- attributes: [
2108
- .font: font,
2109
- .foregroundColor: theme.highlight.withAlphaComponent(0.96),
2110
- ]
2111
- )
2112
- let size = attributed.size()
2113
- let rect = CGRect(x: point.x, y: point.y, width: size.width + 12, height: size.height + 7)
2114
- let bubble = NSBezierPath(roundedRect: rect, xRadius: 8, yRadius: 8)
2115
- theme.graphiteDark.withAlphaComponent(0.72).setFill()
2116
- bubble.fill()
2117
- color.withAlphaComponent(0.5).setStroke()
2118
- bubble.lineWidth = 1
2119
- bubble.stroke()
2120
- attributed.draw(in: CGRect(x: rect.minX + 6, y: rect.minY + 3.5, width: size.width, height: size.height))
2121
- }
2122
-
2123
- private func smoothedGesturePath(from points: [CGPoint]) -> CGPath {
2124
- let path = CGMutablePath()
2125
- guard let first = points.first else { return path }
2126
- path.move(to: first)
2127
-
2128
- guard points.count > 2 else {
2129
- if let last = points.last {
2130
- path.addLine(to: last)
2131
- }
2132
- return path
2133
- }
2134
-
2135
- for index in 0..<(points.count - 1) {
2136
- let previous = points[max(index - 1, 0)]
2137
- let current = points[index]
2138
- let next = points[index + 1]
2139
- let nextNext = points[min(index + 2, points.count - 1)]
2140
- let tension: CGFloat = 0.34
2141
- let control1 = CGPoint(
2142
- x: current.x + (next.x - previous.x) * tension,
2143
- y: current.y + (next.y - previous.y) * tension
2144
- )
2145
- let control2 = CGPoint(
2146
- x: next.x - (nextNext.x - current.x) * tension,
2147
- y: next.y - (nextNext.y - current.y) * tension
2148
- )
2149
- path.addCurve(to: next, control1: control1, control2: control2)
2150
- }
2151
- return path
2152
- }
2153
-
2154
- private func visiblePathPoints(_ points: [CGPoint], progress: CGFloat) -> [CGPoint] {
2155
- guard progress < 1, points.count > 2 else { return points }
2156
- let clamped = min(1, max(0.04, progress))
2157
- let count = max(2, Int(ceil(CGFloat(points.count) * clamped)))
2158
- return Array(points.prefix(count))
2159
- }
2160
-
2161
- private func pathDirection(from points: [CGPoint]) -> MouseGestureDirection? {
2162
- guard points.count >= 2 else { return nil }
2163
- let window = points.suffix(min(6, points.count))
2164
- guard let first = window.first, let last = window.last else { return nil }
2165
- let delta = CGPoint(x: last.x - first.x, y: last.y - first.y)
2166
- return MouseGestureController.resolveDirection(delta: delta, threshold: 4, axisBias: 1.0)
2167
- }
2168
-
2169
- private func arrowMetrics(for style: MouseGestureOverlayStyle) -> (lineWidth: CGFloat, glowWidth: CGFloat, headSize: CGFloat) {
2170
- switch style {
2171
- case .thinLine:
2172
- return (2.4, 7, 10)
2173
- case .thickLine:
2174
- return (8.5, 20, 18)
2175
- case .drawing:
2176
- return (5, 16, 15)
2177
- }
2178
- }
2179
-
2180
- private func drawArrowHead(at end: CGPoint, direction: MouseGestureDirection, color: NSColor, size: CGFloat) {
2181
- let path = NSBezierPath()
2182
-
2183
- switch direction {
2184
- case .left:
2185
- path.move(to: CGPoint(x: end.x - size, y: end.y))
2186
- path.line(to: CGPoint(x: end.x + size * 0.2, y: end.y + size * 0.72))
2187
- path.line(to: CGPoint(x: end.x + size * 0.2, y: end.y - size * 0.72))
2188
- case .right:
2189
- path.move(to: CGPoint(x: end.x + size, y: end.y))
2190
- path.line(to: CGPoint(x: end.x - size * 0.2, y: end.y + size * 0.72))
2191
- path.line(to: CGPoint(x: end.x - size * 0.2, y: end.y - size * 0.72))
2192
- case .up:
2193
- path.move(to: CGPoint(x: end.x, y: end.y + size))
2194
- path.line(to: CGPoint(x: end.x - size * 0.72, y: end.y - size * 0.2))
2195
- path.line(to: CGPoint(x: end.x + size * 0.72, y: end.y - size * 0.2))
2196
- case .down:
2197
- path.move(to: CGPoint(x: end.x, y: end.y - size))
2198
- path.line(to: CGPoint(x: end.x - size * 0.72, y: end.y + size * 0.2))
2199
- path.line(to: CGPoint(x: end.x + size * 0.72, y: end.y + size * 0.2))
2200
- }
2201
-
2202
- path.close()
2203
- color.withAlphaComponent(0.96).setFill()
2204
- path.fill()
2205
- }
2206
-
2207
- private func drawLabel(_ label: String, from origin: CGPoint, to end: CGPoint, direction: MouseGestureDirection, color: NSColor) {
2208
- let display = labelComponents(for: label)
2209
- let titleFont = NSFont.systemFont(ofSize: 15, weight: .heavy)
2210
- let kickerFont = NSFont.monospacedSystemFont(ofSize: 8, weight: .bold)
2211
- let titleAttributes: [NSAttributedString.Key: Any] = [
2212
- .font: titleFont,
2213
- .foregroundColor: theme.highlight.withAlphaComponent(0.98),
2214
- ]
2215
- let kickerAttributes: [NSAttributedString.Key: Any] = [
2216
- .font: kickerFont,
2217
- .foregroundColor: color.withAlphaComponent(0.84),
2218
- ]
2219
- let title = NSAttributedString(string: display.title, attributes: titleAttributes)
2220
- let kicker = display.kicker.map { NSAttributedString(string: $0, attributes: kickerAttributes) }
2221
- let titleSize = title.size()
2222
- let kickerSize = kicker?.size() ?? .zero
2223
- let gap: CGFloat = kicker == nil ? 0 : 2
2224
- let textSize = CGSize(
2225
- width: max(titleSize.width, kickerSize.width),
2226
- height: titleSize.height + gap + kickerSize.height
2227
- )
2228
- let paddingX: CGFloat = 14
2229
- let paddingY: CGFloat = 8
2230
- let tickWidth: CGFloat = 6
2231
- let bubbleSize = CGSize(
2232
- width: textSize.width + paddingX * 2 + tickWidth,
2233
- height: textSize.height + paddingY * 2
2234
- )
2235
- let bubbleOrigin = labelOrigin(from: origin, to: end, direction: direction, bubbleSize: bubbleSize)
2236
- let rect = CGRect(
2237
- x: bubbleOrigin.x,
2238
- y: bubbleOrigin.y,
2239
- width: bubbleSize.width,
2240
- height: bubbleSize.height
2241
- )
2242
-
2243
- ctxSaveForLabel(rotationDegrees: -4, around: CGPoint(x: rect.midX, y: rect.midY))
2244
-
2245
- let shadowRect = rect.insetBy(dx: -5, dy: -5)
2246
- let shadow = NSBezierPath(roundedRect: shadowRect, xRadius: 15, yRadius: 15)
2247
- theme.graphiteDark.withAlphaComponent(0.24).setFill()
2248
- shadow.fill()
2249
-
2250
- let bg = NSBezierPath(roundedRect: rect, xRadius: 13, yRadius: 13)
2251
- theme.graphiteDark.withAlphaComponent(0.82).setFill()
2252
- bg.fill()
2253
-
2254
- let border = NSBezierPath(roundedRect: rect, xRadius: 13, yRadius: 13)
2255
- theme.graphite.withAlphaComponent(0.46).setStroke()
2256
- border.lineWidth = 1.2
2257
- border.stroke()
2258
-
2259
- let tickRect = CGRect(
2260
- x: rect.minX + 8,
2261
- y: rect.midY - 7,
2262
- width: 3,
2263
- height: 14
2264
- )
2265
- let tick = NSBezierPath(roundedRect: tickRect, xRadius: 1.5, yRadius: 1.5)
2266
- color.withAlphaComponent(0.82).setFill()
2267
- tick.fill()
2268
-
2269
- let titleRect = CGRect(
2270
- x: rect.minX + paddingX + tickWidth,
2271
- y: rect.minY + paddingY + kickerSize.height + gap,
2272
- width: titleSize.width,
2273
- height: titleSize.height
2274
- )
2275
- title.draw(in: titleRect)
2276
-
2277
- if let kicker {
2278
- let kickerRect = CGRect(
2279
- x: rect.minX + paddingX + tickWidth + 1,
2280
- y: rect.minY + paddingY,
2281
- width: kickerSize.width,
2282
- height: kickerSize.height
2283
- )
2284
- kicker.draw(in: kickerRect)
2285
- }
2286
-
2287
- NSGraphicsContext.current?.cgContext.restoreGState()
2288
- }
2289
-
2290
- private func labelComponents(for label: String) -> (title: String, kicker: String?) {
2291
- if label.hasSuffix(" Focused") {
2292
- return (String(label.dropLast(" Focused".count)), "FOCUSED")
2293
- }
2294
- return (label, nil)
2295
- }
2296
-
2297
- private func ctxSaveForLabel(rotationDegrees: CGFloat, around center: CGPoint) {
2298
- guard let ctx = NSGraphicsContext.current?.cgContext else { return }
2299
- ctx.saveGState()
2300
- ctx.translateBy(x: center.x, y: center.y)
2301
- ctx.rotate(by: rotationDegrees * .pi / 180)
2302
- ctx.translateBy(x: -center.x, y: -center.y)
2303
- }
2304
-
2305
- private func labelOrigin(from origin: CGPoint, to end: CGPoint, direction: MouseGestureDirection, bubbleSize: CGSize) -> CGPoint {
2306
- let midpoint = CGPoint(x: (origin.x + end.x) / 2, y: (origin.y + end.y) / 2)
2307
- let proposedOrigin: CGPoint
2308
-
2309
- switch direction {
2310
- case .left, .right:
2311
- proposedOrigin = CGPoint(x: midpoint.x - bubbleSize.width / 2, y: midpoint.y + 18)
2312
- case .up, .down:
2313
- proposedOrigin = CGPoint(x: midpoint.x + 20, y: midpoint.y - bubbleSize.height / 2)
2314
- }
2315
-
2316
- let minX: CGFloat = 12
2317
- let minY: CGFloat = 12
2318
- let maxX = max(minX, bounds.width - bubbleSize.width - 12)
2319
- let maxY = max(minY, bounds.height - bubbleSize.height - 12)
2320
-
2321
- return CGPoint(
2322
- x: min(max(proposedOrigin.x, minX), maxX),
2323
- y: min(max(proposedOrigin.y, minY), maxY)
2324
- )
2325
- }
2326
-
2327
- private func arrowVector(for direction: MouseGestureDirection, length: CGFloat) -> CGPoint {
2328
- switch direction {
2329
- case .left:
2330
- return CGPoint(x: -length, y: 0)
2331
- case .right:
2332
- return CGPoint(x: length, y: 0)
2333
- case .up:
2334
- return CGPoint(x: 0, y: length)
2335
- case .down:
2336
- return CGPoint(x: 0, y: -length)
2337
- }
2338
- }
2339
-
2340
- private func updateArrowAnimation(from oldState: State, to newState: State) {
2341
- let oldDirection = stateDirection(from: oldState)
2342
- let newDirection = stateDirection(from: newState)
2343
- let oldCommitted = isCommitted(state: oldState)
2344
- let newCommitted = isCommitted(state: newState)
2345
-
2346
- if newCommitted, newDirection != nil {
2347
- let shouldRestart = oldDirection != newDirection || !oldCommitted
2348
- if shouldRestart {
2349
- let previousProgress = trackingProgress(from: oldState)
2350
- let isMatrixReplay = isCommittedShapeReplay(state: newState)
2351
- committedStartProgress = isMatrixReplay ? 0 : max(0, min(1, previousProgress ?? 0))
2352
- if committedStartProgress >= 0.94 {
2353
- arrowAnimationTimer?.invalidate()
2354
- arrowAnimationTimer = nil
2355
- arrowAnimationStartedAt = nil
2356
- arrowAnimationDuration = 0
2357
- committedStartProgress = 1
2358
- } else {
2359
- let duration = isMatrixReplay ? matrixCompletionAnimationDuration : committedArrowAnimationDuration
2360
- startArrowAnimation(duration: duration)
2361
- }
2362
- }
2363
- return
2364
- }
2365
-
2366
- arrowAnimationTimer?.invalidate()
2367
- arrowAnimationTimer = nil
2368
- arrowAnimationStartedAt = nil
2369
- arrowAnimationDuration = 0
2370
- committedStartProgress = 0
2371
- }
2372
-
2373
- private func startArrowAnimation(duration: TimeInterval) {
2374
- arrowAnimationTimer?.invalidate()
2375
- arrowAnimationStartedAt = Date()
2376
- arrowAnimationDuration = duration
2377
-
2378
- let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in
2379
- guard let self else {
2380
- timer.invalidate()
2381
- return
2382
- }
2383
- self.needsDisplay = true
2384
- let elapsed = Date().timeIntervalSince(self.arrowAnimationStartedAt ?? Date())
2385
- if elapsed >= self.replayLeadInDuration {
2386
- timer.invalidate()
2387
- self.arrowAnimationTimer = nil
2388
- }
2389
- }
2390
- arrowAnimationTimer = timer
2391
- RunLoop.main.add(timer, forMode: .common)
2392
- }
2393
-
2394
- private func currentArrowProgress(committed: Bool) -> CGFloat {
2395
- guard committed,
2396
- let startedAt = arrowAnimationStartedAt,
2397
- arrowAnimationDuration > 0 else {
2398
- return committed ? (committedStartProgress > 0 ? committedStartProgress : 1) : 1
2399
- }
2400
-
2401
- let delayedElapsed = Date().timeIntervalSince(startedAt) - arrowAnimationDelay
2402
- guard delayedElapsed > 0 else { return committedStartProgress }
2403
- let normalized = min(1, max(0, delayedElapsed / arrowAnimationDuration))
2404
- let animated = easeOut(normalized)
2405
- return committedStartProgress + (1 - committedStartProgress) * animated
2406
- }
2407
-
2408
- private func updateAccessoryAnimation() {
2409
- accessoryAnimationTimer?.invalidate()
2410
- accessoryAnimationTimer = nil
2411
- accessoryAnimationStartedAt = nil
2412
- accessoryAnimationDuration = 0
2413
- accessoryAnimationDelay = 0
2414
-
2415
- if case .committed(_, _, _, _, _, _, _, _, _, let accessory, let duration) = state, accessory != nil {
2416
- accessoryAnimationStartedAt = Date()
2417
- accessoryAnimationDuration = duration
2418
- accessoryAnimationDelay = replayLeadInDuration * 0.86
2419
- let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in
2420
- guard let self else {
2421
- timer.invalidate()
2422
- return
2423
- }
2424
- self.needsDisplay = true
2425
- let elapsed = Date().timeIntervalSince(self.accessoryAnimationStartedAt ?? Date())
2426
- if elapsed >= self.accessoryAnimationDelay + self.accessoryAnimationDuration {
2427
- timer.invalidate()
2428
- self.accessoryAnimationTimer = nil
2429
- }
2430
- }
2431
- accessoryAnimationTimer = timer
2432
- RunLoop.main.add(timer, forMode: .common)
2433
- }
2434
- }
2435
-
2436
- private func drawAccessory(
2437
- _ accessory: MouseGestureAccessory,
2438
- from origin: CGPoint,
2439
- to end: CGPoint,
2440
- direction: MouseGestureDirection,
2441
- color: NSColor,
2442
- in ctx: CGContext
2443
- ) {
2444
- guard let startedAt = accessoryAnimationStartedAt, accessoryAnimationDuration > 0 else { return }
2445
- let delayedElapsed = Date().timeIntervalSince(startedAt) - accessoryAnimationDelay
2446
- guard delayedElapsed > 0 else { return }
2447
- let progress = min(1, max(0, delayedElapsed / accessoryAnimationDuration))
2448
- let scale = 0.82 + 0.18 * easeOut(progress)
2449
- let fadeStart: CGFloat = 0.58
2450
- let alphaProgress = progress <= fadeStart ? 1 : 1 - ((progress - fadeStart) / (1 - fadeStart))
2451
- let alpha = max(0, min(1, alphaProgress))
2452
- guard alpha > 0 else { return }
2453
-
2454
- let center = accessoryCenter(from: end, direction: direction)
2455
- let badgeDiameter: CGFloat = 34 * scale
2456
- let badgeRect = CGRect(
2457
- x: center.x - badgeDiameter / 2,
2458
- y: center.y - badgeDiameter / 2,
2459
- width: badgeDiameter,
2460
- height: badgeDiameter
2461
- )
2462
-
2463
- let badge = NSBezierPath(ovalIn: badgeRect)
2464
- NSColor.black.withAlphaComponent(0.46 * alpha).setFill()
2465
- badge.fill()
2466
-
2467
- color.withAlphaComponent(0.32 * alpha).setStroke()
2468
- badge.lineWidth = 1
2469
- badge.stroke()
2470
-
2471
- switch accessory {
2472
- case .mic:
2473
- drawMicGlyph(in: badgeRect.insetBy(dx: badgeDiameter * 0.26, dy: badgeDiameter * 0.2), color: color.withAlphaComponent(0.96 * alpha), in: ctx)
2474
- }
2475
- }
2476
-
2477
- private func accessoryCenter(from end: CGPoint, direction: MouseGestureDirection) -> CGPoint {
2478
- switch direction {
2479
- case .up:
2480
- return CGPoint(x: end.x, y: end.y + 34)
2481
- case .down:
2482
- return CGPoint(x: end.x, y: end.y - 34)
2483
- case .left:
2484
- return CGPoint(x: end.x - 34, y: end.y)
2485
- case .right:
2486
- return CGPoint(x: end.x + 34, y: end.y)
2487
- }
2488
- }
2489
-
2490
- private func drawMicGlyph(in rect: CGRect, color: NSColor, in ctx: CGContext) {
2491
- ctx.saveGState()
2492
- color.setStroke()
2493
- color.withAlphaComponent(0.22).setFill()
2494
-
2495
- let bodyWidth = rect.width * 0.42
2496
- let bodyHeight = rect.height * 0.54
2497
- let bodyRect = CGRect(
2498
- x: rect.midX - bodyWidth / 2,
2499
- y: rect.maxY - bodyHeight,
2500
- width: bodyWidth,
2501
- height: bodyHeight
2502
- )
2503
- let body = NSBezierPath(roundedRect: bodyRect, xRadius: bodyWidth / 2, yRadius: bodyWidth / 2)
2504
- body.lineWidth = 1.6
2505
- body.fill()
2506
- body.stroke()
2507
-
2508
- let stem = NSBezierPath()
2509
- stem.move(to: CGPoint(x: rect.midX, y: bodyRect.minY))
2510
- stem.line(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.24))
2511
- stem.lineWidth = 1.8
2512
- stem.lineCapStyle = .round
2513
- stem.stroke()
2514
-
2515
- let arcRect = CGRect(
2516
- x: rect.midX - rect.width * 0.28,
2517
- y: rect.minY + rect.height * 0.18,
2518
- width: rect.width * 0.56,
2519
- height: rect.height * 0.42
2520
- )
2521
- let arc = NSBezierPath()
2522
- arc.appendArc(
2523
- withCenter: CGPoint(x: arcRect.midX, y: arcRect.midY + arcRect.height * 0.08),
2524
- radius: arcRect.width / 2,
2525
- startAngle: 200,
2526
- endAngle: -20,
2527
- clockwise: true
2528
- )
2529
- arc.lineWidth = 1.6
2530
- arc.lineCapStyle = .round
2531
- arc.stroke()
2532
-
2533
- let base = NSBezierPath()
2534
- base.move(to: CGPoint(x: rect.midX - rect.width * 0.22, y: rect.minY + rect.height * 0.14))
2535
- base.line(to: CGPoint(x: rect.midX + rect.width * 0.22, y: rect.minY + rect.height * 0.14))
2536
- base.lineWidth = 1.6
2537
- base.lineCapStyle = .round
2538
- base.stroke()
2539
- ctx.restoreGState()
2540
- }
2541
-
2542
- private func easeOut(_ t: CGFloat) -> CGFloat {
2543
- 1 - pow(1 - t, 3)
2544
- }
2545
-
2546
- private func stateDirection(from state: State) -> MouseGestureDirection? {
2547
- switch state {
2548
- case .idle:
2549
- return nil
2550
- case .tracking(_, let direction, _, _, _, _, _, _, _, _):
2551
- return direction
2552
- case .committed(_, let direction, _, _, _, _, _, _, _, _, _):
2553
- return direction
2554
- }
2555
- }
2556
-
2557
- private func isCommitted(state: State) -> Bool {
2558
- if case .committed = state {
2559
- return true
2560
- }
2561
- return false
2562
- }
2563
-
2564
- private func isCommittedShapeReplay(state: State) -> Bool {
2565
- if case .committed(_, _, _, _, _, let visual, _, let shape, let path, _, _) = state {
2566
- return shape != nil && path.count > 1 && shouldDrawMatrixCompletion(visual)
2567
- }
2568
- return false
2569
- }
2570
-
2571
- private func shouldDrawMatrixCompletion(_ visual: MouseShortcutVisualDefinition?) -> Bool {
2572
- guard let visual else { return false }
2573
- return visual.renderer.localizedCaseInsensitiveCompare("matrix") == .orderedSame
2574
- || visual.theme?.localizedCaseInsensitiveCompare("matrix") == .orderedSame
2575
- }
2576
-
2577
- private func trackingProgress(from state: State) -> CGFloat? {
2578
- if case .tracking(_, _, _, _, _, _, _, _, _, let progress) = state {
2579
- return progress
2580
- }
2581
- return nil
2582
- }
2583
- }