@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,1240 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
struct ScreenOverlayLayerID: Hashable {
|
|
4
|
+
let rawValue: String
|
|
5
|
+
|
|
6
|
+
init(_ rawValue: String) {
|
|
7
|
+
self.rawValue = rawValue
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
enum ScreenOverlayOwner: String {
|
|
12
|
+
case dragSnap
|
|
13
|
+
case mouseGesture
|
|
14
|
+
case hotkeyHints
|
|
15
|
+
case focusHighlight
|
|
16
|
+
case agentApi
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
enum ScreenOverlayScreenTarget: Equatable {
|
|
20
|
+
case screen(id: String)
|
|
21
|
+
case all
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
struct ScreenOverlayLayerSnapshot {
|
|
25
|
+
let id: ScreenOverlayLayerID
|
|
26
|
+
let owner: ScreenOverlayOwner
|
|
27
|
+
let screen: ScreenOverlayScreenTarget
|
|
28
|
+
let zIndex: Int
|
|
29
|
+
let opacity: CGFloat
|
|
30
|
+
let payload: ScreenOverlayPayload
|
|
31
|
+
let expiresAt: Date?
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
enum ScreenOverlayPayload {
|
|
35
|
+
case snapZones(ScreenOverlaySnapZonesPayload)
|
|
36
|
+
case toast(ScreenOverlayTextPayload)
|
|
37
|
+
case label(ScreenOverlayTextPayload)
|
|
38
|
+
case highlight(ScreenOverlayHighlightPayload)
|
|
39
|
+
case pet(ScreenOverlayPetPayload)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
struct ScreenOverlaySnapZone {
|
|
43
|
+
let id: String
|
|
44
|
+
let label: String
|
|
45
|
+
let rect: CGRect
|
|
46
|
+
let isHovered: Bool
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
struct ScreenOverlaySnapZonesPayload {
|
|
50
|
+
let zones: [ScreenOverlaySnapZone]
|
|
51
|
+
let previewRect: CGRect?
|
|
52
|
+
let previewLabel: String?
|
|
53
|
+
let zoneOpacity: CGFloat
|
|
54
|
+
let highlightOpacity: CGFloat
|
|
55
|
+
let previewOpacity: CGFloat
|
|
56
|
+
let cornerRadius: CGFloat
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
struct ScreenOverlayTextPayload {
|
|
60
|
+
let text: String
|
|
61
|
+
let detail: String?
|
|
62
|
+
let point: CGPoint?
|
|
63
|
+
let placement: ScreenOverlayPlacement
|
|
64
|
+
let style: ScreenOverlayStyle
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
struct ScreenOverlayHighlightPayload {
|
|
68
|
+
let rect: CGRect
|
|
69
|
+
let label: String?
|
|
70
|
+
let style: ScreenOverlayStyle
|
|
71
|
+
let cornerRadius: CGFloat
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
struct ScreenOverlayPetPayload {
|
|
75
|
+
let glyph: String
|
|
76
|
+
let petID: String?
|
|
77
|
+
let state: String?
|
|
78
|
+
let name: String?
|
|
79
|
+
let message: String?
|
|
80
|
+
let point: CGPoint?
|
|
81
|
+
let placement: ScreenOverlayPlacement
|
|
82
|
+
let style: ScreenOverlayStyle
|
|
83
|
+
let isDragging: Bool
|
|
84
|
+
let dismissible: Bool
|
|
85
|
+
|
|
86
|
+
func moved(to point: CGPoint, state nextState: String?, isDragging nextIsDragging: Bool? = nil) -> ScreenOverlayPetPayload {
|
|
87
|
+
ScreenOverlayPetPayload(
|
|
88
|
+
glyph: glyph,
|
|
89
|
+
petID: petID,
|
|
90
|
+
state: nextState ?? state,
|
|
91
|
+
name: name,
|
|
92
|
+
message: message,
|
|
93
|
+
point: point,
|
|
94
|
+
placement: .point,
|
|
95
|
+
style: style,
|
|
96
|
+
isDragging: nextIsDragging ?? isDragging,
|
|
97
|
+
dismissible: dismissible
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
enum ScreenOverlayPlacement: String {
|
|
103
|
+
case top
|
|
104
|
+
case bottom
|
|
105
|
+
case center
|
|
106
|
+
case cursor
|
|
107
|
+
case point
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
enum ScreenOverlayStyle: String {
|
|
111
|
+
case info
|
|
112
|
+
case success
|
|
113
|
+
case warning
|
|
114
|
+
case danger
|
|
115
|
+
case playful
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
final class ScreenOverlayCanvasController {
|
|
119
|
+
static let shared = ScreenOverlayCanvasController()
|
|
120
|
+
|
|
121
|
+
private var windowsByScreenID: [String: ScreenOverlayWindow] = [:]
|
|
122
|
+
private var layersByID: [ScreenOverlayLayerID: ScreenOverlayLayerSnapshot] = [:]
|
|
123
|
+
private var motionsByLayerID: [ScreenOverlayLayerID: OverlayLayerMotion] = [:]
|
|
124
|
+
private var animationTimer: Timer?
|
|
125
|
+
private var globalDismissMonitor: Any?
|
|
126
|
+
private var localDismissMonitor: Any?
|
|
127
|
+
private var dragState: OverlayActorDragState?
|
|
128
|
+
private var agentActorsHidden = false
|
|
129
|
+
private let maxActorDragDuration: TimeInterval = 8.0
|
|
130
|
+
|
|
131
|
+
private init() {}
|
|
132
|
+
|
|
133
|
+
func warmUp() {
|
|
134
|
+
reconcileScreens()
|
|
135
|
+
for window in windowsByScreenID.values {
|
|
136
|
+
window.orderFrontRegardless()
|
|
137
|
+
window.alphaValue = 0
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func reconcileScreens() {
|
|
142
|
+
let currentScreenIDs = Set(NSScreen.screens.map(Self.screenID(for:)))
|
|
143
|
+
for staleID in windowsByScreenID.keys where !currentScreenIDs.contains(staleID) {
|
|
144
|
+
windowsByScreenID[staleID]?.orderOut(nil)
|
|
145
|
+
windowsByScreenID.removeValue(forKey: staleID)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for screen in NSScreen.screens {
|
|
149
|
+
let screenID = Self.screenID(for: screen)
|
|
150
|
+
let window = windowsByScreenID[screenID] ?? makeWindow(for: screen)
|
|
151
|
+
window.setFrame(screen.frame, display: false)
|
|
152
|
+
window.overlayView.frame = NSRect(origin: .zero, size: screen.frame.size)
|
|
153
|
+
windowsByScreenID[screenID] = window
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func publishLayer(_ layer: ScreenOverlayLayerSnapshot) {
|
|
158
|
+
layersByID[layer.id] = layer
|
|
159
|
+
scheduleExpiration(for: layer)
|
|
160
|
+
render()
|
|
161
|
+
updateLifecycleMonitors()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func replaceLayers(owner: ScreenOverlayOwner, with layers: [ScreenOverlayLayerSnapshot]) {
|
|
165
|
+
layersByID = layersByID.filter { _, layer in layer.owner != owner }
|
|
166
|
+
for layer in layers {
|
|
167
|
+
layersByID[layer.id] = layer
|
|
168
|
+
scheduleExpiration(for: layer)
|
|
169
|
+
}
|
|
170
|
+
render()
|
|
171
|
+
updateLifecycleMonitors()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func removeLayer(id: ScreenOverlayLayerID) {
|
|
175
|
+
layersByID.removeValue(forKey: id)
|
|
176
|
+
motionsByLayerID.removeValue(forKey: id)
|
|
177
|
+
if dragState?.id == id {
|
|
178
|
+
dragState = nil
|
|
179
|
+
resetPointerCapture()
|
|
180
|
+
}
|
|
181
|
+
render()
|
|
182
|
+
updateLifecycleMonitors()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func removeLayers(owner: ScreenOverlayOwner) {
|
|
186
|
+
let removedIDs = Set(layersByID.values.filter { $0.owner == owner }.map(\.id))
|
|
187
|
+
layersByID = layersByID.filter { _, layer in layer.owner != owner }
|
|
188
|
+
motionsByLayerID = motionsByLayerID.filter { id, _ in layersByID[id] != nil }
|
|
189
|
+
if let dragState, removedIDs.contains(dragState.id) {
|
|
190
|
+
self.dragState = nil
|
|
191
|
+
resetPointerCapture()
|
|
192
|
+
}
|
|
193
|
+
render()
|
|
194
|
+
updateLifecycleMonitors()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func toggleAgentActorsVisibility() {
|
|
198
|
+
agentActorsHidden.toggle()
|
|
199
|
+
if agentActorsHidden {
|
|
200
|
+
dragState = nil
|
|
201
|
+
resetPointerCapture()
|
|
202
|
+
}
|
|
203
|
+
render()
|
|
204
|
+
updateLifecycleMonitors()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func resetInputCapture(reason: String) {
|
|
208
|
+
dragState = nil
|
|
209
|
+
resetPointerCapture()
|
|
210
|
+
DiagnosticLog.shared.warn("ScreenOverlay: input capture reset for \(reason)")
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@discardableResult
|
|
214
|
+
func moveLayer(id: ScreenOverlayLayerID, to target: CGPoint, durationMs: Int, easing: String?) -> Bool {
|
|
215
|
+
guard let layer = layersByID[id],
|
|
216
|
+
case .pet(let payload) = layer.payload else { return false }
|
|
217
|
+
let now = Date()
|
|
218
|
+
let currentPoint = motionsByLayerID[id]?.point(at: now) ?? payload.point ?? target
|
|
219
|
+
let duration = max(0.08, min(Double(durationMs) / 1000.0, 8.0))
|
|
220
|
+
let restingState = payload.state == "run_left" || payload.state == "run_right" ? "idle" : payload.state
|
|
221
|
+
let movingState = target.x < currentPoint.x - 2 ? "run_left" : "run_right"
|
|
222
|
+
|
|
223
|
+
motionsByLayerID[id] = OverlayLayerMotion(
|
|
224
|
+
from: currentPoint,
|
|
225
|
+
to: target,
|
|
226
|
+
startedAt: now,
|
|
227
|
+
duration: duration,
|
|
228
|
+
easing: OverlayLayerMotion.Easing.parse(easing),
|
|
229
|
+
restingState: restingState
|
|
230
|
+
)
|
|
231
|
+
layersByID[id] = layer.replacingPayload(.pet(payload.moved(to: currentPoint, state: movingState)))
|
|
232
|
+
render()
|
|
233
|
+
updateLifecycleMonitors()
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
static func screenID(for screen: NSScreen) -> String {
|
|
238
|
+
let key = NSDeviceDescriptionKey("NSScreenNumber")
|
|
239
|
+
if let number = screen.deviceDescription[key] as? NSNumber {
|
|
240
|
+
return number.stringValue
|
|
241
|
+
}
|
|
242
|
+
return screen.localizedName
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private func makeWindow(for screen: NSScreen) -> ScreenOverlayWindow {
|
|
246
|
+
let window = ScreenOverlayWindow(frame: screen.frame)
|
|
247
|
+
windowsByScreenID[Self.screenID(for: screen)] = window
|
|
248
|
+
return window
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private func render() {
|
|
252
|
+
reconcileScreens()
|
|
253
|
+
dropExpiredLayers()
|
|
254
|
+
|
|
255
|
+
for screen in NSScreen.screens {
|
|
256
|
+
let screenID = Self.screenID(for: screen)
|
|
257
|
+
guard let window = windowsByScreenID[screenID] else { continue }
|
|
258
|
+
let visibleLayers = layersByID.values
|
|
259
|
+
.filter { layer in
|
|
260
|
+
if agentActorsHidden && layer.isParkableActor {
|
|
261
|
+
return false
|
|
262
|
+
}
|
|
263
|
+
switch layer.screen {
|
|
264
|
+
case .all:
|
|
265
|
+
return true
|
|
266
|
+
case .screen(let targetID):
|
|
267
|
+
return targetID == screenID
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
.sorted { left, right in
|
|
271
|
+
if left.zIndex != right.zIndex {
|
|
272
|
+
return left.zIndex < right.zIndex
|
|
273
|
+
}
|
|
274
|
+
return left.id.rawValue < right.id.rawValue
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
window.overlayView.layers = visibleLayers
|
|
278
|
+
if visibleLayers.isEmpty {
|
|
279
|
+
window.alphaValue = 0
|
|
280
|
+
} else {
|
|
281
|
+
window.alphaValue = 1
|
|
282
|
+
window.orderFrontRegardless()
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private func dropExpiredLayers() {
|
|
288
|
+
let now = Date()
|
|
289
|
+
layersByID = layersByID.filter { _, layer in
|
|
290
|
+
guard let expiresAt = layer.expiresAt else { return true }
|
|
291
|
+
return expiresAt > now
|
|
292
|
+
}
|
|
293
|
+
motionsByLayerID = motionsByLayerID.filter { id, _ in layersByID[id] != nil }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private func scheduleExpiration(for layer: ScreenOverlayLayerSnapshot) {
|
|
297
|
+
guard let expiresAt = layer.expiresAt else { return }
|
|
298
|
+
let delay = max(0, expiresAt.timeIntervalSinceNow)
|
|
299
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
300
|
+
guard let self,
|
|
301
|
+
let current = self.layersByID[layer.id],
|
|
302
|
+
current.expiresAt == expiresAt else { return }
|
|
303
|
+
self.layersByID.removeValue(forKey: layer.id)
|
|
304
|
+
self.motionsByLayerID.removeValue(forKey: layer.id)
|
|
305
|
+
self.render()
|
|
306
|
+
self.updateLifecycleMonitors()
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private func updateLifecycleMonitors() {
|
|
311
|
+
updateAnimationTimer()
|
|
312
|
+
updateDismissMonitors()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private func updateAnimationTimer() {
|
|
316
|
+
let needsAnimation = !motionsByLayerID.isEmpty || layersByID.values.contains { layer in
|
|
317
|
+
if case .pet = layer.payload { return true }
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if needsAnimation, animationTimer == nil {
|
|
322
|
+
animationTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
|
|
323
|
+
self?.tickAnimation()
|
|
324
|
+
}
|
|
325
|
+
} else if !needsAnimation {
|
|
326
|
+
animationTimer?.invalidate()
|
|
327
|
+
animationTimer = nil
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private func tickAnimation() {
|
|
332
|
+
let now = Date()
|
|
333
|
+
var completedIDs: [ScreenOverlayLayerID] = []
|
|
334
|
+
for (id, motion) in motionsByLayerID {
|
|
335
|
+
guard let layer = layersByID[id],
|
|
336
|
+
case .pet(let payload) = layer.payload else {
|
|
337
|
+
completedIDs.append(id)
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let point = motion.point(at: now)
|
|
342
|
+
let isComplete = motion.isComplete(at: now)
|
|
343
|
+
let state: String?
|
|
344
|
+
if isComplete {
|
|
345
|
+
state = motion.restingState
|
|
346
|
+
completedIDs.append(id)
|
|
347
|
+
} else {
|
|
348
|
+
state = motion.to.x < motion.from.x ? "run_left" : "run_right"
|
|
349
|
+
}
|
|
350
|
+
layersByID[id] = layer.replacingPayload(.pet(payload.moved(to: point, state: state)))
|
|
351
|
+
}
|
|
352
|
+
for id in completedIDs {
|
|
353
|
+
motionsByLayerID.removeValue(forKey: id)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if !completedIDs.isEmpty {
|
|
357
|
+
updateLifecycleMonitors()
|
|
358
|
+
}
|
|
359
|
+
render()
|
|
360
|
+
for window in windowsByScreenID.values {
|
|
361
|
+
window.overlayView.needsDisplay = true
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private func updateDismissMonitors() {
|
|
366
|
+
let hasAgentLayer = layersByID.values.contains { $0.owner == .agentApi }
|
|
367
|
+
if hasAgentLayer, globalDismissMonitor == nil {
|
|
368
|
+
let mask: NSEvent.EventTypeMask = [
|
|
369
|
+
.mouseMoved,
|
|
370
|
+
.leftMouseDown,
|
|
371
|
+
.leftMouseUp,
|
|
372
|
+
.rightMouseDown,
|
|
373
|
+
.otherMouseDown,
|
|
374
|
+
.leftMouseDragged,
|
|
375
|
+
.rightMouseDragged,
|
|
376
|
+
.otherMouseDragged,
|
|
377
|
+
]
|
|
378
|
+
globalDismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in
|
|
379
|
+
DispatchQueue.main.async {
|
|
380
|
+
_ = self?.handlePointerEvent(event)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
localDismissMonitor = NSEvent.addLocalMonitorForEvents(matching: mask.union(.keyDown)) { [weak self] event in
|
|
384
|
+
if event.type == .keyDown {
|
|
385
|
+
if event.keyCode == 53 {
|
|
386
|
+
self?.dismissAgentOverlays()
|
|
387
|
+
return nil
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
if self?.handlePointerEvent(event) == true {
|
|
391
|
+
return nil
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return event
|
|
395
|
+
}
|
|
396
|
+
} else if !hasAgentLayer {
|
|
397
|
+
if let globalDismissMonitor {
|
|
398
|
+
NSEvent.removeMonitor(globalDismissMonitor)
|
|
399
|
+
self.globalDismissMonitor = nil
|
|
400
|
+
}
|
|
401
|
+
if let localDismissMonitor {
|
|
402
|
+
NSEvent.removeMonitor(localDismissMonitor)
|
|
403
|
+
self.localDismissMonitor = nil
|
|
404
|
+
}
|
|
405
|
+
dragState = nil
|
|
406
|
+
resetPointerCapture()
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
@discardableResult
|
|
411
|
+
private func handlePointerEvent(_ event: NSEvent) -> Bool {
|
|
412
|
+
switch event.type {
|
|
413
|
+
case .mouseMoved:
|
|
414
|
+
updatePointerCapture(at: NSEvent.mouseLocation)
|
|
415
|
+
return false
|
|
416
|
+
case .leftMouseDown:
|
|
417
|
+
updatePointerCapture(at: NSEvent.mouseLocation)
|
|
418
|
+
if beginActorDrag(at: NSEvent.mouseLocation) {
|
|
419
|
+
return true
|
|
420
|
+
}
|
|
421
|
+
dismissAgentOverlays()
|
|
422
|
+
return false
|
|
423
|
+
case .leftMouseDragged:
|
|
424
|
+
if dragActor(to: NSEvent.mouseLocation) {
|
|
425
|
+
return true
|
|
426
|
+
}
|
|
427
|
+
dismissAgentOverlays()
|
|
428
|
+
return false
|
|
429
|
+
case .leftMouseUp:
|
|
430
|
+
let wasDragging = dragState != nil
|
|
431
|
+
endActorDrag()
|
|
432
|
+
updatePointerCapture(at: NSEvent.mouseLocation)
|
|
433
|
+
return wasDragging
|
|
434
|
+
case .rightMouseDown:
|
|
435
|
+
updatePointerCapture(at: NSEvent.mouseLocation)
|
|
436
|
+
if closeActor(at: NSEvent.mouseLocation) {
|
|
437
|
+
return true
|
|
438
|
+
}
|
|
439
|
+
dismissAgentOverlays()
|
|
440
|
+
return false
|
|
441
|
+
case .otherMouseDown, .rightMouseDragged, .otherMouseDragged:
|
|
442
|
+
dismissAgentOverlays()
|
|
443
|
+
return false
|
|
444
|
+
default:
|
|
445
|
+
return false
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private func updatePointerCapture(at globalPoint: CGPoint) {
|
|
450
|
+
clearStaleActorDragIfNeeded()
|
|
451
|
+
let captureWindow: ScreenOverlayWindow?
|
|
452
|
+
if let dragState {
|
|
453
|
+
captureWindow = windowsByScreenID[dragState.screenID]
|
|
454
|
+
} else {
|
|
455
|
+
captureWindow = hitActor(at: globalPoint)?.window
|
|
456
|
+
}
|
|
457
|
+
for window in windowsByScreenID.values {
|
|
458
|
+
window.ignoresMouseEvents = window !== captureWindow
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private func beginActorDrag(at globalPoint: CGPoint) -> Bool {
|
|
463
|
+
guard let hit = hitActor(at: globalPoint),
|
|
464
|
+
let layer = layersByID[hit.id],
|
|
465
|
+
case .pet(let payload) = layer.payload else { return false }
|
|
466
|
+
let currentPoint = motionsByLayerID[hit.id]?.point(at: Date()) ?? payload.point ?? hit.localPoint
|
|
467
|
+
motionsByLayerID.removeValue(forKey: hit.id)
|
|
468
|
+
dragState = OverlayActorDragState(
|
|
469
|
+
id: hit.id,
|
|
470
|
+
screenID: hit.screenID,
|
|
471
|
+
offset: CGPoint(x: hit.localPoint.x - currentPoint.x, y: hit.localPoint.y - currentPoint.y),
|
|
472
|
+
lastPoint: currentPoint,
|
|
473
|
+
startedAt: Date()
|
|
474
|
+
)
|
|
475
|
+
layersByID[hit.id] = layer.replacingPayload(.pet(payload.moved(to: currentPoint, state: "idle", isDragging: true)))
|
|
476
|
+
render()
|
|
477
|
+
updateLifecycleMonitors()
|
|
478
|
+
updatePointerCapture(at: globalPoint)
|
|
479
|
+
return true
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private func dragActor(to globalPoint: CGPoint) -> Bool {
|
|
483
|
+
guard var dragState,
|
|
484
|
+
let hit = screenLocalPoint(for: globalPoint),
|
|
485
|
+
let layer = layersByID[dragState.id],
|
|
486
|
+
case .pet(let payload) = layer.payload else { return false }
|
|
487
|
+
clearStaleActorDragIfNeeded()
|
|
488
|
+
guard self.dragState != nil else { return false }
|
|
489
|
+
let nextPoint = CGPoint(
|
|
490
|
+
x: hit.localPoint.x - dragState.offset.x,
|
|
491
|
+
y: hit.localPoint.y - dragState.offset.y
|
|
492
|
+
)
|
|
493
|
+
let state = nextPoint.x < dragState.lastPoint.x - 1 ? "run_left" : "run_right"
|
|
494
|
+
layersByID[dragState.id] = layer.replacingPayload(.pet(payload.moved(to: nextPoint, state: state, isDragging: true)))
|
|
495
|
+
dragState.screenID = hit.screenID
|
|
496
|
+
dragState.lastPoint = nextPoint
|
|
497
|
+
self.dragState = dragState
|
|
498
|
+
render()
|
|
499
|
+
updatePointerCapture(at: globalPoint)
|
|
500
|
+
return true
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private func endActorDrag() {
|
|
504
|
+
guard let dragState,
|
|
505
|
+
let layer = layersByID[dragState.id],
|
|
506
|
+
case .pet(let payload) = layer.payload else {
|
|
507
|
+
self.dragState = nil
|
|
508
|
+
resetPointerCapture()
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
layersByID[dragState.id] = layer.replacingPayload(.pet(payload.moved(to: dragState.lastPoint, state: "idle", isDragging: false)))
|
|
512
|
+
self.dragState = nil
|
|
513
|
+
render()
|
|
514
|
+
updateLifecycleMonitors()
|
|
515
|
+
updatePointerCapture(at: NSEvent.mouseLocation)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private func clearStaleActorDragIfNeeded() {
|
|
519
|
+
guard let dragState,
|
|
520
|
+
Date().timeIntervalSince(dragState.startedAt) > maxActorDragDuration else { return }
|
|
521
|
+
DiagnosticLog.shared.warn("ScreenOverlay: stale actor drag cleared for \(dragState.id.rawValue)")
|
|
522
|
+
endActorDrag()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private func resetPointerCapture() {
|
|
526
|
+
for window in windowsByScreenID.values {
|
|
527
|
+
window.ignoresMouseEvents = true
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private func closeActor(at globalPoint: CGPoint) -> Bool {
|
|
532
|
+
guard let hit = hitActor(at: globalPoint),
|
|
533
|
+
let layer = layersByID[hit.id],
|
|
534
|
+
layer.isParkableActor else { return false }
|
|
535
|
+
layersByID.removeValue(forKey: hit.id)
|
|
536
|
+
motionsByLayerID.removeValue(forKey: hit.id)
|
|
537
|
+
if dragState?.id == hit.id {
|
|
538
|
+
dragState = nil
|
|
539
|
+
}
|
|
540
|
+
render()
|
|
541
|
+
updateLifecycleMonitors()
|
|
542
|
+
updatePointerCapture(at: globalPoint)
|
|
543
|
+
return true
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private func hitActor(at globalPoint: CGPoint) -> (id: ScreenOverlayLayerID, window: ScreenOverlayWindow, screenID: String, localPoint: CGPoint)? {
|
|
547
|
+
guard let hit = screenLocalPoint(for: globalPoint) else { return nil }
|
|
548
|
+
guard let id = hit.window.overlayView.layerID(at: hit.localPoint) else { return nil }
|
|
549
|
+
return (id, hit.window, hit.screenID, hit.localPoint)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private func screenLocalPoint(for globalPoint: CGPoint) -> (window: ScreenOverlayWindow, screenID: String, localPoint: CGPoint)? {
|
|
553
|
+
for (screenID, window) in windowsByScreenID where window.frame.contains(globalPoint) {
|
|
554
|
+
let localPoint = CGPoint(
|
|
555
|
+
x: globalPoint.x - window.frame.minX,
|
|
556
|
+
y: globalPoint.y - window.frame.minY
|
|
557
|
+
)
|
|
558
|
+
return (window, screenID, localPoint)
|
|
559
|
+
}
|
|
560
|
+
return nil
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private func dismissAgentOverlays() {
|
|
564
|
+
let before = layersByID.count
|
|
565
|
+
layersByID = layersByID.filter { _, layer in
|
|
566
|
+
guard layer.owner == .agentApi else { return true }
|
|
567
|
+
return !layer.isDismissible
|
|
568
|
+
}
|
|
569
|
+
motionsByLayerID = motionsByLayerID.filter { id, _ in layersByID[id] != nil }
|
|
570
|
+
if let dragState, layersByID[dragState.id] == nil {
|
|
571
|
+
self.dragState = nil
|
|
572
|
+
resetPointerCapture()
|
|
573
|
+
}
|
|
574
|
+
guard layersByID.count != before else { return }
|
|
575
|
+
render()
|
|
576
|
+
updateLifecycleMonitors()
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private struct OverlayActorDragState {
|
|
581
|
+
let id: ScreenOverlayLayerID
|
|
582
|
+
var screenID: String
|
|
583
|
+
let offset: CGPoint
|
|
584
|
+
var lastPoint: CGPoint
|
|
585
|
+
let startedAt: Date
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private extension ScreenOverlayLayerSnapshot {
|
|
589
|
+
var isDismissible: Bool {
|
|
590
|
+
switch payload {
|
|
591
|
+
case .pet(let payload):
|
|
592
|
+
return payload.dismissible
|
|
593
|
+
default:
|
|
594
|
+
return true
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
var isParkableActor: Bool {
|
|
599
|
+
owner == .agentApi && !isDismissible
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
func replacingPayload(_ payload: ScreenOverlayPayload) -> ScreenOverlayLayerSnapshot {
|
|
603
|
+
ScreenOverlayLayerSnapshot(
|
|
604
|
+
id: id,
|
|
605
|
+
owner: owner,
|
|
606
|
+
screen: screen,
|
|
607
|
+
zIndex: zIndex,
|
|
608
|
+
opacity: opacity,
|
|
609
|
+
payload: payload,
|
|
610
|
+
expiresAt: expiresAt
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private struct OverlayLayerMotion {
|
|
616
|
+
enum Easing: String {
|
|
617
|
+
case linear
|
|
618
|
+
case easeInOut
|
|
619
|
+
case spring
|
|
620
|
+
|
|
621
|
+
static func parse(_ value: String?) -> Easing {
|
|
622
|
+
switch value?.lowercased() {
|
|
623
|
+
case "linear":
|
|
624
|
+
return .linear
|
|
625
|
+
case "easeinout", "ease-in-out", "ease_in_out":
|
|
626
|
+
return .easeInOut
|
|
627
|
+
case "spring", nil:
|
|
628
|
+
return .spring
|
|
629
|
+
default:
|
|
630
|
+
return .spring
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let from: CGPoint
|
|
636
|
+
let to: CGPoint
|
|
637
|
+
let startedAt: Date
|
|
638
|
+
let duration: TimeInterval
|
|
639
|
+
let easing: Easing
|
|
640
|
+
let restingState: String?
|
|
641
|
+
|
|
642
|
+
func isComplete(at date: Date) -> Bool {
|
|
643
|
+
date.timeIntervalSince(startedAt) >= duration
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
func point(at date: Date) -> CGPoint {
|
|
647
|
+
let rawProgress = duration <= 0 ? 1 : min(max(date.timeIntervalSince(startedAt) / duration, 0), 1)
|
|
648
|
+
let progress = eased(rawProgress)
|
|
649
|
+
return CGPoint(
|
|
650
|
+
x: from.x + (to.x - from.x) * progress,
|
|
651
|
+
y: from.y + (to.y - from.y) * progress
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private func eased(_ progress: Double) -> Double {
|
|
656
|
+
switch easing {
|
|
657
|
+
case .linear:
|
|
658
|
+
return progress
|
|
659
|
+
case .easeInOut:
|
|
660
|
+
return progress * progress * (3 - 2 * progress)
|
|
661
|
+
case .spring:
|
|
662
|
+
let damping = exp(-6.8 * progress)
|
|
663
|
+
let oscillation = cos(10.5 * progress)
|
|
664
|
+
return min(max(1 - damping * oscillation, 0), 1)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private final class ScreenOverlayWindow: NSPanel {
|
|
670
|
+
let overlayView = ScreenOverlayCanvasView(frame: .zero)
|
|
671
|
+
|
|
672
|
+
init(frame: CGRect) {
|
|
673
|
+
super.init(
|
|
674
|
+
contentRect: frame,
|
|
675
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
676
|
+
backing: .buffered,
|
|
677
|
+
defer: false
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
isOpaque = false
|
|
681
|
+
backgroundColor = .clear
|
|
682
|
+
hasShadow = false
|
|
683
|
+
ignoresMouseEvents = true
|
|
684
|
+
level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
|
|
685
|
+
collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
|
|
686
|
+
isMovable = false
|
|
687
|
+
hidesOnDeactivate = false
|
|
688
|
+
animationBehavior = .none
|
|
689
|
+
alphaValue = 0
|
|
690
|
+
overlayView.frame = NSRect(origin: .zero, size: frame.size)
|
|
691
|
+
overlayView.autoresizingMask = [.width, .height]
|
|
692
|
+
contentView = overlayView
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
override var canBecomeKey: Bool { false }
|
|
696
|
+
override var canBecomeMain: Bool { false }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private final class ScreenOverlayCanvasView: NSView {
|
|
700
|
+
var layers: [ScreenOverlayLayerSnapshot] = [] {
|
|
701
|
+
didSet { needsDisplay = true }
|
|
702
|
+
}
|
|
703
|
+
private var interactiveRectsByLayerID: [ScreenOverlayLayerID: CGRect] = [:]
|
|
704
|
+
|
|
705
|
+
override init(frame frameRect: NSRect) {
|
|
706
|
+
super.init(frame: frameRect)
|
|
707
|
+
wantsLayer = true
|
|
708
|
+
layer?.backgroundColor = NSColor.clear.cgColor
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
required init?(coder: NSCoder) {
|
|
712
|
+
fatalError("init(coder:) has not been implemented")
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
716
|
+
NSColor.clear.setFill()
|
|
717
|
+
bounds.fill()
|
|
718
|
+
interactiveRectsByLayerID.removeAll()
|
|
719
|
+
|
|
720
|
+
for layer in layers {
|
|
721
|
+
NSGraphicsContext.saveGraphicsState()
|
|
722
|
+
NSColor.black.withAlphaComponent(0).set()
|
|
723
|
+
switch layer.payload {
|
|
724
|
+
case .snapZones(let payload):
|
|
725
|
+
drawSnapZones(payload, opacity: layer.opacity)
|
|
726
|
+
case .toast(let payload):
|
|
727
|
+
drawTextPill(payload, opacity: layer.opacity, isToast: true)
|
|
728
|
+
case .label(let payload):
|
|
729
|
+
drawTextPill(payload, opacity: layer.opacity, isToast: false)
|
|
730
|
+
case .highlight(let payload):
|
|
731
|
+
drawHighlight(payload, opacity: layer.opacity)
|
|
732
|
+
case .pet(let payload):
|
|
733
|
+
drawPet(payload, id: layer.id, opacity: layer.opacity)
|
|
734
|
+
}
|
|
735
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
func layerID(at point: CGPoint) -> ScreenOverlayLayerID? {
|
|
740
|
+
interactiveRectsByLayerID
|
|
741
|
+
.first { _, rect in rect.contains(point) }
|
|
742
|
+
.map(\.key)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private func drawTextPill(_ payload: ScreenOverlayTextPayload, opacity: CGFloat, isToast: Bool) {
|
|
746
|
+
let titleFont = NSFont.monospacedSystemFont(ofSize: isToast ? 13 : 11, weight: .semibold)
|
|
747
|
+
let detailFont = NSFont.systemFont(ofSize: 11, weight: .regular)
|
|
748
|
+
let maxWidth = min(bounds.width - 64, isToast ? 460 : 320)
|
|
749
|
+
let title = attributed(payload.text, font: titleFont, color: NSColor.white.withAlphaComponent(0.94 * opacity))
|
|
750
|
+
let detail = payload.detail.map {
|
|
751
|
+
attributed($0, font: detailFont, color: NSColor.white.withAlphaComponent(0.66 * opacity))
|
|
752
|
+
}
|
|
753
|
+
let detailSize = detail?.boundingRect(
|
|
754
|
+
with: CGSize(width: maxWidth - 28, height: 120),
|
|
755
|
+
options: [.usesLineFragmentOrigin, .usesFontLeading]
|
|
756
|
+
).size ?? .zero
|
|
757
|
+
let titleSize = title.boundingRect(
|
|
758
|
+
with: CGSize(width: maxWidth - 28, height: 80),
|
|
759
|
+
options: [.usesLineFragmentOrigin, .usesFontLeading]
|
|
760
|
+
).size
|
|
761
|
+
let width = min(maxWidth, max(110, max(titleSize.width, detailSize.width) + 28))
|
|
762
|
+
let height = max(30, titleSize.height + (detail == nil ? 12 : detailSize.height + 18))
|
|
763
|
+
let origin = overlayOrigin(
|
|
764
|
+
placement: payload.placement,
|
|
765
|
+
point: payload.point,
|
|
766
|
+
size: CGSize(width: width, height: height),
|
|
767
|
+
margin: isToast ? 42 : 18
|
|
768
|
+
)
|
|
769
|
+
let rect = CGRect(origin: origin, size: CGSize(width: width, height: height))
|
|
770
|
+
|
|
771
|
+
drawPanel(rect, style: payload.style, opacity: opacity, radius: min(16, height / 2))
|
|
772
|
+
title.draw(with: CGRect(x: rect.minX + 14, y: rect.maxY - titleSize.height - (detail == nil ? 8 : 10), width: width - 28, height: titleSize.height), options: [.usesLineFragmentOrigin])
|
|
773
|
+
if let detail {
|
|
774
|
+
detail.draw(with: CGRect(x: rect.minX + 14, y: rect.minY + 8, width: width - 28, height: detailSize.height + 2), options: [.usesLineFragmentOrigin])
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
private func drawHighlight(_ payload: ScreenOverlayHighlightPayload, opacity: CGFloat) {
|
|
779
|
+
let rect = payload.rect.insetBy(dx: -3, dy: -3)
|
|
780
|
+
let radius = min(payload.cornerRadius, min(rect.width, rect.height) * 0.2)
|
|
781
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
|
782
|
+
let tint = color(for: payload.style)
|
|
783
|
+
|
|
784
|
+
let shadow = NSShadow()
|
|
785
|
+
shadow.shadowBlurRadius = 18
|
|
786
|
+
shadow.shadowOffset = .zero
|
|
787
|
+
shadow.shadowColor = tint.withAlphaComponent(0.25 * opacity)
|
|
788
|
+
NSGraphicsContext.saveGraphicsState()
|
|
789
|
+
shadow.set()
|
|
790
|
+
tint.withAlphaComponent(0.08 * opacity).setFill()
|
|
791
|
+
path.fill()
|
|
792
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
793
|
+
|
|
794
|
+
path.lineWidth = 2
|
|
795
|
+
tint.withAlphaComponent(0.82 * opacity).setStroke()
|
|
796
|
+
path.stroke()
|
|
797
|
+
|
|
798
|
+
if let label = payload.label, !label.isEmpty {
|
|
799
|
+
let textPayload = ScreenOverlayTextPayload(
|
|
800
|
+
text: label,
|
|
801
|
+
detail: nil,
|
|
802
|
+
point: CGPoint(x: rect.minX + 14, y: rect.maxY + 18),
|
|
803
|
+
placement: .point,
|
|
804
|
+
style: payload.style
|
|
805
|
+
)
|
|
806
|
+
drawTextPill(textPayload, opacity: opacity, isToast: false)
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private func drawPet(_ payload: ScreenOverlayPetPayload, id: ScreenOverlayLayerID, opacity: CGFloat) {
|
|
811
|
+
let glyphFont = NSFont.systemFont(ofSize: 44, weight: .regular)
|
|
812
|
+
let nameFont = NSFont.systemFont(ofSize: 12, weight: .semibold)
|
|
813
|
+
let messageFont = NSFont.systemFont(ofSize: 12, weight: .regular)
|
|
814
|
+
let glyph = attributed(payload.glyph, font: glyphFont, color: NSColor.white.withAlphaComponent(0.96 * opacity))
|
|
815
|
+
let name = payload.name.map { attributed($0, font: nameFont, color: NSColor.white.withAlphaComponent(0.96 * opacity)) }
|
|
816
|
+
let message = payload.message.map {
|
|
817
|
+
attributed($0, font: messageFont, color: NSColor.white.withAlphaComponent(0.86 * opacity))
|
|
818
|
+
}
|
|
819
|
+
let artSize = CGSize(width: 96, height: 104)
|
|
820
|
+
let textWidth: CGFloat = (name == nil && message == nil) ? 0 : 228
|
|
821
|
+
let textHeight = textPlateHeight(name: name, message: message, width: textWidth)
|
|
822
|
+
let bubbleWidth = artSize.width + (textWidth > 0 ? textWidth + 10 : 0)
|
|
823
|
+
let bubbleHeight = max(artSize.height, textHeight)
|
|
824
|
+
let origin = overlayOrigin(
|
|
825
|
+
placement: payload.placement,
|
|
826
|
+
point: payload.point,
|
|
827
|
+
size: CGSize(width: bubbleWidth, height: bubbleHeight),
|
|
828
|
+
margin: 30
|
|
829
|
+
)
|
|
830
|
+
let rect = CGRect(origin: origin, size: CGSize(width: bubbleWidth, height: bubbleHeight))
|
|
831
|
+
let artRect = CGRect(
|
|
832
|
+
x: rect.minX,
|
|
833
|
+
y: rect.midY - artSize.height / 2,
|
|
834
|
+
width: artSize.width,
|
|
835
|
+
height: artSize.height
|
|
836
|
+
)
|
|
837
|
+
let dragPhase = Date().timeIntervalSinceReferenceDate * 11
|
|
838
|
+
let dragLift: CGFloat = payload.isDragging ? 8 + CGFloat(sin(dragPhase)) * 2.5 : 0
|
|
839
|
+
let dragTilt: CGFloat = payload.isDragging
|
|
840
|
+
? (payload.state == "run_left" ? 7 : -7) + CGFloat(sin(dragPhase * 0.72)) * 2.5
|
|
841
|
+
: 0
|
|
842
|
+
let dragScaleX: CGFloat = payload.isDragging ? 1.05 + CGFloat(sin(dragPhase * 0.9)) * 0.018 : 1
|
|
843
|
+
let dragScaleY: CGFloat = payload.isDragging ? 0.98 + CGFloat(cos(dragPhase * 0.9)) * 0.018 : 1
|
|
844
|
+
let bodyRect = artRect.offsetBy(dx: 0, dy: dragLift)
|
|
845
|
+
|
|
846
|
+
NSGraphicsContext.saveGraphicsState()
|
|
847
|
+
if payload.isDragging {
|
|
848
|
+
let transform = NSAffineTransform()
|
|
849
|
+
transform.translateX(by: bodyRect.midX, yBy: bodyRect.midY)
|
|
850
|
+
transform.rotate(byDegrees: dragTilt)
|
|
851
|
+
transform.scaleX(by: dragScaleX, yBy: dragScaleY)
|
|
852
|
+
transform.translateX(by: -bodyRect.midX, yBy: -bodyRect.midY)
|
|
853
|
+
transform.concat()
|
|
854
|
+
}
|
|
855
|
+
if let petID = payload.petID,
|
|
856
|
+
let frame = CodexPetAssetCache.shared.frame(for: petID, state: payload.state) {
|
|
857
|
+
frame.image.draw(
|
|
858
|
+
in: bodyRect,
|
|
859
|
+
from: frame.sourceRect,
|
|
860
|
+
operation: .sourceOver,
|
|
861
|
+
fraction: opacity,
|
|
862
|
+
respectFlipped: true,
|
|
863
|
+
hints: [.interpolation: NSImageInterpolation.high]
|
|
864
|
+
)
|
|
865
|
+
} else {
|
|
866
|
+
glyph.draw(
|
|
867
|
+
with: CGRect(x: bodyRect.midX - 26, y: bodyRect.midY - 26, width: 52, height: 52),
|
|
868
|
+
options: [.usesLineFragmentOrigin]
|
|
869
|
+
)
|
|
870
|
+
}
|
|
871
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
872
|
+
|
|
873
|
+
guard textWidth > 0 else {
|
|
874
|
+
interactiveRectsByLayerID[id] = artRect.insetBy(dx: -8, dy: -8)
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
let textRect = CGRect(
|
|
878
|
+
x: artRect.maxX + 10,
|
|
879
|
+
y: rect.midY - textHeight / 2,
|
|
880
|
+
width: textWidth,
|
|
881
|
+
height: textHeight
|
|
882
|
+
)
|
|
883
|
+
interactiveRectsByLayerID[id] = artRect.union(textRect).insetBy(dx: -8, dy: -8)
|
|
884
|
+
drawTranslucentTextWash(textRect, opacity: opacity)
|
|
885
|
+
|
|
886
|
+
var cursorY = textRect.maxY - 10
|
|
887
|
+
if let name {
|
|
888
|
+
let nameRect = CGRect(x: textRect.minX + 12, y: cursorY - 16, width: textRect.width - 24, height: 16)
|
|
889
|
+
drawCrispOverlayText(name, in: nameRect, opacity: opacity)
|
|
890
|
+
cursorY = nameRect.minY - 4
|
|
891
|
+
}
|
|
892
|
+
if let message {
|
|
893
|
+
let messageRect = CGRect(x: textRect.minX + 12, y: textRect.minY + 10, width: textRect.width - 24, height: max(18, cursorY - textRect.minY - 10))
|
|
894
|
+
drawCrispOverlayText(message, in: messageRect, opacity: opacity)
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
private func textPlateHeight(name: NSAttributedString?, message: NSAttributedString?, width: CGFloat) -> CGFloat {
|
|
899
|
+
guard width > 0 else { return 0 }
|
|
900
|
+
let messageSize = message?.boundingRect(
|
|
901
|
+
with: CGSize(width: width - 24, height: 72),
|
|
902
|
+
options: [.usesLineFragmentOrigin, .usesFontLeading]
|
|
903
|
+
).size ?? .zero
|
|
904
|
+
return max(38, (name == nil ? 0 : 20) + (message == nil ? 0 : ceil(messageSize.height) + 10) + 18)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private func drawTranslucentTextWash(_ rect: CGRect, opacity: CGFloat) {
|
|
908
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: 8, yRadius: 8)
|
|
909
|
+
NSColor(calibratedWhite: 0.02, alpha: 0.34 * opacity).setFill()
|
|
910
|
+
path.fill()
|
|
911
|
+
|
|
912
|
+
path.lineWidth = 0.5
|
|
913
|
+
NSColor.white.withAlphaComponent(0.10 * opacity).setStroke()
|
|
914
|
+
path.stroke()
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
private func drawCrispOverlayText(_ text: NSAttributedString, in rect: CGRect, opacity: CGFloat) {
|
|
918
|
+
let shadow = NSShadow()
|
|
919
|
+
shadow.shadowBlurRadius = 2
|
|
920
|
+
shadow.shadowOffset = NSSize(width: 0, height: -1)
|
|
921
|
+
shadow.shadowColor = NSColor.black.withAlphaComponent(0.72 * opacity)
|
|
922
|
+
|
|
923
|
+
NSGraphicsContext.saveGraphicsState()
|
|
924
|
+
shadow.set()
|
|
925
|
+
text.draw(with: rect.offsetBy(dx: 0, dy: -0.5), options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
|
|
926
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
927
|
+
|
|
928
|
+
let halo = NSMutableAttributedString(attributedString: text)
|
|
929
|
+
halo.addAttribute(.foregroundColor, value: NSColor.black.withAlphaComponent(0.36 * opacity), range: NSRange(location: 0, length: halo.length))
|
|
930
|
+
halo.draw(with: rect.offsetBy(dx: 0.5, dy: -0.5), options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
|
|
931
|
+
halo.draw(with: rect.offsetBy(dx: -0.5, dy: -0.5), options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
|
|
932
|
+
|
|
933
|
+
text.draw(with: rect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private func drawSnapZones(_ payload: ScreenOverlaySnapZonesPayload, opacity: CGFloat) {
|
|
937
|
+
for zone in payload.zones {
|
|
938
|
+
drawSnapZone(zone, payload: payload, opacity: opacity)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if let previewRect = payload.previewRect {
|
|
942
|
+
drawSnapPreview(previewRect, label: payload.previewLabel, payload: payload, opacity: opacity)
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private func drawSnapZone(
|
|
947
|
+
_ zone: ScreenOverlaySnapZone,
|
|
948
|
+
payload: ScreenOverlaySnapZonesPayload,
|
|
949
|
+
opacity: CGFloat
|
|
950
|
+
) {
|
|
951
|
+
let rect = zone.rect.insetBy(dx: 1.5, dy: 1.5)
|
|
952
|
+
let radius = min(payload.cornerRadius, min(rect.width, rect.height) * 0.34)
|
|
953
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
|
954
|
+
let idleStrength = max(0.35, min(payload.zoneOpacity / 0.10, 1.4))
|
|
955
|
+
let hoverStrength = max(0.35, min(payload.highlightOpacity / 0.22, 1.4))
|
|
956
|
+
|
|
957
|
+
let shadow = NSShadow()
|
|
958
|
+
shadow.shadowBlurRadius = zone.isHovered ? 18 : 10
|
|
959
|
+
shadow.shadowOffset = NSSize(width: 0, height: -2)
|
|
960
|
+
shadow.shadowColor = NSColor.black.withAlphaComponent((zone.isHovered ? 0.20 : 0.10) * opacity)
|
|
961
|
+
|
|
962
|
+
NSGraphicsContext.saveGraphicsState()
|
|
963
|
+
shadow.set()
|
|
964
|
+
let baseTop = NSColor(
|
|
965
|
+
calibratedWhite: 0.13,
|
|
966
|
+
alpha: (zone.isHovered ? 0.42 * hoverStrength : 0.22 * idleStrength) * opacity
|
|
967
|
+
)
|
|
968
|
+
let baseBottom = NSColor(
|
|
969
|
+
calibratedWhite: 0.07,
|
|
970
|
+
alpha: (zone.isHovered ? 0.34 * hoverStrength : 0.15 * idleStrength) * opacity
|
|
971
|
+
)
|
|
972
|
+
NSGradient(starting: baseTop, ending: baseBottom)?.draw(in: path, angle: -90)
|
|
973
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
974
|
+
|
|
975
|
+
if zone.isHovered {
|
|
976
|
+
let glowPath = path.copy() as! NSBezierPath
|
|
977
|
+
glowPath.lineWidth = 6
|
|
978
|
+
NSColor(
|
|
979
|
+
calibratedRed: 0.25,
|
|
980
|
+
green: 0.84,
|
|
981
|
+
blue: 0.58,
|
|
982
|
+
alpha: payload.highlightOpacity * 0.28 * opacity
|
|
983
|
+
).setStroke()
|
|
984
|
+
glowPath.stroke()
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
path.lineWidth = zone.isHovered ? 1.6 : 1.0
|
|
988
|
+
NSColor(
|
|
989
|
+
calibratedRed: 0.52,
|
|
990
|
+
green: 0.94,
|
|
991
|
+
blue: 0.72,
|
|
992
|
+
alpha: (zone.isHovered ? 0.54 * hoverStrength : 0.10 * idleStrength) * opacity
|
|
993
|
+
).setStroke()
|
|
994
|
+
path.stroke()
|
|
995
|
+
|
|
996
|
+
let lipRect = CGRect(x: rect.minX + 1.5, y: rect.maxY - 2.5, width: rect.width - 3, height: 2)
|
|
997
|
+
if lipRect.width > 0 {
|
|
998
|
+
let lipPath = NSBezierPath(roundedRect: lipRect, xRadius: 1, yRadius: 1)
|
|
999
|
+
NSColor.white.withAlphaComponent((zone.isHovered ? 0.18 : 0.08) * opacity).setFill()
|
|
1000
|
+
lipPath.fill()
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
drawLabel(zone.label, in: rect, emphasized: zone.isHovered, opacity: opacity)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private func drawSnapPreview(
|
|
1007
|
+
_ rect: CGRect,
|
|
1008
|
+
label: String?,
|
|
1009
|
+
payload: ScreenOverlaySnapZonesPayload,
|
|
1010
|
+
opacity: CGFloat
|
|
1011
|
+
) {
|
|
1012
|
+
let previewRect = rect.insetBy(dx: 10, dy: 10)
|
|
1013
|
+
let radius = min(payload.cornerRadius, min(previewRect.width, previewRect.height) * 0.14)
|
|
1014
|
+
let path = NSBezierPath(roundedRect: previewRect, xRadius: radius, yRadius: radius)
|
|
1015
|
+
|
|
1016
|
+
NSColor(calibratedWhite: 1.0, alpha: payload.previewOpacity * 0.22 * opacity).setFill()
|
|
1017
|
+
path.fill()
|
|
1018
|
+
|
|
1019
|
+
path.lineWidth = 1.6
|
|
1020
|
+
path.setLineDash([10, 8], count: 2, phase: 0)
|
|
1021
|
+
NSColor(
|
|
1022
|
+
calibratedRed: 0.44,
|
|
1023
|
+
green: 0.90,
|
|
1024
|
+
blue: 0.68,
|
|
1025
|
+
alpha: max(0.34, payload.previewOpacity * 3.2) * opacity
|
|
1026
|
+
).setStroke()
|
|
1027
|
+
path.stroke()
|
|
1028
|
+
path.setLineDash([], count: 0, phase: 0)
|
|
1029
|
+
|
|
1030
|
+
let innerPath = NSBezierPath(
|
|
1031
|
+
roundedRect: previewRect.insetBy(dx: 7, dy: 7),
|
|
1032
|
+
xRadius: max(radius - 4, 8),
|
|
1033
|
+
yRadius: max(radius - 4, 8)
|
|
1034
|
+
)
|
|
1035
|
+
innerPath.lineWidth = 1
|
|
1036
|
+
NSColor.white.withAlphaComponent(max(0.08, payload.previewOpacity * 1.2) * opacity).setStroke()
|
|
1037
|
+
innerPath.stroke()
|
|
1038
|
+
|
|
1039
|
+
if let label {
|
|
1040
|
+
let tagRect = CGRect(x: previewRect.minX + 14, y: previewRect.maxY - 34, width: 110, height: 24)
|
|
1041
|
+
let tagPath = NSBezierPath(roundedRect: tagRect, xRadius: 12, yRadius: 12)
|
|
1042
|
+
NSColor(calibratedWhite: 0.08, alpha: 0.62 * opacity).setFill()
|
|
1043
|
+
tagPath.fill()
|
|
1044
|
+
NSColor.white.withAlphaComponent(0.10 * opacity).setStroke()
|
|
1045
|
+
tagPath.lineWidth = 1
|
|
1046
|
+
tagPath.stroke()
|
|
1047
|
+
drawLabel(label, in: tagRect, emphasized: true, opacity: opacity)
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private func drawLabel(_ label: String, in rect: CGRect, emphasized: Bool, opacity: CGFloat) {
|
|
1052
|
+
let font = NSFont.monospacedSystemFont(ofSize: emphasized ? 11 : 10, weight: emphasized ? .semibold : .medium)
|
|
1053
|
+
let attributes: [NSAttributedString.Key: Any] = [
|
|
1054
|
+
.font: font,
|
|
1055
|
+
.foregroundColor: NSColor.white.withAlphaComponent((emphasized ? 0.92 : 0.72) * opacity),
|
|
1056
|
+
]
|
|
1057
|
+
let attributed = NSAttributedString(string: label.uppercased(), attributes: attributes)
|
|
1058
|
+
let size = attributed.size()
|
|
1059
|
+
let drawPoint = CGPoint(
|
|
1060
|
+
x: rect.midX - size.width / 2,
|
|
1061
|
+
y: rect.midY - size.height / 2
|
|
1062
|
+
)
|
|
1063
|
+
attributed.draw(at: drawPoint)
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
private func attributed(_ text: String, font: NSFont, color: NSColor) -> NSAttributedString {
|
|
1067
|
+
NSAttributedString(string: text, attributes: [
|
|
1068
|
+
.font: font,
|
|
1069
|
+
.foregroundColor: color,
|
|
1070
|
+
])
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
private func overlayOrigin(
|
|
1074
|
+
placement: ScreenOverlayPlacement,
|
|
1075
|
+
point: CGPoint?,
|
|
1076
|
+
size: CGSize,
|
|
1077
|
+
margin: CGFloat
|
|
1078
|
+
) -> CGPoint {
|
|
1079
|
+
let cursor = convertGlobalPointToLocal(NSEvent.mouseLocation)
|
|
1080
|
+
let anchor: CGPoint
|
|
1081
|
+
switch placement {
|
|
1082
|
+
case .top:
|
|
1083
|
+
anchor = CGPoint(x: bounds.midX, y: bounds.maxY - margin - size.height / 2)
|
|
1084
|
+
case .bottom:
|
|
1085
|
+
anchor = CGPoint(x: bounds.midX, y: bounds.minY + margin + size.height / 2)
|
|
1086
|
+
case .center:
|
|
1087
|
+
anchor = CGPoint(x: bounds.midX, y: bounds.midY)
|
|
1088
|
+
case .cursor:
|
|
1089
|
+
anchor = cursor
|
|
1090
|
+
case .point:
|
|
1091
|
+
anchor = point ?? cursor
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
return CGPoint(
|
|
1095
|
+
x: min(max(anchor.x - size.width / 2, 16), bounds.maxX - size.width - 16),
|
|
1096
|
+
y: min(max(anchor.y - size.height / 2, 16), bounds.maxY - size.height - 16)
|
|
1097
|
+
)
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
private func convertGlobalPointToLocal(_ point: CGPoint) -> CGPoint {
|
|
1101
|
+
guard let window else { return point }
|
|
1102
|
+
return CGPoint(x: point.x - window.frame.minX, y: point.y - window.frame.minY)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
private func drawPanel(_ rect: CGRect, style: ScreenOverlayStyle, opacity: CGFloat, radius: CGFloat) {
|
|
1106
|
+
let tint = color(for: style)
|
|
1107
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
|
1108
|
+
let shadow = NSShadow()
|
|
1109
|
+
shadow.shadowBlurRadius = 18
|
|
1110
|
+
shadow.shadowOffset = NSSize(width: 0, height: -4)
|
|
1111
|
+
shadow.shadowColor = NSColor.black.withAlphaComponent(0.26 * opacity)
|
|
1112
|
+
|
|
1113
|
+
NSGraphicsContext.saveGraphicsState()
|
|
1114
|
+
shadow.set()
|
|
1115
|
+
NSGradient(
|
|
1116
|
+
starting: NSColor(calibratedWhite: 0.12, alpha: 0.90 * opacity),
|
|
1117
|
+
ending: NSColor(calibratedWhite: 0.06, alpha: 0.90 * opacity)
|
|
1118
|
+
)?.draw(in: path, angle: -90)
|
|
1119
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
1120
|
+
|
|
1121
|
+
path.lineWidth = 1
|
|
1122
|
+
tint.withAlphaComponent(0.34 * opacity).setStroke()
|
|
1123
|
+
path.stroke()
|
|
1124
|
+
|
|
1125
|
+
let lipRect = CGRect(x: rect.minX + 10, y: rect.maxY - 2, width: rect.width - 20, height: 1)
|
|
1126
|
+
NSColor.white.withAlphaComponent(0.10 * opacity).setFill()
|
|
1127
|
+
NSBezierPath(roundedRect: lipRect, xRadius: 0.5, yRadius: 0.5).fill()
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
private func color(for style: ScreenOverlayStyle) -> NSColor {
|
|
1131
|
+
switch style {
|
|
1132
|
+
case .info:
|
|
1133
|
+
return NSColor(calibratedRed: 0.36, green: 0.72, blue: 1.0, alpha: 1)
|
|
1134
|
+
case .success:
|
|
1135
|
+
return NSColor(calibratedRed: 0.38, green: 0.92, blue: 0.62, alpha: 1)
|
|
1136
|
+
case .warning:
|
|
1137
|
+
return NSColor(calibratedRed: 1.0, green: 0.66, blue: 0.24, alpha: 1)
|
|
1138
|
+
case .danger:
|
|
1139
|
+
return NSColor(calibratedRed: 1.0, green: 0.36, blue: 0.38, alpha: 1)
|
|
1140
|
+
case .playful:
|
|
1141
|
+
return NSColor(calibratedRed: 0.95, green: 0.66, blue: 1.0, alpha: 1)
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
private final class CodexPetAssetCache {
|
|
1147
|
+
static let shared = CodexPetAssetCache()
|
|
1148
|
+
|
|
1149
|
+
struct Frame {
|
|
1150
|
+
let image: NSImage
|
|
1151
|
+
let sourceRect: CGRect
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
private struct Metadata: Decodable {
|
|
1155
|
+
struct State: Decodable {
|
|
1156
|
+
let row: Int
|
|
1157
|
+
let frames: Int
|
|
1158
|
+
let frameWidth: CGFloat
|
|
1159
|
+
let frameHeight: CGFloat
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
let spritesheetPath: String?
|
|
1163
|
+
let states: [String: State]?
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private var cache: [String: (image: NSImage, metadata: Metadata?)] = [:]
|
|
1167
|
+
|
|
1168
|
+
private init() {}
|
|
1169
|
+
|
|
1170
|
+
func frame(for petID: String, state requestedState: String?) -> Frame? {
|
|
1171
|
+
guard let asset = load(petID: petID),
|
|
1172
|
+
let size = asset.image.representations.first.map({ CGSize(width: $0.pixelsWide, height: $0.pixelsHigh) }) else {
|
|
1173
|
+
return nil
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
let state = requestedState.flatMap { asset.metadata?.states?[$0] }
|
|
1177
|
+
?? asset.metadata?.states?["idle"]
|
|
1178
|
+
?? Metadata.State(row: 0, frames: 1, frameWidth: 192, frameHeight: 208)
|
|
1179
|
+
let frameWidth = max(1, state.frameWidth)
|
|
1180
|
+
let frameHeight = max(1, state.frameHeight)
|
|
1181
|
+
let frameCount = max(1, state.frames)
|
|
1182
|
+
let frameIndex = Int(Date().timeIntervalSinceReferenceDate * 8) % frameCount
|
|
1183
|
+
let row = max(0, state.row)
|
|
1184
|
+
let maxX = max(0, size.width - frameWidth)
|
|
1185
|
+
let y = max(0, size.height - CGFloat(row + 1) * frameHeight)
|
|
1186
|
+
return Frame(
|
|
1187
|
+
image: asset.image,
|
|
1188
|
+
sourceRect: CGRect(x: min(CGFloat(frameIndex) * frameWidth, maxX), y: y, width: frameWidth, height: frameHeight)
|
|
1189
|
+
)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
private func load(petID: String) -> (image: NSImage, metadata: Metadata?)? {
|
|
1193
|
+
if let cached = cache[petID] {
|
|
1194
|
+
return cached
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
guard petID.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil else {
|
|
1198
|
+
return nil
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
let root = bundledPetRoot(petID: petID) ?? codexPetRoot(petID: petID)
|
|
1202
|
+
let metadataURL = root.appendingPathComponent("pet.json")
|
|
1203
|
+
let metadata = try? JSONDecoder().decode(Metadata.self, from: Data(contentsOf: metadataURL))
|
|
1204
|
+
let spritesheetURL = root.appendingPathComponent(metadata?.spritesheetPath ?? "spritesheet.webp")
|
|
1205
|
+
guard let image = NSImage(contentsOf: spritesheetURL) else {
|
|
1206
|
+
return nil
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
let asset = (image, metadata)
|
|
1210
|
+
cache[petID] = asset
|
|
1211
|
+
return asset
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
private func bundledPetRoot(petID: String) -> URL? {
|
|
1215
|
+
let candidateRoots = [
|
|
1216
|
+
Bundle.main.resourceURL?.appendingPathComponent("Pets"),
|
|
1217
|
+
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Pets"),
|
|
1218
|
+
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
|
1219
|
+
.appendingPathComponent("apps/mac/Resources/Pets"),
|
|
1220
|
+
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
|
1221
|
+
.appendingPathComponent("Resources/Pets"),
|
|
1222
|
+
].compactMap { $0 }
|
|
1223
|
+
|
|
1224
|
+
for petsRoot in candidateRoots {
|
|
1225
|
+
let root = petsRoot.appendingPathComponent(petID)
|
|
1226
|
+
if FileManager.default.fileExists(atPath: root.appendingPathComponent("spritesheet.webp").path) {
|
|
1227
|
+
return root
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
return nil
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
private func codexPetRoot(petID: String) -> URL {
|
|
1235
|
+
URL(fileURLWithPath: NSHomeDirectory())
|
|
1236
|
+
.appendingPathComponent(".codex")
|
|
1237
|
+
.appendingPathComponent("pets")
|
|
1238
|
+
.appendingPathComponent(petID)
|
|
1239
|
+
}
|
|
1240
|
+
}
|