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