@lattices/cli 0.4.1 → 0.4.5

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 (71) hide show
  1. package/README.md +3 -0
  2. package/app/Info.plist +2 -2
  3. package/app/Lattices.app/Contents/Info.plist +2 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Package.swift +6 -0
  6. package/app/Sources/ActionRow.swift +43 -26
  7. package/app/Sources/App.swift +10 -0
  8. package/app/Sources/AppDelegate.swift +91 -30
  9. package/app/Sources/AppShellView.swift +2 -0
  10. package/app/Sources/AppTypeClassifier.swift +36 -0
  11. package/app/Sources/AppUpdater.swift +92 -0
  12. package/app/Sources/CheatSheetHUD.swift +1 -0
  13. package/app/Sources/CliActionLauncher.swift +50 -0
  14. package/app/Sources/CommandModeView.swift +4 -24
  15. package/app/Sources/CompanionActivityLog.swift +70 -0
  16. package/app/Sources/CompanionKeyboardController.swift +141 -0
  17. package/app/Sources/DesktopModel.swift +4 -0
  18. package/app/Sources/HandsOffSession.swift +53 -16
  19. package/app/Sources/HomeDashboardView.swift +18 -10
  20. package/app/Sources/HotkeyStore.swift +8 -5
  21. package/app/Sources/IntentEngine.swift +7 -1
  22. package/app/Sources/LatticesApi.swift +125 -4
  23. package/app/Sources/LatticesCompanionBridgeServer.swift +438 -0
  24. package/app/Sources/LatticesCompanionCockpit.swift +555 -0
  25. package/app/Sources/LatticesCompanionSecurityCoordinator.swift +594 -0
  26. package/app/Sources/LatticesCompanionTrackpadController.swift +204 -0
  27. package/app/Sources/LatticesDeckHost.swift +1463 -0
  28. package/app/Sources/LatticesRuntime.swift +61 -0
  29. package/app/Sources/MainView.swift +398 -186
  30. package/app/Sources/MouseFinder.swift +335 -30
  31. package/app/Sources/MouseGestureConfig.swift +364 -0
  32. package/app/Sources/MouseGestureController.swift +1203 -0
  33. package/app/Sources/MouseInputDeviceStore.swift +98 -0
  34. package/app/Sources/MouseInputEventViewer.swift +272 -0
  35. package/app/Sources/MouseShortcutStore.swift +107 -0
  36. package/app/Sources/OmniSearchView.swift +136 -2
  37. package/app/Sources/OmniSearchWindow.swift +65 -5
  38. package/app/Sources/OnboardingView.swift +30 -16
  39. package/app/Sources/PaletteCommand.swift +26 -6
  40. package/app/Sources/PermissionChecker.swift +76 -2
  41. package/app/Sources/PiAuthNextStepCard.swift +148 -0
  42. package/app/Sources/PiAuthPromptCard.swift +90 -0
  43. package/app/Sources/PiChatDock.swift +137 -74
  44. package/app/Sources/PiChatSession.swift +608 -108
  45. package/app/Sources/PiInstallCallout.swift +86 -0
  46. package/app/Sources/PiProviderSetupCallout.swift +99 -0
  47. package/app/Sources/PiWorkspaceView.swift +174 -77
  48. package/app/Sources/Preferences.swift +78 -0
  49. package/app/Sources/ScreenMapState.swift +91 -31
  50. package/app/Sources/ScreenMapView.swift +510 -524
  51. package/app/Sources/ScreenMapWindowController.swift +12 -4
  52. package/app/Sources/SettingsView.swift +869 -152
  53. package/app/Sources/SystemTelemetryMonitor.swift +273 -0
  54. package/app/Sources/VoiceCommandWindow.swift +23 -2
  55. package/app/Sources/WindowDragSnapController.swift +628 -0
  56. package/app/Sources/WindowTiler.swift +328 -65
  57. package/app/Sources/WorkspaceManager.swift +288 -0
  58. package/bin/assistant-intelligence.ts +874 -0
  59. package/bin/handsoff-infer.ts +16 -209
  60. package/bin/handsoff-worker.ts +45 -258
  61. package/bin/lattices-app.ts +65 -1
  62. package/bin/lattices-dev +4 -0
  63. package/bin/lattices.ts +125 -14
  64. package/docs/agents.md +14 -0
  65. package/docs/api.md +55 -0
  66. package/docs/app.md +3 -0
  67. package/docs/companion-deck.md +180 -0
  68. package/docs/config.md +25 -0
  69. package/docs/tiling-reference.md +55 -0
  70. package/docs/voice-error-model.md +73 -0
  71. package/package.json +4 -2
@@ -0,0 +1,1203 @@
1
+ import AppKit
2
+ import Combine
3
+ import CoreGraphics
4
+
5
+ private enum MouseGestureAccessory {
6
+ case mic
7
+ }
8
+
9
+ final class MouseGestureController {
10
+ static let shared = MouseGestureController()
11
+
12
+ private struct GestureOutcome {
13
+ let label: String
14
+ let success: Bool
15
+ let accessory: MouseGestureAccessory?
16
+ }
17
+
18
+ private final class GestureSession {
19
+ let buttonNumber: Int64
20
+ let startPoint: CGPoint
21
+ let overlay: MouseGestureOverlay
22
+ var currentPoint: CGPoint
23
+ var lockedDirection: MouseGestureDirection?
24
+
25
+ init(buttonNumber: Int64, startPoint: CGPoint, overlay: MouseGestureOverlay) {
26
+ self.buttonNumber = buttonNumber
27
+ self.startPoint = startPoint
28
+ self.overlay = overlay
29
+ self.currentPoint = startPoint
30
+ self.lockedDirection = nil
31
+ }
32
+ }
33
+
34
+ private static let syntheticMarker: Int64 = 0x4C474D47
35
+
36
+ private var eventTap: CFMachPort?
37
+ private var runLoopSource: CFRunLoopSource?
38
+ private var session: GestureSession?
39
+ private var retainedOverlays: [ObjectIdentifier: MouseGestureOverlay] = [:]
40
+ private var subscriptions: Set<AnyCancellable> = []
41
+ private var installedObservers = false
42
+
43
+ private init() {}
44
+
45
+ func start() {
46
+ installObserversIfNeeded()
47
+ refresh()
48
+ }
49
+
50
+ func stop() {
51
+ clearSession()
52
+ removeEventTap()
53
+ }
54
+
55
+ static func resolveDirection(
56
+ delta: CGPoint,
57
+ threshold: CGFloat = 68,
58
+ axisBias: CGFloat = 1.2
59
+ ) -> MouseGestureDirection? {
60
+ let absX = abs(delta.x)
61
+ let absY = abs(delta.y)
62
+ guard max(absX, absY) >= threshold else { return nil }
63
+
64
+ if absX >= absY * axisBias {
65
+ return delta.x >= 0 ? .right : .left
66
+ }
67
+
68
+ if absY >= absX * axisBias {
69
+ return delta.y >= 0 ? .down : .up
70
+ }
71
+
72
+ return nil
73
+ }
74
+
75
+ private func installObserversIfNeeded() {
76
+ guard !installedObservers else { return }
77
+ installedObservers = true
78
+
79
+ Preferences.shared.$mouseGesturesEnabled
80
+ .receive(on: RunLoop.main)
81
+ .sink { [weak self] _ in self?.refresh() }
82
+ .store(in: &subscriptions)
83
+
84
+ PermissionChecker.shared.$accessibility
85
+ .receive(on: RunLoop.main)
86
+ .sink { [weak self] _ in self?.refresh() }
87
+ .store(in: &subscriptions)
88
+
89
+ MouseInputEventViewer.shared.$isCaptureActive
90
+ .receive(on: RunLoop.main)
91
+ .sink { [weak self] _ in self?.refresh() }
92
+ .store(in: &subscriptions)
93
+ }
94
+
95
+ private func refresh() {
96
+ let shouldCapture = MouseInputEventViewer.shared.isCaptureActive || Preferences.shared.mouseGesturesEnabled
97
+ guard shouldCapture, PermissionChecker.shared.accessibility else {
98
+ clearSession()
99
+ removeEventTap()
100
+ return
101
+ }
102
+
103
+ if eventTap == nil {
104
+ installEventTap()
105
+ } else if let eventTap {
106
+ CGEvent.tapEnable(tap: eventTap, enable: true)
107
+ }
108
+ }
109
+
110
+ private func installEventTap() {
111
+ var mask = CGEventMask(0)
112
+ mask |= CGEventMask(1) << CGEventType.leftMouseDown.rawValue
113
+ mask |= CGEventMask(1) << CGEventType.leftMouseUp.rawValue
114
+ mask |= CGEventMask(1) << CGEventType.rightMouseDown.rawValue
115
+ mask |= CGEventMask(1) << CGEventType.rightMouseUp.rawValue
116
+ mask |= CGEventMask(1) << CGEventType.otherMouseDown.rawValue
117
+ mask |= CGEventMask(1) << CGEventType.otherMouseDragged.rawValue
118
+ mask |= CGEventMask(1) << CGEventType.otherMouseUp.rawValue
119
+
120
+ let tap = CGEvent.tapCreate(
121
+ tap: .cgSessionEventTap,
122
+ place: .headInsertEventTap,
123
+ options: .defaultTap,
124
+ eventsOfInterest: mask,
125
+ callback: Self.eventTapCallback,
126
+ userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
127
+ )
128
+
129
+ guard let tap else {
130
+ DiagnosticLog.shared.warn("MouseGesture: failed to install mouse shortcut event tap")
131
+ return
132
+ }
133
+
134
+ let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
135
+ eventTap = tap
136
+ runLoopSource = source
137
+
138
+ if let source {
139
+ CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
140
+ }
141
+ CGEvent.tapEnable(tap: tap, enable: true)
142
+ DiagnosticLog.shared.info("MouseGesture: mouse shortcut event tap installed")
143
+ }
144
+
145
+ private func removeEventTap() {
146
+ if let source = runLoopSource {
147
+ CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
148
+ }
149
+ runLoopSource = nil
150
+ if let tap = eventTap {
151
+ CFMachPortInvalidate(tap)
152
+ }
153
+ eventTap = nil
154
+ }
155
+
156
+ private static let eventTapCallback: CGEventTapCallBack = { _, type, event, userInfo in
157
+ guard let userInfo else { return Unmanaged.passUnretained(event) }
158
+ let controller = Unmanaged<MouseGestureController>.fromOpaque(userInfo).takeUnretainedValue()
159
+ return controller.handleEvent(type: type, event: event)
160
+ }
161
+
162
+ private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
163
+ if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
164
+ if let eventTap {
165
+ CGEvent.tapEnable(tap: eventTap, enable: true)
166
+ }
167
+ return Unmanaged.passUnretained(event)
168
+ }
169
+
170
+ if event.getIntegerValueField(.eventSourceUserData) == Self.syntheticMarker {
171
+ return Unmanaged.passUnretained(event)
172
+ }
173
+
174
+ switch type {
175
+ case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp:
176
+ return handlePassiveMouseButtonEvent(type: type, event: event)
177
+ default:
178
+ break
179
+ }
180
+
181
+ let buttonNumber = event.getIntegerValueField(.mouseEventButtonNumber)
182
+ if buttonNumber < 2 {
183
+ return Unmanaged.passUnretained(event)
184
+ }
185
+
186
+ switch type {
187
+ case .otherMouseDown:
188
+ return handleMouseDown(event, buttonNumber: buttonNumber)
189
+ case .otherMouseDragged:
190
+ return handleMouseDragged(event, buttonNumber: buttonNumber)
191
+ case .otherMouseUp:
192
+ return handleMouseUp(event, buttonNumber: buttonNumber)
193
+ default:
194
+ return Unmanaged.passUnretained(event)
195
+ }
196
+ }
197
+
198
+ private func handlePassiveMouseButtonEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
199
+ guard MouseInputEventViewer.shared.isCaptureActive else {
200
+ return Unmanaged.passUnretained(event)
201
+ }
202
+
203
+ let phase: String
204
+ switch type {
205
+ case .leftMouseDown, .rightMouseDown:
206
+ phase = "down"
207
+ case .leftMouseUp, .rightMouseUp:
208
+ phase = "up"
209
+ default:
210
+ return Unmanaged.passUnretained(event)
211
+ }
212
+
213
+ let buttonNumber = Int(event.getIntegerValueField(.mouseEventButtonNumber))
214
+ let appInfo = currentAppInfo()
215
+ recordObservedEvent(
216
+ phase: phase,
217
+ button: MouseShortcutButton(rawButtonNumber: buttonNumber),
218
+ location: event.location,
219
+ delta: .zero,
220
+ modifiers: event.flags,
221
+ candidate: nil,
222
+ match: nil,
223
+ note: "pass-through primary button",
224
+ appInfo: appInfo
225
+ )
226
+ return Unmanaged.passUnretained(event)
227
+ }
228
+
229
+ private func handleMouseDown(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
230
+ MouseShortcutStore.shared.reloadIfNeeded()
231
+ let point = event.location
232
+ let button = MouseShortcutButton(rawButtonNumber: Int(buttonNumber))
233
+ let appInfo = currentAppInfo()
234
+ let canRecognize = Preferences.shared.mouseGesturesEnabled && MouseShortcutStore.shared.watchedButtonNumbers.contains(buttonNumber)
235
+
236
+ guard let screen = screen(containing: point) else {
237
+ DiagnosticLog.shared.info("MouseGesture: ignored click at \(format(point)) (off-screen)")
238
+ recordObservedEvent(
239
+ phase: "down",
240
+ button: button,
241
+ location: point,
242
+ delta: .zero,
243
+ modifiers: event.flags,
244
+ candidate: nil,
245
+ match: nil,
246
+ note: "off-screen",
247
+ appInfo: appInfo
248
+ )
249
+ clearSession()
250
+ return Unmanaged.passUnretained(event)
251
+ }
252
+
253
+ guard canRecognize else {
254
+ recordObservedEvent(
255
+ phase: "down",
256
+ button: button,
257
+ location: point,
258
+ delta: .zero,
259
+ modifiers: event.flags,
260
+ candidate: nil,
261
+ match: nil,
262
+ note: "button not mapped",
263
+ appInfo: appInfo
264
+ )
265
+ return Unmanaged.passUnretained(event)
266
+ }
267
+
268
+ clearSession()
269
+ let overlay = MouseGestureOverlay(screen: screen)
270
+ overlay.onDismiss = { [weak self, weak overlay] in
271
+ guard let self, let overlay else { return }
272
+ self.releaseOverlay(overlay)
273
+ }
274
+ let newSession = GestureSession(buttonNumber: buttonNumber, startPoint: point, overlay: overlay)
275
+ session = newSession
276
+ DiagnosticLog.shared.info("MouseGesture: began at \(format(point)) button=\(buttonNumber)")
277
+ recordObservedEvent(
278
+ phase: "down",
279
+ button: button,
280
+ location: point,
281
+ delta: .zero,
282
+ modifiers: event.flags,
283
+ candidate: nil,
284
+ match: nil,
285
+ note: "tracking",
286
+ appInfo: appInfo
287
+ )
288
+ return nil
289
+ }
290
+
291
+ private func handleMouseDragged(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
292
+ guard let session else {
293
+ return Unmanaged.passUnretained(event)
294
+ }
295
+ guard session.buttonNumber == buttonNumber else {
296
+ return Unmanaged.passUnretained(event)
297
+ }
298
+ MouseShortcutStore.shared.reloadIfNeeded()
299
+
300
+ session.currentPoint = event.location
301
+ let delta = CGPoint(
302
+ x: event.location.x - session.startPoint.x,
303
+ y: event.location.y - session.startPoint.y
304
+ )
305
+ let tuning = MouseShortcutStore.shared.tuning
306
+ let direction = Self.resolveDirection(delta: delta, threshold: tuning.dragThreshold, axisBias: tuning.axisBias)
307
+
308
+ if direction != session.lockedDirection {
309
+ session.lockedDirection = direction
310
+ if let direction {
311
+ let button = MouseShortcutButton(rawButtonNumber: Int(buttonNumber))
312
+ let triggerEvent = MouseShortcutTriggerEvent(button: button, kind: .drag, direction: direction, device: nil)
313
+ let match = MouseShortcutStore.shared.match(for: triggerEvent)
314
+ DiagnosticLog.shared.info("MouseGesture: locked \(label(for: direction)) via \(triggerEvent.triggerName)")
315
+ recordObservedEvent(
316
+ phase: "drag",
317
+ button: button,
318
+ location: event.location,
319
+ delta: delta,
320
+ modifiers: event.flags,
321
+ candidate: triggerEvent.triggerName,
322
+ match: match,
323
+ note: match == nil ? "no rule" : "candidate",
324
+ appInfo: currentAppInfo()
325
+ )
326
+ }
327
+ }
328
+
329
+ if let direction {
330
+ let dominantDistance = max(abs(delta.x), abs(delta.y))
331
+ let previewProgress = previewProgress(
332
+ dominantDistance: dominantDistance,
333
+ threshold: tuning.dragThreshold
334
+ )
335
+ session.overlay.track(
336
+ origin: session.startPoint,
337
+ direction: direction,
338
+ label: nil,
339
+ progress: previewProgress
340
+ )
341
+ } else {
342
+ session.overlay.track(origin: session.startPoint, direction: nil, label: nil, progress: 0)
343
+ }
344
+
345
+ return nil
346
+ }
347
+
348
+ private func handleMouseUp(_ event: CGEvent, buttonNumber: Int64) -> Unmanaged<CGEvent>? {
349
+ MouseShortcutStore.shared.reloadIfNeeded()
350
+ let button = MouseShortcutButton(rawButtonNumber: Int(buttonNumber))
351
+ let appInfo = currentAppInfo()
352
+
353
+ guard let session else {
354
+ recordObservedEvent(
355
+ phase: "up",
356
+ button: button,
357
+ location: event.location,
358
+ delta: .zero,
359
+ modifiers: event.flags,
360
+ candidate: nil,
361
+ match: nil,
362
+ note: "no active session",
363
+ appInfo: appInfo
364
+ )
365
+ return Unmanaged.passUnretained(event)
366
+ }
367
+ guard session.buttonNumber == buttonNumber else {
368
+ return Unmanaged.passUnretained(event)
369
+ }
370
+
371
+ let delta = CGPoint(
372
+ x: event.location.x - session.startPoint.x,
373
+ y: event.location.y - session.startPoint.y
374
+ )
375
+ let tuning = MouseShortcutStore.shared.tuning
376
+ let direction = Self.resolveDirection(delta: delta, threshold: tuning.dragThreshold, axisBias: tuning.axisBias)
377
+ self.session = nil
378
+
379
+ guard let direction else {
380
+ DiagnosticLog.shared.info("MouseGesture: released without a gesture at \(format(event.location))")
381
+ recordObservedEvent(
382
+ phase: "up",
383
+ button: button,
384
+ location: event.location,
385
+ delta: delta,
386
+ modifiers: event.flags,
387
+ candidate: nil,
388
+ match: nil,
389
+ note: "replay click",
390
+ appInfo: appInfo
391
+ )
392
+ DispatchQueue.main.async { [weak self] in
393
+ session.overlay.dismiss()
394
+ self?.replayMouseClick(
395
+ buttonNumber: buttonNumber,
396
+ at: session.startPoint,
397
+ flags: event.flags
398
+ )
399
+ }
400
+ return nil
401
+ }
402
+
403
+ let triggerEvent = MouseShortcutTriggerEvent(button: button, kind: .drag, direction: direction, device: nil)
404
+ let match = MouseShortcutStore.shared.match(for: triggerEvent)
405
+ let dismissBeforeAction = shouldDismissOverlayBeforeAction(match: match)
406
+ if dismissBeforeAction {
407
+ session.overlay.dismiss(immediately: true)
408
+ }
409
+
410
+ DispatchQueue.main.async { [weak self] in
411
+ guard let self else { return }
412
+ if !dismissBeforeAction {
413
+ self.retainOverlay(session.overlay)
414
+ }
415
+ let outcome = self.performAction(match: match, startPoint: session.startPoint)
416
+ DiagnosticLog.shared.info("MouseGesture: \(outcome.label) -> \(outcome.success ? "ok" : "blocked")")
417
+ self.recordObservedEvent(
418
+ phase: "up",
419
+ button: button,
420
+ location: event.location,
421
+ delta: delta,
422
+ modifiers: event.flags,
423
+ candidate: triggerEvent.triggerName,
424
+ match: match,
425
+ note: outcome.success ? "fired" : "blocked",
426
+ appInfo: appInfo
427
+ )
428
+ if !dismissBeforeAction {
429
+ session.overlay.commit(
430
+ origin: session.startPoint,
431
+ direction: direction,
432
+ label: outcome.label,
433
+ success: outcome.success,
434
+ accessory: outcome.accessory
435
+ )
436
+ }
437
+ }
438
+ return nil
439
+ }
440
+
441
+ private func performAction(match: MouseShortcutMatchResult?, startPoint: CGPoint) -> GestureOutcome {
442
+ guard let match else {
443
+ return GestureOutcome(label: "No Shortcut Assigned", success: false, accessory: nil)
444
+ }
445
+
446
+ switch match.action.type {
447
+ case .spacePrevious:
448
+ guard WindowTiler.adjacentSpaceTarget(offset: -1, from: startPoint) != nil else {
449
+ return GestureOutcome(label: "No Previous Space", success: false, accessory: nil)
450
+ }
451
+ let switched = WindowTiler.switchToAdjacentSpace(offset: -1, from: startPoint)
452
+ return GestureOutcome(label: switched ? "Previous Space" : "Previous Space Blocked", success: switched, accessory: nil)
453
+ case .spaceNext:
454
+ guard WindowTiler.adjacentSpaceTarget(offset: 1, from: startPoint) != nil else {
455
+ return GestureOutcome(label: "No Next Space", success: false, accessory: nil)
456
+ }
457
+ let switched = WindowTiler.switchToAdjacentSpace(offset: 1, from: startPoint)
458
+ return GestureOutcome(label: switched ? "Next Space" : "Next Space Blocked", success: switched, accessory: nil)
459
+ case .screenMapToggle:
460
+ ScreenMapWindowController.shared.showScreenMapOverview()
461
+ return GestureOutcome(label: "Screen Map Overview", success: true, accessory: nil)
462
+ case .dictationStart:
463
+ let sent = sendDictationShortcut()
464
+ return GestureOutcome(
465
+ label: sent ? "Dictation" : "Dictation Blocked",
466
+ success: sent,
467
+ accessory: sent ? .mic : nil
468
+ )
469
+ case .shortcutSend:
470
+ let sent = sendShortcut(match.action.shortcut)
471
+ return GestureOutcome(
472
+ label: sent ? match.action.label : "Shortcut Blocked",
473
+ success: sent,
474
+ accessory: nil
475
+ )
476
+ }
477
+ }
478
+
479
+ private func label(for direction: MouseGestureDirection) -> String {
480
+ switch direction {
481
+ case .left:
482
+ return "Previous Space"
483
+ case .right:
484
+ return "Next Space"
485
+ case .up:
486
+ return "Up"
487
+ case .down:
488
+ return "Screen Map Overview"
489
+ }
490
+ }
491
+
492
+ private func clearSession() {
493
+ session?.overlay.dismiss(immediately: true)
494
+ session = nil
495
+ }
496
+
497
+ private func replayMouseClick(buttonNumber: Int64, at point: CGPoint, flags: CGEventFlags) {
498
+ let events: [CGEventType] = [.otherMouseDown, .otherMouseUp]
499
+ for type in events {
500
+ guard let mouseButton = CGMouseButton(rawValue: UInt32(buttonNumber)) else { continue }
501
+ guard let event = CGEvent(
502
+ mouseEventSource: nil,
503
+ mouseType: type,
504
+ mouseCursorPosition: point,
505
+ mouseButton: mouseButton
506
+ ) else { continue }
507
+
508
+ event.setIntegerValueField(CGEventField.mouseEventButtonNumber, value: buttonNumber)
509
+ event.setIntegerValueField(CGEventField.eventSourceUserData, value: Self.syntheticMarker)
510
+ event.flags = flags
511
+ event.post(tap: CGEventTapLocation.cghidEventTap)
512
+ }
513
+ }
514
+
515
+ private func screen(containing cgPoint: CGPoint) -> NSScreen? {
516
+ let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
517
+ let nsPoint = NSPoint(x: cgPoint.x, y: primaryHeight - cgPoint.y)
518
+ return NSScreen.screens.first(where: { $0.frame.contains(nsPoint) }) ?? NSScreen.main ?? NSScreen.screens.first
519
+ }
520
+
521
+ private func format(_ point: CGPoint) -> String {
522
+ "\(Int(point.x)),\(Int(point.y))"
523
+ }
524
+
525
+ private func shouldDismissOverlayBeforeAction(match: MouseShortcutMatchResult?) -> Bool {
526
+ guard let match else { return false }
527
+ switch match.action.type {
528
+ case .spacePrevious, .spaceNext, .screenMapToggle:
529
+ return true
530
+ case .dictationStart, .shortcutSend:
531
+ return false
532
+ }
533
+ }
534
+
535
+ private func previewProgress(dominantDistance: CGFloat, threshold: CGFloat) -> CGFloat {
536
+ guard dominantDistance > threshold else { return 0 }
537
+ let overshoot = dominantDistance - threshold
538
+ let normalized = min(1, max(0, overshoot / 90))
539
+ return 0.32 + normalized * 0.68
540
+ }
541
+
542
+ private func retainOverlay(_ overlay: MouseGestureOverlay) {
543
+ retainedOverlays[ObjectIdentifier(overlay)] = overlay
544
+ }
545
+
546
+ private func releaseOverlay(_ overlay: MouseGestureOverlay) {
547
+ retainedOverlays.removeValue(forKey: ObjectIdentifier(overlay))
548
+ }
549
+
550
+ private func currentAppInfo() -> (name: String?, bundleId: String?) {
551
+ let app = NSWorkspace.shared.frontmostApplication
552
+ return (app?.localizedName, app?.bundleIdentifier)
553
+ }
554
+
555
+ private func recordObservedEvent(
556
+ phase: String,
557
+ button: MouseShortcutButton,
558
+ location: CGPoint,
559
+ delta: CGPoint,
560
+ modifiers: CGEventFlags,
561
+ candidate: String?,
562
+ match: MouseShortcutMatchResult?,
563
+ note: String?,
564
+ appInfo: (name: String?, bundleId: String?)
565
+ ) {
566
+ guard MouseInputEventViewer.shared.isCaptureActive else { return }
567
+ let sourceState = Int(modifiers.rawValue)
568
+ MouseInputEventViewer.shared.record(
569
+ MouseShortcutObservedEvent(
570
+ timestamp: Date(),
571
+ phase: phase,
572
+ buttonNumber: button.rawButtonNumber,
573
+ location: location,
574
+ delta: delta,
575
+ modifiers: NSEvent.ModifierFlags(rawValue: UInt(modifiers.rawValue)),
576
+ frontmostAppName: appInfo.name,
577
+ frontmostBundleId: appInfo.bundleId,
578
+ candidateTrigger: candidate,
579
+ device: nil,
580
+ matchedRuleSummary: match?.rule.summary,
581
+ willFire: match != nil,
582
+ note: note.map { "\($0) | flags=\(sourceState)" } ?? "flags=\(sourceState)"
583
+ )
584
+ )
585
+ }
586
+
587
+ private func sendShortcut(_ shortcut: MouseShortcutKeyStroke?) -> Bool {
588
+ guard let shortcut else { return false }
589
+ let modifiers = shortcut.modifiers.map(\.appleScriptToken).joined(separator: ", ")
590
+ let command: String
591
+
592
+ if let keyCode = shortcut.keyCode {
593
+ command = modifiers.isEmpty
594
+ ? "key code \(keyCode)"
595
+ : "key code \(keyCode) using {\(modifiers)}"
596
+ } else if let key = shortcut.key {
597
+ command = modifiers.isEmpty
598
+ ? "keystroke \"\(key)\""
599
+ : "keystroke \"\(key)\" using {\(modifiers)}"
600
+ } else {
601
+ return false
602
+ }
603
+
604
+ let script = """
605
+ tell application "System Events"
606
+ \(command)
607
+ end tell
608
+ return "ok"
609
+ """
610
+ return ProcessQuery.shell(["/usr/bin/osascript", "-e", script]) == "ok"
611
+ }
612
+
613
+ private func sendDictationShortcut() -> Bool {
614
+ sendShortcut(
615
+ MouseShortcutKeyStroke(
616
+ key: "a",
617
+ keyCode: nil,
618
+ modifiers: [.command, .shift]
619
+ )
620
+ )
621
+ }
622
+ }
623
+
624
+ private final class MouseGestureOverlay {
625
+ private let committedHoldDuration: TimeInterval = 0.0
626
+ private let fadeDuration: TimeInterval = 0.03
627
+ private let accessoryCommittedHoldDuration: TimeInterval = 0.0
628
+ private let accessoryAnimationDuration: TimeInterval = 0.10
629
+
630
+ private let screen: NSScreen
631
+ private let window: NSWindow
632
+ private let overlayView: MouseGestureOverlayView
633
+ private var fadeTimer: Timer?
634
+ var onDismiss: (() -> Void)?
635
+
636
+ init(screen: NSScreen) {
637
+ self.screen = screen
638
+ self.window = NSWindow(
639
+ contentRect: screen.frame,
640
+ styleMask: .borderless,
641
+ backing: .buffered,
642
+ defer: false
643
+ )
644
+ self.overlayView = MouseGestureOverlayView(frame: NSRect(origin: .zero, size: screen.frame.size))
645
+
646
+ window.isOpaque = false
647
+ window.backgroundColor = .clear
648
+ window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
649
+ window.hasShadow = false
650
+ window.ignoresMouseEvents = true
651
+ window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
652
+ window.contentView = overlayView
653
+ window.orderFrontRegardless()
654
+ }
655
+
656
+ func track(origin: CGPoint, direction: MouseGestureDirection?, label: String?, progress: CGFloat) {
657
+ fadeTimer?.invalidate()
658
+ window.alphaValue = 1
659
+ if let direction {
660
+ overlayView.state = .tracking(
661
+ origin: localPoint(from: origin),
662
+ direction: direction,
663
+ label: label,
664
+ progress: progress
665
+ )
666
+ } else {
667
+ overlayView.state = .idle
668
+ }
669
+ overlayView.needsDisplay = true
670
+ }
671
+
672
+ func commit(
673
+ origin: CGPoint,
674
+ direction: MouseGestureDirection,
675
+ label: String,
676
+ success: Bool,
677
+ accessory: MouseGestureAccessory?
678
+ ) {
679
+ fadeTimer?.invalidate()
680
+ window.alphaValue = 1
681
+ overlayView.state = .committed(
682
+ origin: localPoint(from: origin),
683
+ direction: direction,
684
+ label: label,
685
+ success: success,
686
+ accessory: accessory,
687
+ accessoryAnimationDuration: accessoryAnimationDuration
688
+ )
689
+ overlayView.needsDisplay = true
690
+
691
+ let postReplayHoldDuration = accessory == nil ? committedHoldDuration : accessoryCommittedHoldDuration
692
+ let totalVisibleDuration = overlayView.replayLeadInDuration + postReplayHoldDuration
693
+ let timer = Timer(timeInterval: totalVisibleDuration, repeats: false) { [weak self] _ in
694
+ self?.dismiss()
695
+ }
696
+ fadeTimer = timer
697
+ RunLoop.main.add(timer, forMode: .common)
698
+ }
699
+
700
+ func dismiss(immediately: Bool = false) {
701
+ fadeTimer?.invalidate()
702
+ fadeTimer = nil
703
+
704
+ if immediately {
705
+ window.orderOut(nil)
706
+ finishDismissal()
707
+ return
708
+ }
709
+
710
+ NSAnimationContext.runAnimationGroup({ ctx in
711
+ ctx.duration = fadeDuration
712
+ window.animator().alphaValue = 0
713
+ }, completionHandler: { [weak self] in
714
+ self?.window.orderOut(nil)
715
+ self?.finishDismissal()
716
+ })
717
+ }
718
+
719
+ private func localPoint(from cgPoint: CGPoint) -> CGPoint {
720
+ let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height
721
+ let nsY = primaryHeight - cgPoint.y
722
+ return CGPoint(x: cgPoint.x - screen.frame.minX, y: nsY - screen.frame.minY)
723
+ }
724
+
725
+ private func finishDismissal() {
726
+ let callback = onDismiss
727
+ onDismiss = nil
728
+ callback?()
729
+ }
730
+ }
731
+
732
+ private final class MouseGestureOverlayView: NSView {
733
+ enum State {
734
+ case idle
735
+ case tracking(origin: CGPoint, direction: MouseGestureDirection?, label: String?, progress: CGFloat)
736
+ case committed(
737
+ origin: CGPoint,
738
+ direction: MouseGestureDirection,
739
+ label: String,
740
+ success: Bool,
741
+ accessory: MouseGestureAccessory?,
742
+ accessoryAnimationDuration: TimeInterval
743
+ )
744
+ }
745
+
746
+ var state: State = .idle {
747
+ didSet {
748
+ updateArrowAnimation(from: oldValue, to: state)
749
+ updateAccessoryAnimation()
750
+ }
751
+ }
752
+ private var accessoryAnimationTimer: Timer?
753
+ private var accessoryAnimationStartedAt: Date?
754
+ private var accessoryAnimationDuration: TimeInterval = 0
755
+ private var arrowAnimationTimer: Timer?
756
+ private var arrowAnimationStartedAt: Date?
757
+ private var arrowAnimationDuration: TimeInterval = 0
758
+ private var committedStartProgress: CGFloat = 0
759
+ private var accessoryAnimationDelay: TimeInterval = 0
760
+ private let committedArrowAnimationDuration: TimeInterval = 0.06
761
+ private let arrowAnimationDelay: TimeInterval = 0.012
762
+ private let labelRevealThreshold: CGFloat = 0.8
763
+
764
+ var replayLeadInDuration: TimeInterval {
765
+ if committedStartProgress >= labelRevealThreshold {
766
+ return 0
767
+ }
768
+ let remainingProgress = max(0, 1 - committedStartProgress)
769
+ return arrowAnimationDelay + committedArrowAnimationDuration * remainingProgress
770
+ }
771
+
772
+ override var isFlipped: Bool {
773
+ false
774
+ }
775
+
776
+ override func viewDidMoveToWindow() {
777
+ super.viewDidMoveToWindow()
778
+ if window == nil {
779
+ arrowAnimationTimer?.invalidate()
780
+ arrowAnimationTimer = nil
781
+ accessoryAnimationTimer?.invalidate()
782
+ accessoryAnimationTimer = nil
783
+ }
784
+ }
785
+
786
+ override func draw(_ dirtyRect: NSRect) {
787
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
788
+ ctx.clear(bounds)
789
+
790
+ switch state {
791
+ case .idle:
792
+ break
793
+ case .tracking(let origin, let direction, let label, let progress):
794
+ drawOrigin(at: origin, in: ctx, alpha: 0.88)
795
+ if let direction {
796
+ drawArrow(
797
+ from: origin,
798
+ direction: direction,
799
+ label: label,
800
+ success: true,
801
+ committed: false,
802
+ accessory: nil,
803
+ progressOverride: progress,
804
+ in: ctx
805
+ )
806
+ }
807
+ case .committed(let origin, let direction, let label, let success, let accessory, _):
808
+ drawOrigin(at: origin, in: ctx, alpha: 1.0)
809
+ drawArrow(from: origin, direction: direction, label: label, success: success, committed: true, accessory: accessory, progressOverride: nil, in: ctx)
810
+ }
811
+ }
812
+
813
+ private func drawOrigin(at point: CGPoint, in ctx: CGContext, alpha: CGFloat) {
814
+ ctx.setFillColor(NSColor(calibratedRed: 0.48, green: 0.76, blue: 1.0, alpha: alpha * 0.18).cgColor)
815
+ ctx.fillEllipse(in: CGRect(x: point.x - 18, y: point.y - 18, width: 36, height: 36))
816
+
817
+ ctx.setFillColor(NSColor(calibratedRed: 0.62, green: 0.84, blue: 1.0, alpha: alpha * 0.95).cgColor)
818
+ ctx.fillEllipse(in: CGRect(x: point.x - 5, y: point.y - 5, width: 10, height: 10))
819
+ }
820
+
821
+ private func drawArrow(
822
+ from origin: CGPoint,
823
+ direction: MouseGestureDirection,
824
+ label: String?,
825
+ success: Bool,
826
+ committed: Bool,
827
+ accessory: MouseGestureAccessory?,
828
+ progressOverride: CGFloat?,
829
+ in ctx: CGContext
830
+ ) {
831
+ let baseLength: CGFloat = 118
832
+ let arrowProgress = progressOverride ?? currentArrowProgress(committed: committed)
833
+ let clampedProgress = min(1, max(0, arrowProgress))
834
+ let length = baseLength * (committed ? max(0.14, clampedProgress) : (0.34 + 0.66 * clampedProgress))
835
+ let vector = arrowVector(for: direction, length: length)
836
+ let end = CGPoint(x: origin.x + vector.x, y: origin.y + vector.y)
837
+ let accent = success
838
+ ? NSColor(calibratedRed: 0.45, green: 0.80, blue: 1.0, alpha: 1.0)
839
+ : NSColor(calibratedRed: 0.98, green: 0.52, blue: 0.42, alpha: 1.0)
840
+ let strokeAlpha = committed ? 0.92 : (0.48 + 0.44 * clampedProgress)
841
+ let glowAlpha = committed ? 0.2 : (0.08 + 0.08 * clampedProgress)
842
+
843
+ ctx.saveGState()
844
+ ctx.setLineCap(.round)
845
+
846
+ let glowPath = CGMutablePath()
847
+ glowPath.move(to: origin)
848
+ glowPath.addLine(to: end)
849
+ ctx.addPath(glowPath)
850
+ ctx.setLineWidth(16)
851
+ ctx.setStrokeColor(accent.withAlphaComponent(glowAlpha).cgColor)
852
+ ctx.strokePath()
853
+
854
+ let linePath = CGMutablePath()
855
+ linePath.move(to: origin)
856
+ linePath.addLine(to: end)
857
+ ctx.addPath(linePath)
858
+ ctx.setLineWidth(5)
859
+ ctx.setStrokeColor(accent.withAlphaComponent(strokeAlpha).cgColor)
860
+ ctx.strokePath()
861
+
862
+ drawArrowHead(at: end, direction: direction, color: accent)
863
+ if let label, (!committed || clampedProgress >= labelRevealThreshold) {
864
+ drawLabel(label, from: origin, to: end, direction: direction, color: accent)
865
+ }
866
+ if committed, let accessory, clampedProgress >= labelRevealThreshold {
867
+ drawAccessory(accessory, from: origin, to: end, direction: direction, color: accent, in: ctx)
868
+ }
869
+ ctx.restoreGState()
870
+ }
871
+
872
+ private func drawArrowHead(at end: CGPoint, direction: MouseGestureDirection, color: NSColor) {
873
+ let size: CGFloat = 15
874
+ let path = NSBezierPath()
875
+
876
+ switch direction {
877
+ case .left:
878
+ path.move(to: CGPoint(x: end.x - size, y: end.y))
879
+ path.line(to: CGPoint(x: end.x + size * 0.2, y: end.y + size * 0.72))
880
+ path.line(to: CGPoint(x: end.x + size * 0.2, y: end.y - size * 0.72))
881
+ case .right:
882
+ path.move(to: CGPoint(x: end.x + size, y: end.y))
883
+ path.line(to: CGPoint(x: end.x - size * 0.2, y: end.y + size * 0.72))
884
+ path.line(to: CGPoint(x: end.x - size * 0.2, y: end.y - size * 0.72))
885
+ case .up:
886
+ path.move(to: CGPoint(x: end.x, y: end.y + size))
887
+ path.line(to: CGPoint(x: end.x - size * 0.72, y: end.y - size * 0.2))
888
+ path.line(to: CGPoint(x: end.x + size * 0.72, y: end.y - size * 0.2))
889
+ case .down:
890
+ path.move(to: CGPoint(x: end.x, y: end.y - size))
891
+ path.line(to: CGPoint(x: end.x - size * 0.72, y: end.y + size * 0.2))
892
+ path.line(to: CGPoint(x: end.x + size * 0.72, y: end.y + size * 0.2))
893
+ }
894
+
895
+ path.close()
896
+ color.withAlphaComponent(0.96).setFill()
897
+ path.fill()
898
+ }
899
+
900
+ private func drawLabel(_ label: String, from origin: CGPoint, to end: CGPoint, direction: MouseGestureDirection, color: NSColor) {
901
+ let font = NSFont.monospacedSystemFont(ofSize: 11, weight: .semibold)
902
+ let attributes: [NSAttributedString.Key: Any] = [
903
+ .font: font,
904
+ .foregroundColor: NSColor.white.withAlphaComponent(0.94),
905
+ ]
906
+ let attributed = NSAttributedString(string: label, attributes: attributes)
907
+ let textSize = attributed.size()
908
+ let paddingX: CGFloat = 10
909
+ let paddingY: CGFloat = 6
910
+ let bubbleSize = CGSize(
911
+ width: textSize.width + paddingX * 2,
912
+ height: textSize.height + paddingY * 2
913
+ )
914
+ let bubbleOrigin = labelOrigin(from: origin, to: end, direction: direction, bubbleSize: bubbleSize)
915
+ let rect = CGRect(
916
+ x: bubbleOrigin.x,
917
+ y: bubbleOrigin.y,
918
+ width: bubbleSize.width,
919
+ height: bubbleSize.height
920
+ )
921
+
922
+ let bg = NSBezierPath(roundedRect: rect, xRadius: 10, yRadius: 10)
923
+ NSColor.black.withAlphaComponent(0.42).setFill()
924
+ bg.fill()
925
+
926
+ let border = NSBezierPath(roundedRect: rect, xRadius: 10, yRadius: 10)
927
+ color.withAlphaComponent(0.26).setStroke()
928
+ border.lineWidth = 1
929
+ border.stroke()
930
+
931
+ let textRect = CGRect(
932
+ x: rect.minX + paddingX,
933
+ y: rect.minY + paddingY,
934
+ width: textSize.width,
935
+ height: textSize.height
936
+ )
937
+ attributed.draw(in: textRect)
938
+ }
939
+
940
+ private func labelOrigin(from origin: CGPoint, to end: CGPoint, direction: MouseGestureDirection, bubbleSize: CGSize) -> CGPoint {
941
+ let midpoint = CGPoint(x: (origin.x + end.x) / 2, y: (origin.y + end.y) / 2)
942
+ let proposedOrigin: CGPoint
943
+
944
+ switch direction {
945
+ case .left, .right:
946
+ proposedOrigin = CGPoint(x: midpoint.x - bubbleSize.width / 2, y: midpoint.y + 18)
947
+ case .up, .down:
948
+ proposedOrigin = CGPoint(x: midpoint.x + 20, y: midpoint.y - bubbleSize.height / 2)
949
+ }
950
+
951
+ let minX: CGFloat = 12
952
+ let minY: CGFloat = 12
953
+ let maxX = max(minX, bounds.width - bubbleSize.width - 12)
954
+ let maxY = max(minY, bounds.height - bubbleSize.height - 12)
955
+
956
+ return CGPoint(
957
+ x: min(max(proposedOrigin.x, minX), maxX),
958
+ y: min(max(proposedOrigin.y, minY), maxY)
959
+ )
960
+ }
961
+
962
+ private func arrowVector(for direction: MouseGestureDirection, length: CGFloat) -> CGPoint {
963
+ switch direction {
964
+ case .left:
965
+ return CGPoint(x: -length, y: 0)
966
+ case .right:
967
+ return CGPoint(x: length, y: 0)
968
+ case .up:
969
+ return CGPoint(x: 0, y: length)
970
+ case .down:
971
+ return CGPoint(x: 0, y: -length)
972
+ }
973
+ }
974
+
975
+ private func updateArrowAnimation(from oldState: State, to newState: State) {
976
+ let oldDirection = stateDirection(from: oldState)
977
+ let newDirection = stateDirection(from: newState)
978
+ let oldCommitted = isCommitted(state: oldState)
979
+ let newCommitted = isCommitted(state: newState)
980
+
981
+ if newCommitted, newDirection != nil {
982
+ let shouldRestart = oldDirection != newDirection || !oldCommitted
983
+ if shouldRestart {
984
+ let previousProgress = trackingProgress(from: oldState)
985
+ committedStartProgress = max(0, min(1, previousProgress ?? 0))
986
+ if committedStartProgress >= 0.94 {
987
+ arrowAnimationTimer?.invalidate()
988
+ arrowAnimationTimer = nil
989
+ arrowAnimationStartedAt = nil
990
+ arrowAnimationDuration = 0
991
+ committedStartProgress = 1
992
+ } else {
993
+ startArrowAnimation(duration: committedArrowAnimationDuration)
994
+ }
995
+ }
996
+ return
997
+ }
998
+
999
+ arrowAnimationTimer?.invalidate()
1000
+ arrowAnimationTimer = nil
1001
+ arrowAnimationStartedAt = nil
1002
+ arrowAnimationDuration = 0
1003
+ committedStartProgress = 0
1004
+ }
1005
+
1006
+ private func startArrowAnimation(duration: TimeInterval) {
1007
+ arrowAnimationTimer?.invalidate()
1008
+ arrowAnimationStartedAt = Date()
1009
+ arrowAnimationDuration = duration
1010
+
1011
+ let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in
1012
+ guard let self else {
1013
+ timer.invalidate()
1014
+ return
1015
+ }
1016
+ self.needsDisplay = true
1017
+ let elapsed = Date().timeIntervalSince(self.arrowAnimationStartedAt ?? Date())
1018
+ if elapsed >= self.replayLeadInDuration {
1019
+ timer.invalidate()
1020
+ self.arrowAnimationTimer = nil
1021
+ }
1022
+ }
1023
+ arrowAnimationTimer = timer
1024
+ RunLoop.main.add(timer, forMode: .common)
1025
+ }
1026
+
1027
+ private func currentArrowProgress(committed: Bool) -> CGFloat {
1028
+ guard committed,
1029
+ let startedAt = arrowAnimationStartedAt,
1030
+ arrowAnimationDuration > 0 else {
1031
+ return committed ? (committedStartProgress > 0 ? committedStartProgress : 1) : 1
1032
+ }
1033
+
1034
+ let delayedElapsed = Date().timeIntervalSince(startedAt) - arrowAnimationDelay
1035
+ guard delayedElapsed > 0 else { return committedStartProgress }
1036
+ let normalized = min(1, max(0, delayedElapsed / arrowAnimationDuration))
1037
+ let animated = easeOut(normalized)
1038
+ return committedStartProgress + (1 - committedStartProgress) * animated
1039
+ }
1040
+
1041
+ private func updateAccessoryAnimation() {
1042
+ accessoryAnimationTimer?.invalidate()
1043
+ accessoryAnimationTimer = nil
1044
+ accessoryAnimationStartedAt = nil
1045
+ accessoryAnimationDuration = 0
1046
+ accessoryAnimationDelay = 0
1047
+
1048
+ if case .committed(_, _, _, _, let accessory, let duration) = state, accessory != nil {
1049
+ accessoryAnimationStartedAt = Date()
1050
+ accessoryAnimationDuration = duration
1051
+ accessoryAnimationDelay = replayLeadInDuration * 0.86
1052
+ let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in
1053
+ guard let self else {
1054
+ timer.invalidate()
1055
+ return
1056
+ }
1057
+ self.needsDisplay = true
1058
+ let elapsed = Date().timeIntervalSince(self.accessoryAnimationStartedAt ?? Date())
1059
+ if elapsed >= self.accessoryAnimationDelay + self.accessoryAnimationDuration {
1060
+ timer.invalidate()
1061
+ self.accessoryAnimationTimer = nil
1062
+ }
1063
+ }
1064
+ accessoryAnimationTimer = timer
1065
+ RunLoop.main.add(timer, forMode: .common)
1066
+ }
1067
+ }
1068
+
1069
+ private func drawAccessory(
1070
+ _ accessory: MouseGestureAccessory,
1071
+ from origin: CGPoint,
1072
+ to end: CGPoint,
1073
+ direction: MouseGestureDirection,
1074
+ color: NSColor,
1075
+ in ctx: CGContext
1076
+ ) {
1077
+ guard let startedAt = accessoryAnimationStartedAt, accessoryAnimationDuration > 0 else { return }
1078
+ let delayedElapsed = Date().timeIntervalSince(startedAt) - accessoryAnimationDelay
1079
+ guard delayedElapsed > 0 else { return }
1080
+ let progress = min(1, max(0, delayedElapsed / accessoryAnimationDuration))
1081
+ let scale = 0.82 + 0.18 * easeOut(progress)
1082
+ let fadeStart: CGFloat = 0.58
1083
+ let alphaProgress = progress <= fadeStart ? 1 : 1 - ((progress - fadeStart) / (1 - fadeStart))
1084
+ let alpha = max(0, min(1, alphaProgress))
1085
+ guard alpha > 0 else { return }
1086
+
1087
+ let center = accessoryCenter(from: end, direction: direction)
1088
+ let badgeDiameter: CGFloat = 34 * scale
1089
+ let badgeRect = CGRect(
1090
+ x: center.x - badgeDiameter / 2,
1091
+ y: center.y - badgeDiameter / 2,
1092
+ width: badgeDiameter,
1093
+ height: badgeDiameter
1094
+ )
1095
+
1096
+ let badge = NSBezierPath(ovalIn: badgeRect)
1097
+ NSColor.black.withAlphaComponent(0.46 * alpha).setFill()
1098
+ badge.fill()
1099
+
1100
+ color.withAlphaComponent(0.32 * alpha).setStroke()
1101
+ badge.lineWidth = 1
1102
+ badge.stroke()
1103
+
1104
+ switch accessory {
1105
+ case .mic:
1106
+ drawMicGlyph(in: badgeRect.insetBy(dx: badgeDiameter * 0.26, dy: badgeDiameter * 0.2), color: color.withAlphaComponent(0.96 * alpha), in: ctx)
1107
+ }
1108
+ }
1109
+
1110
+ private func accessoryCenter(from end: CGPoint, direction: MouseGestureDirection) -> CGPoint {
1111
+ switch direction {
1112
+ case .up:
1113
+ return CGPoint(x: end.x, y: end.y + 34)
1114
+ case .down:
1115
+ return CGPoint(x: end.x, y: end.y - 34)
1116
+ case .left:
1117
+ return CGPoint(x: end.x - 34, y: end.y)
1118
+ case .right:
1119
+ return CGPoint(x: end.x + 34, y: end.y)
1120
+ }
1121
+ }
1122
+
1123
+ private func drawMicGlyph(in rect: CGRect, color: NSColor, in ctx: CGContext) {
1124
+ ctx.saveGState()
1125
+ color.setStroke()
1126
+ color.withAlphaComponent(0.22).setFill()
1127
+
1128
+ let bodyWidth = rect.width * 0.42
1129
+ let bodyHeight = rect.height * 0.54
1130
+ let bodyRect = CGRect(
1131
+ x: rect.midX - bodyWidth / 2,
1132
+ y: rect.maxY - bodyHeight,
1133
+ width: bodyWidth,
1134
+ height: bodyHeight
1135
+ )
1136
+ let body = NSBezierPath(roundedRect: bodyRect, xRadius: bodyWidth / 2, yRadius: bodyWidth / 2)
1137
+ body.lineWidth = 1.6
1138
+ body.fill()
1139
+ body.stroke()
1140
+
1141
+ let stem = NSBezierPath()
1142
+ stem.move(to: CGPoint(x: rect.midX, y: bodyRect.minY))
1143
+ stem.line(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.24))
1144
+ stem.lineWidth = 1.8
1145
+ stem.lineCapStyle = .round
1146
+ stem.stroke()
1147
+
1148
+ let arcRect = CGRect(
1149
+ x: rect.midX - rect.width * 0.28,
1150
+ y: rect.minY + rect.height * 0.18,
1151
+ width: rect.width * 0.56,
1152
+ height: rect.height * 0.42
1153
+ )
1154
+ let arc = NSBezierPath()
1155
+ arc.appendArc(
1156
+ withCenter: CGPoint(x: arcRect.midX, y: arcRect.midY + arcRect.height * 0.08),
1157
+ radius: arcRect.width / 2,
1158
+ startAngle: 200,
1159
+ endAngle: -20,
1160
+ clockwise: true
1161
+ )
1162
+ arc.lineWidth = 1.6
1163
+ arc.lineCapStyle = .round
1164
+ arc.stroke()
1165
+
1166
+ let base = NSBezierPath()
1167
+ base.move(to: CGPoint(x: rect.midX - rect.width * 0.22, y: rect.minY + rect.height * 0.14))
1168
+ base.line(to: CGPoint(x: rect.midX + rect.width * 0.22, y: rect.minY + rect.height * 0.14))
1169
+ base.lineWidth = 1.6
1170
+ base.lineCapStyle = .round
1171
+ base.stroke()
1172
+ ctx.restoreGState()
1173
+ }
1174
+
1175
+ private func easeOut(_ t: CGFloat) -> CGFloat {
1176
+ 1 - pow(1 - t, 3)
1177
+ }
1178
+
1179
+ private func stateDirection(from state: State) -> MouseGestureDirection? {
1180
+ switch state {
1181
+ case .idle:
1182
+ return nil
1183
+ case .tracking(_, let direction, _, _):
1184
+ return direction
1185
+ case .committed(_, let direction, _, _, _, _):
1186
+ return direction
1187
+ }
1188
+ }
1189
+
1190
+ private func isCommitted(state: State) -> Bool {
1191
+ if case .committed = state {
1192
+ return true
1193
+ }
1194
+ return false
1195
+ }
1196
+
1197
+ private func trackingProgress(from state: State) -> CGFloat? {
1198
+ if case .tracking(_, _, _, let progress) = state {
1199
+ return progress
1200
+ }
1201
+ return nil
1202
+ }
1203
+ }