@lattices/cli 0.4.13 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +5 -7
  2. package/apps/mac/Info.plist +2 -2
  3. package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
  4. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/bin/lattices-app.ts +110 -17
  6. package/bin/lattices-build +125 -0
  7. package/bin/lattices-dev +89 -16
  8. package/bin/lattices.ts +977 -16
  9. package/docs/agents.md +81 -4
  10. package/docs/ai-chat-ux-review.md +416 -0
  11. package/docs/api.md +135 -3
  12. package/docs/app.md +30 -8
  13. package/docs/config.md +4 -0
  14. package/docs/mouse-gestures.md +191 -63
  15. package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
  16. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  17. package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
  18. package/docs/reference/dewey.config.ts +2 -2
  19. package/docs/release.md +171 -0
  20. package/docs/repo-structure.md +4 -5
  21. package/docs/voice.md +11 -27
  22. package/package.json +9 -10
  23. package/apps/mac/Package.swift +0 -27
  24. package/apps/mac/Sources/AppShell/App.swift +0 -26
  25. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
  26. package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
  27. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
  28. package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
  29. package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
  30. package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
  31. package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
  32. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
  33. package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
  34. package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
  35. package/apps/mac/Sources/AppShell/MainView.swift +0 -847
  36. package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
  37. package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
  38. package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
  39. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
  40. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
  41. package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
  42. package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
  43. package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
  44. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
  45. package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
  46. package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
  47. package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
  48. package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
  49. package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
  50. package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
  51. package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
  52. package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
  53. package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
  54. package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
  55. package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
  56. package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
  57. package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
  58. package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
  59. package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
  60. package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
  61. package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
  62. package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
  63. package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
  64. package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
  65. package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
  66. package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
  67. package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
  68. package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
  69. package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
  70. package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
  71. package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
  72. package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
  73. package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
  74. package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
  75. package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
  76. package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
  77. package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
  78. package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
  79. package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
  80. package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
  81. package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
  82. package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
  83. package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
  84. package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
  85. package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
  86. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
  87. package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
  88. package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
  89. package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
  90. package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
  91. package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
  92. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
  93. package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
  94. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
  95. package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
  96. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
  97. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
  98. package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
  99. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2271
  100. package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
  101. package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
  102. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
  103. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
  104. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
  105. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
  106. package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
  107. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
  108. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
  109. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
  110. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
  111. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
  112. package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
  113. package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
  114. package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
  115. package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
  116. package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
  117. package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
  118. package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
  119. package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
  120. package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
  121. package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
  122. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
  123. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
  124. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
  125. package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
  126. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
  127. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
  128. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
  129. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
  130. package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
  131. package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
  132. package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
  133. package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
  134. package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
  135. package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
  136. package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
  137. package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
  138. package/apps/mac/Sources/Core/System/Capability.swift +0 -79
  139. package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
  140. package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
  141. package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
  142. package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
  143. package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
  144. package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
  145. package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
  146. package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
  147. package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
  148. package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
  149. package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
  150. package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
  151. package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
  152. package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
  153. package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
  154. package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
  155. package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
  156. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
  157. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
  158. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
  159. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
  160. package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
  161. package/apps/mac/Sources/UI/ActionRow.swift +0 -78
  162. package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
  163. package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
  164. package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
  165. package/apps/mac/Sources/UI/Theme.swift +0 -164
  166. package/apps/mac/Tests/StageDragTests.swift +0 -333
  167. package/apps/mac/Tests/StageJoinTests.swift +0 -313
  168. package/apps/mac/Tests/StageManagerTests.swift +0 -280
  169. package/apps/mac/Tests/StageTileTests.swift +0 -353
  170. package/swift/Package.swift +0 -20
  171. package/swift/Sources/DeckKit/DeckAction.swift +0 -51
  172. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
  173. package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
  174. package/swift/Sources/DeckKit/DeckHost.swift +0 -7
  175. package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
  176. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
  177. package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
  178. package/swift/Sources/DeckKit/DeckValue.swift +0 -93
  179. package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
  180. package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
@@ -1,3977 +0,0 @@
1
- import SwiftUI
2
- import AppKit
3
-
4
- // MARK: - Screen Map View (Standalone)
5
-
6
- struct ScreenMapView: View {
7
- private static let canvasPadding: CGFloat = 8
8
- private static let canvasFitInsets = CGSize(width: 132, height: 112)
9
- private static let canvasViewportInsets = CGSize(width: 16, height: 16)
10
- private static let canvasPanMinVisiblePixels: CGFloat = 1
11
- private static let canvasFitScaleMultiplier: CGFloat = 0.66
12
- private static let canvasStageMaxWidth: CGFloat = 980
13
- private static let canvasStageMaxHeight: CGFloat = 560
14
- private static let canvasStageMinAspect: CGFloat = 1.25
15
- private static let canvasStageMaxAspect: CGFloat = 2.45
16
- private static let sidebarWindowRowHeight: CGFloat = 28
17
- private static let sidebarWindowRowStride: CGFloat = 30
18
-
19
- private struct CanvasMetrics: Equatable {
20
- let worldBounds: CGRect
21
- let fitScale: CGFloat
22
- let effectiveScale: CGFloat
23
- let mapSize: CGSize
24
- let centerOffset: CGPoint
25
- let syncedViewportSize: CGSize
26
-
27
- init(editor: ScreenMapEditorState?, displays: [DisplayGeometry], viewportSize: CGSize) {
28
- let fallbackBounds: CGRect = {
29
- guard let first = displays.first else {
30
- let size = NSScreen.main?.frame.size ?? CGSize(width: 1920, height: 1080)
31
- return CGRect(origin: .zero, size: size)
32
- }
33
- return displays.dropFirst().reduce(first.cgRect) { $0.union($1.cgRect) }
34
- }()
35
-
36
- worldBounds = editor?.canvasWorldBounds ?? fallbackBounds
37
-
38
- let fitArea = CGSize(
39
- width: max(viewportSize.width - ScreenMapView.canvasFitInsets.width, 1),
40
- height: max(viewportSize.height - ScreenMapView.canvasFitInsets.height, 1)
41
- )
42
- syncedViewportSize = CGSize(
43
- width: max(viewportSize.width - ScreenMapView.canvasViewportInsets.width, 1),
44
- height: max(viewportSize.height - ScreenMapView.canvasViewportInsets.height, 1)
45
- )
46
-
47
- fitScale = min(
48
- fitArea.width / max(worldBounds.width, 1),
49
- fitArea.height / max(worldBounds.height, 1)
50
- ) * ScreenMapView.canvasFitScaleMultiplier
51
- effectiveScale = fitScale * (editor?.zoomLevel ?? 1)
52
- mapSize = CGSize(width: worldBounds.width * effectiveScale, height: worldBounds.height * effectiveScale)
53
- centerOffset = CGPoint(
54
- x: (viewportSize.width - mapSize.width) / 2,
55
- y: (viewportSize.height - mapSize.height) / 2
56
- )
57
- }
58
-
59
- func mapRect(for worldRect: CGRect, minimumSize: CGFloat = 4) -> CGRect {
60
- CGRect(
61
- x: (worldRect.origin.x - worldBounds.origin.x) * effectiveScale,
62
- y: (worldRect.origin.y - worldBounds.origin.y) * effectiveScale,
63
- width: max(worldRect.width * effectiveScale, minimumSize),
64
- height: max(worldRect.height * effectiveScale, minimumSize)
65
- )
66
- }
67
- }
68
-
69
- private struct CanvasProjection {
70
- let scale: CGFloat
71
- let bboxOrigin: CGPoint
72
- let mapOrigin: CGPoint
73
- let panOffset: CGPoint
74
-
75
- init(editor: ScreenMapEditorState) {
76
- scale = max(editor.fitScale * editor.zoomLevel, editor.effectiveScale)
77
- bboxOrigin = editor.bboxOrigin
78
- mapOrigin = editor.mapOrigin
79
- panOffset = editor.panOffset
80
- }
81
-
82
- func mapRect(for worldRect: CGRect, minimumSize: CGFloat = 4) -> CGRect {
83
- CGRect(
84
- x: (worldRect.origin.x - bboxOrigin.x) * scale,
85
- y: (worldRect.origin.y - bboxOrigin.y) * scale,
86
- width: max(worldRect.width * scale, minimumSize),
87
- height: max(worldRect.height * scale, minimumSize)
88
- )
89
- }
90
-
91
- func mapPoint(forCanvasPoint canvasPoint: CGPoint) -> CGPoint {
92
- CGPoint(
93
- x: canvasPoint.x - ScreenMapView.canvasPadding - mapOrigin.x - panOffset.x,
94
- y: canvasPoint.y - ScreenMapView.canvasPadding - mapOrigin.y - panOffset.y
95
- )
96
- }
97
- }
98
-
99
- private struct CanvasHit {
100
- let id: UInt32
101
- let mapRect: CGRect
102
- let mapPoint: CGPoint
103
- }
104
-
105
- private struct CanvasSyncKey: Equatable {
106
- let viewportSize: CGSize
107
- let worldBounds: CGRect
108
- let zoomLevel: CGFloat
109
- let navigationRevision: Int
110
- }
111
-
112
- private struct MiniMapMetrics {
113
- let worldBounds: CGRect
114
- let scale: CGFloat
115
- let drawSize: CGSize
116
- let offset: CGPoint
117
-
118
- init(worldBounds: CGRect, canvasSize: CGSize) {
119
- self.worldBounds = worldBounds
120
- let scaleW = canvasSize.width / max(worldBounds.width, 1)
121
- let scaleH = canvasSize.height / max(worldBounds.height, 1)
122
- scale = min(scaleW, scaleH)
123
- drawSize = CGSize(width: worldBounds.width * scale, height: worldBounds.height * scale)
124
- offset = CGPoint(
125
- x: (canvasSize.width - drawSize.width) / 2,
126
- y: (canvasSize.height - drawSize.height) / 2
127
- )
128
- }
129
-
130
- func rect(for worldRect: CGRect, minimumSize: CGFloat) -> CGRect {
131
- CGRect(
132
- x: (worldRect.origin.x - worldBounds.origin.x) * scale + offset.x,
133
- y: (worldRect.origin.y - worldBounds.origin.y) * scale + offset.y,
134
- width: max(worldRect.width * scale, minimumSize),
135
- height: max(worldRect.height * scale, minimumSize)
136
- )
137
- }
138
-
139
- func worldPoint(for localPoint: CGPoint) -> CGPoint {
140
- let localX = min(max(localPoint.x - offset.x, 0), drawSize.width)
141
- let localY = min(max(localPoint.y - offset.y, 0), drawSize.height)
142
- return CGPoint(
143
- x: worldBounds.origin.x + localX / max(scale, 0.0001),
144
- y: worldBounds.origin.y + localY / max(scale, 0.0001)
145
- )
146
- }
147
- }
148
-
149
- @ObservedObject var controller: ScreenMapController
150
- var onNavigate: ((AppPage) -> Void)? = nil
151
- @ObservedObject private var daemon = DaemonServer.shared
152
- @ObservedObject private var handsOff = HandsOffSession.shared
153
- @ObservedObject private var diagnosticLog = DiagnosticLog.shared
154
- @StateObject private var piChat = PiChatSession.shared
155
- @State private var eventMonitor: Any?
156
- @State private var mouseDownMonitor: Any?
157
- @State private var mouseDragMonitor: Any?
158
- @State private var mouseUpMonitor: Any?
159
- @State private var rightClickMonitor: Any?
160
- @State private var scrollWheelMonitor: Any?
161
- @State private var screenMapCanvasOrigin: CGPoint = .zero
162
- @State private var screenMapCanvasSize: CGSize = .zero
163
- @State private var screenMapClickWindowId: UInt32? = nil
164
- @State private var screenMapClickPoint: NSPoint = .zero
165
- @State private var hoveredWindowId: UInt32?
166
- @State private var hoveredShelfAction: String?
167
- @State private var dropTargetLayer: Int?
168
- @State private var layerRowFrames: [Int: CGRect] = [:]
169
- @State private var sidebarDragWindowId: UInt32? = nil
170
- @State private var sidebarDragOffset: CGSize = .zero
171
- @State private var expandedLayers: Set<Int> = []
172
- @State private var showUnnamedLayers: Bool = false
173
- @State private var showSets: Bool = false
174
- @State private var showExplorer: Bool = false
175
- @State private var mouseMovedMonitor: Any?
176
- @State private var sidebarWidth: CGFloat = 180
177
- @State private var isDraggingSidebar: Bool = false
178
- @State private var inspectorWidth: CGFloat = 280
179
- @State private var isDraggingInspector: Bool = false
180
- @FocusState private var isSearchFieldFocused: Bool
181
- @State private var searchHoveredDisplayIndex: Int? = nil
182
- @State private var canvasTransitionOffset: CGFloat = 0
183
- @State private var canvasTransitionOpacity: Double = 1.0
184
- @State private var isSpaceHeld: Bool = false
185
- @State private var canvasPanStart: NSPoint? = nil
186
- @State private var canvasPanStartOffset: CGPoint = .zero
187
- @State private var searchOverlayFrame: CGRect = .zero
188
-
189
- var body: some View {
190
- VStack(spacing: 0) {
191
- HStack(spacing: 0) {
192
- if let editor = controller.editor {
193
- layerSidebar(editor: editor)
194
- panelResizeHandle(isActive: $isDraggingSidebar, width: $sidebarWidth,
195
- range: 140...320, edge: .trailing)
196
- }
197
- ZStack {
198
- VStack(spacing: 0) {
199
- canvasHeaderBezel
200
- screenMapCanvas(editor: controller.editor)
201
- .frame(maxWidth: .infinity, maxHeight: .infinity)
202
- }
203
- .offset(x: canvasTransitionOffset)
204
- .opacity(canvasTransitionOpacity)
205
- .onChange(of: controller.displayTransition) { direction in
206
- guard direction != .none else { return }
207
- let slideDistance: CGFloat = direction == .right ? -60 : 60
208
- // Start from opposite side
209
- canvasTransitionOffset = -slideDistance
210
- canvasTransitionOpacity = 0.3
211
- withAnimation(.easeOut(duration: 0.2)) {
212
- canvasTransitionOffset = 0
213
- canvasTransitionOpacity = 1.0
214
- }
215
- }
216
- if controller.isSearchActive, let editor = controller.editor {
217
- floatingSearchOverlay(editor: editor)
218
- }
219
- // Viewport controls removed — accessible via keyboard shortcuts
220
- }
221
- if let editor = controller.editor {
222
- panelResizeHandle(isActive: $isDraggingInspector, width: $inspectorWidth,
223
- range: 220...480, edge: .leading)
224
- inspectorPane(editor: editor)
225
- }
226
- }
227
- if piChat.isVisible {
228
- PiChatDock(session: piChat)
229
- .transition(.move(edge: .bottom).combined(with: .opacity))
230
- }
231
- footerBar
232
- }
233
- .background(Palette.bg)
234
- .overlay(flashOverlay)
235
- .onAppear {
236
- installKeyHandler()
237
- installMouseMonitors()
238
- }
239
- .onDisappear {
240
- removeKeyHandler()
241
- removeMouseMonitors()
242
- }
243
- .onChange(of: controller.editor?.isPreviewing) { isPreviewing in
244
- handlePreviewChange(isPreviewing: isPreviewing ?? false)
245
- }
246
- }
247
-
248
- // MARK: - Display Toolbar (floating in canvas)
249
-
250
- private func displayToolbar(editor: ScreenMapEditorState) -> some View {
251
- HStack(spacing: 4) {
252
- Button {
253
- controller.stepDisplayFocus(.previous)
254
- } label: {
255
- Image(systemName: "chevron.left")
256
- .font(.system(size: 8, weight: .semibold))
257
- .foregroundColor(Palette.textDim)
258
- .frame(width: 18, height: 18)
259
- .contentShape(Rectangle())
260
- }
261
- .buttonStyle(.plain)
262
-
263
- Button {
264
- controller.setDisplayFocus(nil)
265
- } label: {
266
- displayToolbarPill(name: "All", isActive: editor.focusedDisplayIndex == nil)
267
- }
268
- .buttonStyle(.plain)
269
-
270
- ForEach(Array(editor.spatialDisplayOrder.enumerated()), id: \.element.index) { spatialPos, disp in
271
- let isActive = editor.focusedDisplayIndex == disp.index
272
- Button {
273
- controller.setDisplayFocus(disp.index)
274
- } label: {
275
- displayToolbarPill(
276
- badge: spatialPos + 1,
277
- name: disp.label,
278
- isActive: isActive
279
- )
280
- }
281
- .buttonStyle(.plain)
282
- }
283
-
284
- Button {
285
- controller.stepDisplayFocus(.next)
286
- } label: {
287
- Image(systemName: "chevron.right")
288
- .font(.system(size: 8, weight: .semibold))
289
- .foregroundColor(Palette.textDim)
290
- .frame(width: 18, height: 18)
291
- .contentShape(Rectangle())
292
- }
293
- .buttonStyle(.plain)
294
- }
295
- .padding(.horizontal, 6)
296
- .padding(.vertical, 4)
297
- .background(
298
- RoundedRectangle(cornerRadius: 8)
299
- .fill(Color.black.opacity(0.65))
300
- .overlay(
301
- RoundedRectangle(cornerRadius: 8)
302
- .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
303
- )
304
- )
305
- }
306
-
307
- private func displayToolbarPill(badge: Int? = nil, name: String, isActive: Bool) -> some View {
308
- HStack(spacing: 4) {
309
- if let badge = badge {
310
- ZStack {
311
- Circle()
312
- .fill(isActive ? Palette.running.opacity(0.5) : Color.white.opacity(0.25))
313
- .frame(width: 14, height: 14)
314
- Text("\(badge)")
315
- .font(.system(size: 7, weight: .bold, design: .monospaced))
316
- .foregroundColor(isActive ? .white : .black)
317
- }
318
- }
319
- Text(name)
320
- .font(Typo.monoBold(8))
321
- .foregroundColor(isActive ? Palette.text : Palette.textDim)
322
- .lineLimit(1)
323
- }
324
- .padding(.horizontal, 6)
325
- .padding(.vertical, 3)
326
- .background(
327
- RoundedRectangle(cornerRadius: 5)
328
- .fill(isActive ? Palette.running.opacity(0.15) : Color.white.opacity(0.06))
329
- )
330
- .overlay(
331
- RoundedRectangle(cornerRadius: 5)
332
- .strokeBorder(isActive ? Palette.running.opacity(0.4) : Color.clear, lineWidth: 0.5)
333
- )
334
- }
335
-
336
- // MARK: - Canvas Header Bezel
337
-
338
- private var canvasHeaderBezel: some View {
339
- HStack(spacing: 6) {
340
- if let editor = controller.editor {
341
- if let focused = editor.focusedDisplay {
342
- Circle().fill(Palette.running.opacity(0.4)).frame(width: 6, height: 6)
343
- Text(focused.label).font(Typo.monoBold(9)).foregroundColor(Palette.textDim).lineLimit(1)
344
- Text("\(Int(focused.cgRect.width))×\(Int(focused.cgRect.height))").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
345
- } else {
346
- Text("All Displays").font(Typo.monoBold(9)).foregroundColor(Palette.textDim)
347
- Text("\(editor.displays.count) monitors").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
348
- }
349
- Spacer()
350
- Text("\(editor.focusedVisibleWindows.count) windows").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
351
- } else { Text("Canvas"); Spacer() }
352
- }
353
- .padding(.horizontal, 10).padding(.vertical, 5)
354
- .background(Color(red: 0.08, green: 0.08, blue: 0.09))
355
- .overlay(alignment: .bottom) { Rectangle().fill(Palette.border).frame(height: 0.5) }
356
- }
357
-
358
- // MARK: - Panel Resize Handle
359
-
360
- enum PanelEdge { case trailing, leading }
361
-
362
- private func panelResizeHandle(isActive: Binding<Bool>, width: Binding<CGFloat>,
363
- range: ClosedRange<CGFloat>, edge: PanelEdge) -> some View {
364
- Rectangle()
365
- .fill(isActive.wrappedValue ? Palette.running.opacity(0.3) : Palette.border)
366
- .frame(width: isActive.wrappedValue ? 2 : 0.5)
367
- .contentShape(Rectangle().inset(by: -3))
368
- .onHover { h in if h { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } }
369
- .gesture(
370
- DragGesture(minimumDistance: 1)
371
- .onChanged { value in
372
- isActive.wrappedValue = true
373
- let delta = edge == .trailing ? value.translation.width : -value.translation.width
374
- let newWidth = width.wrappedValue + delta
375
- width.wrappedValue = max(range.lowerBound, min(range.upperBound, newWidth))
376
- }
377
- .onEnded { _ in isActive.wrappedValue = false }
378
- )
379
- }
380
-
381
- // MARK: - Inspector Pane
382
-
383
- private func inspectorPane(editor: ScreenMapEditorState) -> some View {
384
- let selectedWindows = editor.windows.filter { controller.selectedWindowIds.contains($0.id) }
385
-
386
- return VStack(spacing: 0) {
387
- ScrollView(.vertical, showsIndicators: false) {
388
- VStack(alignment: .leading, spacing: 12) {
389
- Text("INSPECTOR")
390
- .font(Typo.monoBold(9))
391
- .foregroundColor(Palette.textMuted)
392
-
393
- inspectorCanvasContextCard(editor: editor, selectedCount: selectedWindows.count)
394
-
395
- if selectedWindows.isEmpty {
396
- VStack(spacing: 8) {
397
- Text("No Selection")
398
- .font(Typo.monoBold(10))
399
- .foregroundColor(Palette.textDim)
400
- Text("Click a window on the canvas to inspect.")
401
- .font(Typo.mono(9))
402
- .foregroundColor(Palette.textMuted)
403
- .multilineTextAlignment(.center)
404
- .lineLimit(3)
405
- }
406
- .frame(maxWidth: .infinity)
407
- .padding(.top, 20)
408
- }
409
-
410
- ForEach(selectedWindows) { win in
411
- inspectorWindowCard(win: win, editor: editor)
412
- }
413
- }
414
- .padding(8)
415
- }
416
-
417
- // Pinned action tray at bottom
418
- inspectorActionTray(editor: editor)
419
- }
420
- .frame(width: inspectorWidth)
421
- }
422
-
423
- private func inspectorCanvasContextCard(editor: ScreenMapEditorState, selectedCount: Int) -> some View {
424
- let viewport = editor.viewportWorldRect
425
- let world = editor.canvasWorldBounds
426
- let scope = editor.focusedDisplay.map { "\(editor.spatialNumber(for: $0.index)). \($0.label)" } ?? "All Displays"
427
-
428
- return VStack(alignment: .leading, spacing: 4) {
429
- inspectorRow(label: "Scope", value: scope)
430
- inspectorRow(label: "Mode", value: "Desktop")
431
- inspectorRow(label: "View", value: "\(Int(viewport.midX)), \(Int(viewport.midY)) · \(Int(viewport.width))×\(Int(viewport.height))")
432
- inspectorRow(label: "World", value: "\(Int(world.width))×\(Int(world.height))")
433
- inspectorRow(label: "Set", value: controller.activeWindowSet?.name ?? "None")
434
- inspectorRow(label: "Select", value: "\(selectedCount) window\(selectedCount == 1 ? "" : "s")")
435
- }
436
- .padding(8)
437
- .background(
438
- RoundedRectangle(cornerRadius: 6)
439
- .fill(Color.black.opacity(0.25))
440
- .overlay(
441
- RoundedRectangle(cornerRadius: 6)
442
- .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
443
- )
444
- )
445
- }
446
-
447
- // MARK: - Inspector Window Card
448
-
449
- private func inspectorWindowCard(win: ScreenMapWindowEntry, editor: ScreenMapEditorState) -> some View {
450
- let desktopEntry = DesktopModel.shared.windows[UInt32(win.id)]
451
- let ocrText = OcrModel.shared.results[UInt32(win.id)]?.fullText
452
-
453
- return VStack(alignment: .leading, spacing: 8) {
454
- // Header: app + visibility
455
- HStack(spacing: 5) {
456
- Circle()
457
- .fill(Palette.running.opacity(0.8))
458
- .frame(width: 6, height: 6)
459
- Text(win.app)
460
- .font(Typo.monoBold(11))
461
- .foregroundColor(Palette.text)
462
- .lineLimit(1)
463
- Spacer()
464
- if desktopEntry?.isOnScreen == true {
465
- Text("visible")
466
- .font(Typo.monoBold(7))
467
- .foregroundColor(Palette.running)
468
- .padding(.horizontal, 4)
469
- .padding(.vertical, 1)
470
- .background(
471
- RoundedRectangle(cornerRadius: 2)
472
- .fill(Palette.running.opacity(0.1))
473
- )
474
- }
475
- }
476
-
477
- // Title
478
- if !win.title.isEmpty {
479
- Text(win.title)
480
- .font(Typo.mono(10))
481
- .foregroundColor(Palette.textDim)
482
- .lineLimit(3)
483
- .textSelection(.enabled)
484
- }
485
-
486
- // Identity
487
- HStack(spacing: 10) {
488
- inspectorLabel(label: "wid", value: "\(win.id)")
489
- if let entry = desktopEntry {
490
- inspectorLabel(label: "pid", value: "\(entry.pid)")
491
- }
492
- }
493
-
494
- // Layout info
495
- VStack(alignment: .leading, spacing: 3) {
496
- inspectorRow(label: "Display", value: {
497
- if let disp = editor.displays.first(where: { $0.index == win.displayIndex }) {
498
- return "\(editor.spatialNumber(for: disp.index)). \(disp.label)"
499
- }
500
- return "Display \(win.displayIndex)"
501
- }())
502
- inspectorRow(label: "Size",
503
- value: "\(Int(win.virtualFrame.width))×\(Int(win.virtualFrame.height))")
504
- inspectorRow(label: "Position",
505
- value: "(\(Int(win.virtualFrame.origin.x)), \(Int(win.virtualFrame.origin.y)))")
506
- inspectorRow(label: "Z-Index", value: "\(win.zIndex)")
507
- if win.hasEdits {
508
- inspectorRow(label: "Original",
509
- value: "\(Int(win.originalFrame.width))×\(Int(win.originalFrame.height))")
510
- }
511
- if let entry = desktopEntry, !entry.spaceIds.isEmpty {
512
- inspectorRow(label: "Spaces", value: entry.spaceIds.map(String.init).joined(separator: ", "))
513
- }
514
- }
515
-
516
- // Session
517
- if let session = desktopEntry?.latticesSession {
518
- HStack(spacing: 4) {
519
- Text("session")
520
- .font(Typo.monoBold(8))
521
- .foregroundColor(Palette.textMuted)
522
- Text(session)
523
- .font(Typo.mono(9))
524
- .foregroundColor(Palette.running)
525
- .lineLimit(1)
526
- }
527
- }
528
-
529
- if win.hasEdits {
530
- HStack(spacing: 4) {
531
- Circle()
532
- .fill(Color.orange)
533
- .frame(width: 5, height: 5)
534
- Text("Modified")
535
- .font(Typo.monoBold(8))
536
- .foregroundColor(Color.orange)
537
- }
538
- }
539
-
540
- // OCR snippet
541
- if let ocr = ocrText, !ocr.isEmpty {
542
- VStack(alignment: .leading, spacing: 2) {
543
- Text("SCREEN TEXT")
544
- .font(Typo.monoBold(8))
545
- .foregroundColor(Palette.textMuted)
546
- Text(String(ocr.prefix(400)))
547
- .font(Typo.mono(8))
548
- .foregroundColor(Palette.textMuted)
549
- .lineLimit(8)
550
- .textSelection(.enabled)
551
- }
552
- .padding(6)
553
- .background(
554
- RoundedRectangle(cornerRadius: 4)
555
- .fill(Palette.bg.opacity(0.5))
556
- )
557
- }
558
-
559
- // Window actions — contextual to this card
560
- if let entry = desktopEntry {
561
- windowCardActions(wid: UInt32(win.id), entry: entry)
562
- }
563
- }
564
- .padding(10)
565
- .background(
566
- RoundedRectangle(cornerRadius: 6)
567
- .fill(Palette.surface)
568
- .overlay(
569
- RoundedRectangle(cornerRadius: 6)
570
- .strokeBorder(Palette.border, lineWidth: 0.5)
571
- )
572
- )
573
- }
574
-
575
- private func windowCardActions(wid: UInt32, entry: WindowEntry) -> some View {
576
- let actions: [(key: String, label: String, action: () -> Void)] = [
577
- ("f", "focus", { [controller] in
578
- controller.focusWindowOnScreen(wid)
579
- }),
580
- ("h", "highlight", {
581
- WindowTiler.highlightWindowById(wid: wid)
582
- }),
583
- ("←", "tile left", {
584
- WindowTiler.focusWindow(wid: wid, pid: entry.pid)
585
- WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: .left)
586
- }),
587
- ("→", "tile right", {
588
- WindowTiler.focusWindow(wid: wid, pid: entry.pid)
589
- WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: .right)
590
- }),
591
- ("m", "maximize", {
592
- WindowTiler.focusWindow(wid: wid, pid: entry.pid)
593
- WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: .maximize)
594
- }),
595
- ("r", "rescan", {
596
- OcrModel.shared.scanSingle(wid: wid)
597
- }),
598
- ("c", "copy info", { [controller] in
599
- let info = [
600
- "wid: \(wid)",
601
- "app: \(entry.app)",
602
- "title: \(entry.title)",
603
- "pid: \(entry.pid)",
604
- "frame: \(Int(entry.frame.x)),\(Int(entry.frame.y)) \(Int(entry.frame.w))×\(Int(entry.frame.h))",
605
- entry.latticesSession.map { "session: \($0)" },
606
- ].compactMap { $0 }.joined(separator: "\n")
607
- NSPasteboard.general.clearContents()
608
- NSPasteboard.general.setString(info, forType: .string)
609
- controller.flash("Copied")
610
- }),
611
- ]
612
-
613
- let columns = [GridItem(.flexible()), GridItem(.flexible())]
614
-
615
- return VStack(spacing: 0) {
616
- Rectangle().fill(Palette.border).frame(height: 0.5)
617
- .padding(.horizontal, -10)
618
- .padding(.top, 4)
619
-
620
- LazyVGrid(columns: columns, spacing: 3) {
621
- ForEach(Array(actions.enumerated()), id: \.offset) { _, item in
622
- let isHov = hoveredShelfAction == "w_\(wid)_\(item.label)"
623
- Button(action: item.action) {
624
- HStack(spacing: 4) {
625
- Text(item.key)
626
- .font(.system(size: 8))
627
- .foregroundColor(Self.shelfGreen)
628
- .frame(width: 14)
629
- Text(item.label)
630
- .font(Typo.mono(8))
631
- .foregroundColor(isHov ? Palette.text : Palette.textDim)
632
- .lineLimit(1)
633
- Spacer()
634
- }
635
- .padding(.horizontal, 6)
636
- .padding(.vertical, 4)
637
- .background(
638
- RoundedRectangle(cornerRadius: 4)
639
- .fill(isHov ? Palette.surfaceHov : Palette.surface)
640
- .overlay(
641
- RoundedRectangle(cornerRadius: 4)
642
- .strokeBorder(isHov ? Palette.borderLit : Palette.border, lineWidth: 0.5)
643
- )
644
- )
645
- .contentShape(Rectangle())
646
- }
647
- .buttonStyle(.plain)
648
- .onHover { h in
649
- let key = "w_\(wid)_\(item.label)"
650
- hoveredShelfAction = h ? key : (hoveredShelfAction == key ? nil : hoveredShelfAction)
651
- }
652
- }
653
- }
654
- .padding(.top, 6)
655
- }
656
- }
657
-
658
- private func inspectorLabel(label: String, value: String) -> some View {
659
- HStack(spacing: 3) {
660
- Text(label)
661
- .font(Typo.monoBold(8))
662
- .foregroundColor(Palette.textMuted)
663
- Text(value)
664
- .font(Typo.mono(9))
665
- .foregroundColor(Palette.textDim)
666
- }
667
- }
668
-
669
- // MARK: - Floating Search Overlay
670
-
671
- private func floatingSearchOverlay(editor: ScreenMapEditorState) -> some View {
672
- let results = editor.searchFilteredWindows
673
- let groups = editor.searchResultsByDisplay
674
- let highlightIdx = max(0, min(controller.searchHighlightIndex, results.count - 1))
675
- let terms = editor.searchTerms
676
-
677
- return VStack(spacing: 0) {
678
- Spacer().frame(height: 60)
679
-
680
- VStack(spacing: 0) {
681
- // Search field
682
- HStack(spacing: 10) {
683
- Image(systemName: "magnifyingglass")
684
- .font(.system(size: 14, weight: .medium))
685
- .foregroundColor(Self.shelfGreen)
686
- TextField("Search windows…", text: Binding(
687
- get: { editor.windowSearchQuery },
688
- set: { newValue in
689
- editor.windowSearchQuery = newValue
690
- controller.searchHighlightIndex = 0
691
- }
692
- ))
693
- .textFieldStyle(.plain)
694
- .font(Typo.mono(14))
695
- .foregroundColor(Palette.text)
696
- .focused($isSearchFieldFocused)
697
- if !editor.windowSearchQuery.isEmpty {
698
- Text("\(results.count)")
699
- .font(Typo.monoBold(10))
700
- .foregroundColor(Palette.textMuted)
701
- Button {
702
- editor.windowSearchQuery = ""
703
- } label: {
704
- Image(systemName: "xmark.circle.fill")
705
- .font(.system(size: 12))
706
- .foregroundColor(Palette.textMuted)
707
- }
708
- .buttonStyle(.plain)
709
- }
710
- }
711
- .padding(.horizontal, 14)
712
- .padding(.vertical, 10)
713
-
714
- // Results: side-by-side columns per display
715
- if !groups.isEmpty {
716
- Rectangle().fill(Palette.border).frame(height: 0.5)
717
- HStack(alignment: .top, spacing: 0) {
718
- ForEach(groups.indices, id: \.self) { groupIdx in
719
- let group = groups[groupIdx]
720
- if groupIdx > 0 {
721
- Rectangle().fill(Palette.border).frame(width: 0.5)
722
- }
723
- VStack(spacing: 0) {
724
- // Display header with hover → mini-map highlight
725
- searchDisplayHeader(
726
- spatialNumber: group.spatialNumber,
727
- label: group.label,
728
- matchCount: group.windows.count,
729
- isHovered: searchHoveredDisplayIndex == group.displayIndex
730
- )
731
- .onHover { hovering in
732
- searchHoveredDisplayIndex = hovering ? group.displayIndex : nil
733
- }
734
-
735
- // Window list for this display
736
- ScrollView(.vertical, showsIndicators: false) {
737
- VStack(spacing: 2) {
738
- ForEach(Array(group.windows.enumerated()), id: \.element.id) { _, win in
739
- let flatIdx = flatIndex(for: win, in: groups)
740
- let isHighlighted = flatIdx == highlightIdx
741
- searchResultRow(win: win, editor: editor, terms: terms, isHighlighted: isHighlighted)
742
- .onTapGesture {
743
- controller.selectSingle(win.id)
744
- if editor.searchHasDirectHit {
745
- controller.closeSearch()
746
- }
747
- }
748
- }
749
- }
750
- .padding(4)
751
- }
752
- }
753
- .frame(maxWidth: .infinity)
754
- }
755
- }
756
- .frame(maxHeight: 280)
757
- } else if !editor.windowSearchQuery.isEmpty {
758
- Rectangle().fill(Palette.border).frame(height: 0.5)
759
- Text("No matches")
760
- .font(Typo.mono(11))
761
- .foregroundColor(Palette.textMuted)
762
- .padding(.vertical, 12)
763
- }
764
-
765
- // Keyboard hints
766
- Rectangle().fill(Palette.border).frame(height: 0.5)
767
- HStack(spacing: 8) {
768
- searchHint("↑↓", label: "nav")
769
- searchHint("↩", label: "select")
770
- searchHint("⌘↩", label: "show")
771
- searchHint("esc", label: "close")
772
- if terms.count > 1 {
773
- Spacer()
774
- Text("\(terms.count) terms")
775
- .font(Typo.mono(7))
776
- .foregroundColor(Palette.textMuted)
777
- }
778
- }
779
- .padding(.horizontal, 10)
780
- .padding(.vertical, 5)
781
- }
782
- .background(
783
- RoundedRectangle(cornerRadius: 10)
784
- .fill(Color(red: 0.1, green: 0.1, blue: 0.11))
785
- .overlay(
786
- RoundedRectangle(cornerRadius: 10)
787
- .strokeBorder(Self.shelfGreen.opacity(0.3), lineWidth: 1)
788
- )
789
- .shadow(color: Self.shelfGreen.opacity(0.15), radius: 20)
790
- .shadow(color: Color.black.opacity(0.5), radius: 30)
791
- )
792
- .clipShape(RoundedRectangle(cornerRadius: 10))
793
- .frame(width: groups.count > 1 ? 600 : 500)
794
- .background(
795
- GeometryReader { geo in
796
- Color.clear.preference(key: SearchOverlayFrameKey.self,
797
- value: geo.frame(in: .global))
798
- }
799
- )
800
- .onPreferenceChange(SearchOverlayFrameKey.self) { frame in
801
- searchOverlayFrame = frame
802
- }
803
- .onAppear {
804
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
805
- isSearchFieldFocused = true
806
- }
807
- }
808
-
809
- Spacer()
810
- }
811
- .frame(maxWidth: .infinity, maxHeight: .infinity)
812
- .background(Color.black.opacity(0.3))
813
- .contentShape(Rectangle())
814
- .onTapGesture {
815
- controller.closeSearch()
816
- }
817
- }
818
-
819
- /// Compute flat index of a window within the grouped results (for highlight tracking)
820
- private func flatIndex(
821
- for win: ScreenMapWindowEntry,
822
- in groups: [(displayIndex: Int, spatialNumber: Int, label: String, windows: [ScreenMapWindowEntry])]
823
- ) -> Int {
824
- var idx = 0
825
- for group in groups {
826
- for w in group.windows {
827
- if w.id == win.id { return idx }
828
- idx += 1
829
- }
830
- }
831
- return 0
832
- }
833
-
834
- /// Display section header within search results
835
- private func searchDisplayHeader(spatialNumber: Int, label: String, matchCount: Int, isHovered: Bool = false) -> some View {
836
- HStack(spacing: 6) {
837
- Text("\(spatialNumber)")
838
- .font(Typo.monoBold(8))
839
- .foregroundColor(isHovered ? Palette.bg : Palette.bg)
840
- .frame(width: 14, height: 14)
841
- .background(
842
- RoundedRectangle(cornerRadius: 3)
843
- .fill(isHovered ? Self.shelfGreen : Palette.textMuted)
844
- )
845
- Text(label)
846
- .font(Typo.mono(9))
847
- .foregroundColor(isHovered ? Palette.text : Palette.textMuted)
848
- .lineLimit(1)
849
- Spacer()
850
- Text("\(matchCount)")
851
- .font(Typo.monoBold(8))
852
- .foregroundColor(isHovered ? Self.shelfGreen : Palette.textMuted)
853
- }
854
- .padding(.horizontal, 8)
855
- .padding(.top, 6)
856
- .padding(.bottom, 4)
857
- .background(isHovered ? Self.shelfGreen.opacity(0.06) : Color.clear)
858
- .contentShape(Rectangle())
859
- .animation(.easeInOut(duration: 0.15), value: isHovered)
860
- }
861
-
862
- private func searchResultRow(win: ScreenMapWindowEntry, editor: ScreenMapEditorState, terms: [String], isHighlighted: Bool) -> some View {
863
- HStack(spacing: 6) {
864
- Circle()
865
- .fill(Self.layerColor(for: win.layer))
866
- .frame(width: 5, height: 5)
867
- VStack(alignment: .leading, spacing: 1) {
868
- highlightedText(win.app, terms: terms, baseFont: Typo.monoBold(9),
869
- baseColor: isHighlighted ? Palette.text : Palette.textDim)
870
- .lineLimit(1)
871
- if !win.title.isEmpty {
872
- highlightedText(win.title, terms: terms, baseFont: Typo.mono(8),
873
- baseColor: Palette.textMuted)
874
- .lineLimit(1)
875
- }
876
- }
877
- Spacer()
878
- if isHighlighted {
879
- Button {
880
- controller.focusWindowOnScreen(win.id)
881
- } label: {
882
- Image(systemName: "macwindow.and.cursorarrow")
883
- .font(.system(size: 8))
884
- .foregroundColor(Self.shelfGreen)
885
- .padding(3)
886
- .background(
887
- RoundedRectangle(cornerRadius: 3)
888
- .fill(Self.shelfGreen.opacity(0.1))
889
- )
890
- }
891
- .buttonStyle(.plain)
892
- .help("Show on screen (⌘↩)")
893
- }
894
- Text(editor.layerDisplayName(for: win.layer))
895
- .font(Typo.mono(7))
896
- .foregroundColor(Palette.textMuted)
897
- .padding(.horizontal, 4)
898
- .padding(.vertical, 1)
899
- .background(
900
- RoundedRectangle(cornerRadius: 3)
901
- .fill(Self.layerColor(for: win.layer).opacity(0.15))
902
- )
903
- }
904
- .padding(.horizontal, 6)
905
- .padding(.vertical, 4)
906
- .background(
907
- RoundedRectangle(cornerRadius: 4)
908
- .fill(isHighlighted ? Self.shelfGreen.opacity(0.12) : Color.clear)
909
- .overlay(
910
- RoundedRectangle(cornerRadius: 4)
911
- .strokeBorder(isHighlighted ? Self.shelfGreen.opacity(0.3) : Color.clear, lineWidth: 0.5)
912
- )
913
- )
914
- .contentShape(Rectangle())
915
- .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
916
- }
917
-
918
- /// Highlight matching search terms within text
919
- private func highlightedText(_ text: String, terms: [String], baseFont: Font, baseColor: Color) -> Text {
920
- guard !terms.isEmpty else {
921
- return Text(text).font(baseFont).foregroundColor(baseColor)
922
- }
923
- let lower = text.lowercased()
924
- // Build set of character offsets that match any term
925
- var matchSet = IndexSet()
926
- for term in terms {
927
- var searchStart = lower.startIndex
928
- while searchStart < lower.endIndex,
929
- let range = lower.range(of: term, range: searchStart..<lower.endIndex) {
930
- let startOffset = lower.distance(from: lower.startIndex, to: range.lowerBound)
931
- let length = lower.distance(from: range.lowerBound, to: range.upperBound)
932
- matchSet.insert(integersIn: startOffset..<(startOffset + length))
933
- searchStart = range.upperBound
934
- }
935
- }
936
- // Convert to segments
937
- var result = Text("")
938
- var i = 0
939
- let chars = Array(text)
940
- while i < chars.count {
941
- let isMatch = matchSet.contains(i)
942
- var j = i + 1
943
- while j < chars.count && matchSet.contains(j) == isMatch { j += 1 }
944
- let segment = String(chars[i..<j])
945
- if isMatch {
946
- result = result + Text(segment).font(baseFont).foregroundColor(Self.shelfGreen)
947
- } else {
948
- result = result + Text(segment).font(baseFont).foregroundColor(baseColor)
949
- }
950
- i = j
951
- }
952
- return result
953
- }
954
-
955
- private func footerHint(_ key: String, label: String) -> some View {
956
- HStack(spacing: 2) {
957
- Text(key)
958
- .font(Typo.monoBold(8))
959
- .foregroundColor(Palette.textDim)
960
- .padding(.horizontal, 3)
961
- .padding(.vertical, 1)
962
- .background(
963
- RoundedRectangle(cornerRadius: 2)
964
- .strokeBorder(Palette.border, lineWidth: 0.5)
965
- )
966
- Text(label)
967
- .font(Typo.mono(8))
968
- .foregroundColor(Palette.textMuted)
969
- }
970
- }
971
-
972
- private func searchHint(_ key: String, label: String) -> some View {
973
- HStack(spacing: 3) {
974
- Text(key)
975
- .font(Typo.monoBold(7))
976
- .foregroundColor(Palette.textDim)
977
- .padding(.horizontal, 3)
978
- .padding(.vertical, 1)
979
- .background(
980
- RoundedRectangle(cornerRadius: 2)
981
- .strokeBorder(Palette.border, lineWidth: 0.5)
982
- )
983
- Text(label)
984
- .font(Typo.mono(7))
985
- .foregroundColor(Palette.textMuted)
986
- }
987
- }
988
-
989
- // MARK: - Inspector Bottom Rail
990
-
991
- private func inspectorActionTray(editor: ScreenMapEditorState) -> some View {
992
- let actions: [(key: String, label: String, action: () -> Void)] = [
993
- ("s", "spread", { [controller] in controller.smartSpreadLayer() }),
994
- ("e", "expose", { [controller] in controller.exposeLayer() }),
995
- ("t", "tile", { [controller] in controller.tileLayer() }),
996
- ("d", "distrib", { [controller] in controller.distributeVisible() }),
997
- ("g", "grow", { [controller] in controller.fitAvailableSpace() }),
998
- ("u", "set", { [controller] in controller.createWindowSetFromSelection() }),
999
- ("m", "project", { [controller] in controller.materializeViewport() }),
1000
- ("v", "preview", { [controller] in controller.previewLayer() }),
1001
- ]
1002
-
1003
- let columns = [GridItem(.flexible()), GridItem(.flexible())]
1004
- let editCount = editor.pendingEditCount
1005
- let isZoomed = editor.zoomLevel != 1.0 || editor.panOffset != .zero
1006
-
1007
- return VStack(spacing: 0) {
1008
- // Contextual commands area (fixed slot, always reserved)
1009
- Rectangle().fill(Palette.border).frame(height: 0.5)
1010
- VStack(spacing: 0) {
1011
- if editor.isTilingMode {
1012
- VStack(spacing: 4) {
1013
- HStack(spacing: 4) {
1014
- Text("TILE")
1015
- .font(Typo.monoBold(9))
1016
- .foregroundColor(.white)
1017
- .padding(.horizontal, 5)
1018
- .padding(.vertical, 2)
1019
- .background(RoundedRectangle(cornerRadius: 3).fill(Self.shelfGreen))
1020
- Spacer()
1021
- Text("esc cancel")
1022
- .font(Typo.mono(7))
1023
- .foregroundColor(Palette.textMuted)
1024
- }
1025
- HStack(spacing: 3) {
1026
- ForEach(["←", "→", "↑", "↓"], id: \.self) { key in
1027
- Text(key)
1028
- .font(Typo.monoBold(8))
1029
- .foregroundColor(Palette.textDim)
1030
- .padding(.horizontal, 3)
1031
- .padding(.vertical, 1)
1032
- .background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
1033
- .overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
1034
- }
1035
- Text("1-7")
1036
- .font(Typo.monoBold(8))
1037
- .foregroundColor(Palette.textDim)
1038
- .padding(.horizontal, 3)
1039
- .padding(.vertical, 1)
1040
- .background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
1041
- .overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
1042
- Text("c")
1043
- .font(Typo.monoBold(8))
1044
- .foregroundColor(Palette.textDim)
1045
- .padding(.horizontal, 3)
1046
- .padding(.vertical, 1)
1047
- .background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
1048
- .overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
1049
- Spacer()
1050
- }
1051
- }
1052
- .padding(.horizontal, 8)
1053
- .padding(.vertical, 5)
1054
- }
1055
- if editCount > 0 {
1056
- Button {
1057
- controller.applyEditsFromButton()
1058
- } label: {
1059
- HStack(spacing: 6) {
1060
- Text("↩")
1061
- .font(Typo.monoBold(10))
1062
- .foregroundColor(Self.shelfGreen)
1063
- Text("Apply \(editCount) \(editCount == 1 ? "edit" : "edits")")
1064
- .font(Typo.monoBold(9))
1065
- .foregroundColor(Self.shelfGreen)
1066
- Spacer()
1067
- }
1068
- .padding(.horizontal, 8)
1069
- .padding(.vertical, 5)
1070
- .contentShape(Rectangle())
1071
- }
1072
- .buttonStyle(.plain)
1073
- .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
1074
- }
1075
- if isZoomed {
1076
- Button {
1077
- controller.focusViewportPreset(.overview)
1078
- } label: {
1079
- HStack(spacing: 4) {
1080
- Text("r")
1081
- .font(Typo.monoBold(8))
1082
- .foregroundColor(Self.shelfGreen)
1083
- .padding(.horizontal, 4)
1084
- .padding(.vertical, 1)
1085
- .background(RoundedRectangle(cornerRadius: 2).fill(Self.shelfGreen.opacity(0.15)))
1086
- Text("fit all")
1087
- .font(Typo.mono(8))
1088
- .foregroundColor(Palette.textDim)
1089
- Spacer()
1090
- Text("\(Int(editor.zoomLevel * 100))%")
1091
- .font(Typo.mono(8))
1092
- .foregroundColor(Palette.textMuted)
1093
- }
1094
- .padding(.horizontal, 8)
1095
- .padding(.vertical, 4)
1096
- .contentShape(Rectangle())
1097
- }
1098
- .buttonStyle(.plain)
1099
- .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
1100
- }
1101
- if let ref = editor.lastActionRef {
1102
- Button {
1103
- if let json = editor.actionLog.lastEntryJSON() {
1104
- NSPasteboard.general.clearContents()
1105
- NSPasteboard.general.setString(json, forType: .string)
1106
- controller.flash("Copied \(ref)")
1107
- }
1108
- } label: {
1109
- HStack(spacing: 4) {
1110
- Text(ref)
1111
- .font(Typo.monoBold(8))
1112
- .foregroundColor(Self.shelfGreen.opacity(0.6))
1113
- Spacer()
1114
- Text("copy")
1115
- .font(Typo.mono(7))
1116
- .foregroundColor(Palette.textMuted)
1117
- }
1118
- .padding(.horizontal, 8)
1119
- .padding(.vertical, 4)
1120
- .contentShape(Rectangle())
1121
- }
1122
- .buttonStyle(.plain)
1123
- .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
1124
- }
1125
- }
1126
- .frame(maxWidth: .infinity)
1127
- .background(Color(red: 0.05, green: 0.05, blue: 0.06))
1128
-
1129
- // Actions grid (always pinned at bottom)
1130
- Rectangle().fill(Palette.border).frame(height: 0.5)
1131
-
1132
- Text("ACTIONS")
1133
- .font(Typo.monoBold(8))
1134
- .foregroundColor(Palette.textMuted)
1135
- .frame(maxWidth: .infinity, alignment: .leading)
1136
- .padding(.horizontal, 8)
1137
- .padding(.top, 6)
1138
- .padding(.bottom, 4)
1139
-
1140
- LazyVGrid(columns: columns, spacing: 4) {
1141
- ForEach(Array(actions.enumerated()), id: \.offset) { _, item in
1142
- let isHovered = hoveredShelfAction == item.key
1143
- Button(action: item.action) {
1144
- HStack(spacing: 4) {
1145
- Text(item.key)
1146
- .font(Typo.monoBold(8))
1147
- .foregroundColor(Self.shelfGreen)
1148
- .frame(width: 14)
1149
- Text(item.label)
1150
- .font(Typo.mono(8))
1151
- .foregroundColor(isHovered ? Palette.text : Palette.textDim)
1152
- .lineLimit(1)
1153
- Spacer()
1154
- }
1155
- .padding(.horizontal, 6)
1156
- .padding(.vertical, 4)
1157
- .background(
1158
- RoundedRectangle(cornerRadius: 4)
1159
- .fill(isHovered ? Palette.surfaceHov : Palette.surface)
1160
- .overlay(
1161
- RoundedRectangle(cornerRadius: 4)
1162
- .strokeBorder(isHovered ? Palette.borderLit : Palette.border, lineWidth: 0.5)
1163
- )
1164
- )
1165
- .contentShape(Rectangle())
1166
- }
1167
- .buttonStyle(.plain)
1168
- .onHover { h in
1169
- hoveredShelfAction = h ? item.key : (hoveredShelfAction == item.key ? nil : hoveredShelfAction)
1170
- }
1171
- }
1172
- }
1173
- .padding(.horizontal, 6)
1174
- .padding(.bottom, 4)
1175
-
1176
- Rectangle().fill(Palette.border).frame(height: 0.5)
1177
- inspectorVoiceTray
1178
-
1179
- Rectangle().fill(Palette.border).frame(height: 0.5)
1180
- inspectorLogTray
1181
- }
1182
- .background(Color(red: 0.06, green: 0.06, blue: 0.07))
1183
- }
1184
-
1185
- private var inspectorVoiceStateLabel: String {
1186
- switch handsOff.state {
1187
- case .idle: return handsOff.lastTranscript == nil ? "ready" : "idle"
1188
- case .connecting: return "connecting"
1189
- case .listening: return "listening"
1190
- case .thinking: return "thinking"
1191
- }
1192
- }
1193
-
1194
- private var inspectorVoiceColor: Color {
1195
- switch handsOff.state {
1196
- case .idle: return Palette.textMuted.opacity(0.55)
1197
- case .connecting: return Palette.detach
1198
- case .listening: return Palette.running
1199
- case .thinking: return Palette.detach
1200
- }
1201
- }
1202
-
1203
- private var visibleDiagnosticEntries: [DiagnosticLog.Entry] {
1204
- let entries = diagnosticLog.entries
1205
- let tail = 8
1206
- if entries.count <= tail { return entries }
1207
- return Array(entries.suffix(tail))
1208
- }
1209
-
1210
- private var inspectorVoiceTray: some View {
1211
- VStack(alignment: .leading, spacing: 6) {
1212
- HStack(spacing: 6) {
1213
- Text("VOICE")
1214
- .font(Typo.monoBold(8))
1215
- .foregroundColor(Palette.textMuted)
1216
- Spacer()
1217
- Circle()
1218
- .fill(inspectorVoiceColor)
1219
- .frame(width: 6, height: 6)
1220
- Text(inspectorVoiceStateLabel)
1221
- .font(Typo.mono(8))
1222
- .foregroundColor(inspectorVoiceColor)
1223
- Text("V")
1224
- .font(Typo.monoBold(7))
1225
- .foregroundColor(Palette.textDim)
1226
- .padding(.horizontal, 4)
1227
- .padding(.vertical, 2)
1228
- .background(
1229
- RoundedRectangle(cornerRadius: 3)
1230
- .fill(Palette.surface)
1231
- .overlay(
1232
- RoundedRectangle(cornerRadius: 3)
1233
- .strokeBorder(Palette.border, lineWidth: 0.5)
1234
- )
1235
- )
1236
- }
1237
-
1238
- if let transcript = handsOff.lastTranscript, !transcript.isEmpty {
1239
- VStack(alignment: .leading, spacing: 2) {
1240
- Text("heard")
1241
- .font(Typo.mono(7))
1242
- .foregroundColor(Palette.textMuted)
1243
- Text(transcript)
1244
- .font(Typo.mono(8))
1245
- .foregroundColor(Palette.text)
1246
- .lineLimit(2)
1247
- }
1248
- } else {
1249
- Text("Voice activity will show up here. Press V to talk.")
1250
- .font(Typo.mono(8))
1251
- .foregroundColor(Palette.textMuted)
1252
- .lineLimit(2)
1253
- }
1254
-
1255
- if let response = handsOff.lastResponse, !response.isEmpty {
1256
- VStack(alignment: .leading, spacing: 2) {
1257
- Text("response")
1258
- .font(Typo.mono(7))
1259
- .foregroundColor(Palette.textMuted)
1260
- Text(response)
1261
- .font(Typo.mono(8))
1262
- .foregroundColor(Palette.textDim)
1263
- .lineLimit(2)
1264
- }
1265
- }
1266
- }
1267
- .padding(.horizontal, 8)
1268
- .padding(.vertical, 8)
1269
- }
1270
-
1271
- private var inspectorLogTray: some View {
1272
- VStack(alignment: .leading, spacing: 6) {
1273
- HStack(spacing: 6) {
1274
- Text("LOGS")
1275
- .font(Typo.monoBold(8))
1276
- .foregroundColor(Palette.textMuted)
1277
- Spacer()
1278
- if !visibleDiagnosticEntries.isEmpty {
1279
- Button("copy") {
1280
- let text = visibleDiagnosticEntries.map { entry in
1281
- "\(Self.inspectorLogTimeFormatter.string(from: entry.time)) \(entry.icon) \(entry.message)"
1282
- }.joined(separator: "\n")
1283
- NSPasteboard.general.clearContents()
1284
- NSPasteboard.general.setString(text, forType: .string)
1285
- controller.flash("Copied logs")
1286
- }
1287
- .font(Typo.mono(7))
1288
- .foregroundColor(Palette.textMuted)
1289
- .buttonStyle(.plain)
1290
- }
1291
- Button("open") {
1292
- DiagnosticWindow.shared.toggle()
1293
- }
1294
- .font(Typo.mono(7))
1295
- .foregroundColor(Palette.textMuted)
1296
- .buttonStyle(.plain)
1297
- }
1298
-
1299
- if visibleDiagnosticEntries.isEmpty {
1300
- Text("Waiting for diagnostic activity.")
1301
- .font(Typo.mono(8))
1302
- .foregroundColor(Palette.textMuted)
1303
- } else {
1304
- VStack(alignment: .leading, spacing: 5) {
1305
- ForEach(visibleDiagnosticEntries) { entry in
1306
- HStack(alignment: .top, spacing: 6) {
1307
- Text(Self.inspectorLogTimeFormatter.string(from: entry.time))
1308
- .font(Typo.mono(7))
1309
- .foregroundColor(Palette.textMuted)
1310
- .frame(width: 52, alignment: .leading)
1311
- Text(entry.icon)
1312
- .font(Typo.monoBold(7))
1313
- .foregroundColor(inspectorLogColor(entry.level))
1314
- .frame(width: 8, alignment: .leading)
1315
- Text(entry.message)
1316
- .font(Typo.mono(8))
1317
- .foregroundColor(inspectorLogColor(entry.level))
1318
- .lineLimit(2)
1319
- .frame(maxWidth: .infinity, alignment: .leading)
1320
- }
1321
- }
1322
- }
1323
- .frame(maxWidth: .infinity, alignment: .leading)
1324
- }
1325
- }
1326
- .padding(.horizontal, 8)
1327
- .padding(.vertical, 8)
1328
- .frame(maxWidth: .infinity, alignment: .leading)
1329
- }
1330
-
1331
- private func inspectorLogColor(_ level: DiagnosticLog.Entry.Level) -> Color {
1332
- switch level {
1333
- case .info: return Palette.textDim
1334
- case .success: return Palette.running
1335
- case .warning: return Palette.detach
1336
- case .error: return Palette.kill
1337
- }
1338
- }
1339
-
1340
- private func inspectorRow(label: String, value: String) -> some View {
1341
- HStack(alignment: .top, spacing: 0) {
1342
- Text(label)
1343
- .font(Typo.mono(8))
1344
- .foregroundColor(Palette.textMuted)
1345
- .frame(width: 52, alignment: .leading)
1346
- Text(value)
1347
- .font(Typo.mono(8))
1348
- .foregroundColor(Palette.textDim)
1349
- .lineLimit(2)
1350
- }
1351
- }
1352
-
1353
- // MARK: - Canvas Context Badge
1354
-
1355
- private var canvasContextBadge: some View {
1356
- HStack(spacing: 6) {
1357
- if let editor = controller.editor {
1358
- let layerColor = editor.activeLayer != nil
1359
- ? Self.layerColor(for: editor.activeLayer!)
1360
- : Palette.running
1361
-
1362
- Circle()
1363
- .fill(layerColor)
1364
- .frame(width: 6, height: 6)
1365
-
1366
- Text(editor.layerLabel)
1367
- .font(Typo.monoBold(9))
1368
- .foregroundColor(layerColor)
1369
-
1370
- Text("·")
1371
- .foregroundColor(Palette.textMuted)
1372
-
1373
- Text("\(editor.focusedVisibleWindows.count) windows")
1374
- .font(Typo.mono(9))
1375
- .foregroundColor(Palette.textDim)
1376
-
1377
- Text("·")
1378
- .foregroundColor(Palette.textMuted)
1379
-
1380
- Text(editor.viewportPresetSummary.uppercased())
1381
- .font(Typo.monoBold(8))
1382
- .foregroundColor(Palette.textMuted)
1383
-
1384
- if let focused = editor.focusedDisplay {
1385
- Text("·")
1386
- .foregroundColor(Palette.textMuted)
1387
- Text(focused.label)
1388
- .font(Typo.mono(8))
1389
- .foregroundColor(Palette.textMuted)
1390
- .lineLimit(1)
1391
- }
1392
-
1393
- let editCount = editor.windows.filter { $0.hasEdits }.count
1394
- if editCount > 0 {
1395
- Text("·")
1396
- .foregroundColor(Palette.textMuted)
1397
- Text("\(editCount) pending")
1398
- .font(Typo.mono(8))
1399
- .foregroundColor(Color.orange.opacity(0.8))
1400
- .onTapGesture { controller.applyEditsFromButton() }
1401
- .onHover { hovering in
1402
- if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
1403
- }
1404
- }
1405
-
1406
- if let ref = editor.lastActionRef {
1407
- Text("·")
1408
- .foregroundColor(Palette.textMuted)
1409
- Text(ref)
1410
- .font(Typo.monoBold(8))
1411
- .foregroundColor(Self.shelfGreen.opacity(0.7))
1412
- }
1413
- }
1414
- }
1415
- .padding(.horizontal, 8)
1416
- .padding(.vertical, 4)
1417
- .background(
1418
- RoundedRectangle(cornerRadius: 6)
1419
- .fill(Color.black.opacity(0.55))
1420
- .overlay(
1421
- RoundedRectangle(cornerRadius: 6)
1422
- .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
1423
- )
1424
- )
1425
- .padding(10)
1426
- }
1427
-
1428
- // MARK: - Layer Sidebar
1429
-
1430
- private func layerSidebar(editor: ScreenMapEditorState) -> some View {
1431
- return VStack(spacing: 0) {
1432
- // Header
1433
- HStack {
1434
- Text("VIEW")
1435
- .font(Typo.monoBold(9))
1436
- .foregroundColor(Palette.textMuted)
1437
- Spacer()
1438
- if editor.effectiveLayerCount > 1 && !editor.isShowingAll {
1439
- Button(action: { controller.consolidateLayers() }) {
1440
- Image(systemName: "arrow.triangle.merge")
1441
- .font(.system(size: 8, weight: .semibold))
1442
- .foregroundColor(Palette.textDim)
1443
- }
1444
- .buttonStyle(.plain)
1445
- .help("Defrag layers (c)")
1446
- }
1447
- }
1448
- .padding(.bottom, 8)
1449
-
1450
- // Layer list
1451
- ScrollView(.vertical, showsIndicators: false) {
1452
- let visibleWindows = editor.renderedCanvasWindows.sorted { $0.zIndex < $1.zIndex }
1453
- let rowWidth = max(sidebarWidth - 16, 1)
1454
-
1455
- ZStack(alignment: .topLeading) {
1456
- VStack(spacing: 2) {
1457
- layerTreeHeader(
1458
- label: "Desktop",
1459
- count: visibleWindows.count,
1460
- isActive: editor.isShowingAll,
1461
- color: Palette.running
1462
- ) {
1463
- editor.selectLayer(nil)
1464
- }
1465
- .frame(width: rowWidth, height: Self.sidebarWindowRowHeight, alignment: .leading)
1466
-
1467
- ForEach(visibleWindows) { win in
1468
- visibleWindowRow(win: win)
1469
- }
1470
-
1471
- /*
1472
- Depth controls intentionally stay out of the default flow.
1473
- Keep the old layer model available internally, but make the
1474
- normal sidebar answer: "which windows are visible right now?"
1475
- */
1476
- if !editor.isShowingAll {
1477
- let namedLayers = editor.namedEffectiveLayers
1478
- let unnamedLayers = editor.unnamedEffectiveLayers
1479
- ForEach(namedLayers, id: \.self) { layer in
1480
- layerRow(layer: layer, editor: editor)
1481
- }
1482
-
1483
- if !unnamedLayers.isEmpty {
1484
- HStack(spacing: 4) {
1485
- let totalWindows = unnamedLayers.reduce(0) { $0 + editor.layerTreeWindows(for: $1).count }
1486
- Image(systemName: showUnnamedLayers ? "chevron.down" : "chevron.right")
1487
- .font(.system(size: 6, weight: .bold))
1488
- .foregroundColor(Palette.textMuted)
1489
- Text(showUnnamedLayers ? "hide depth" : "show depth")
1490
- .font(Typo.mono(8))
1491
- .foregroundColor(Palette.textMuted)
1492
- Text("· \(totalWindows)w")
1493
- .font(Typo.mono(7))
1494
- .foregroundColor(Palette.textDim)
1495
- Spacer()
1496
- }
1497
- .padding(.vertical, 4)
1498
- .padding(.horizontal, 4)
1499
- .contentShape(Rectangle())
1500
- .simultaneousGesture(TapGesture().onEnded { showUnnamedLayers.toggle() })
1501
- }
1502
-
1503
- if showUnnamedLayers {
1504
- ForEach(unnamedLayers, id: \.self) { layer in
1505
- layerRow(layer: layer, editor: editor)
1506
- }
1507
- }
1508
- }
1509
- }
1510
- .frame(width: rowWidth, alignment: .topLeading)
1511
- .allowsHitTesting(!editor.isShowingAll)
1512
-
1513
- if editor.isShowingAll {
1514
- SidebarWindowHitCatcher(rowHeight: Self.sidebarWindowRowStride) { row in
1515
- if row == 0 {
1516
- editor.selectLayer(nil)
1517
- } else {
1518
- let index = row - 1
1519
- guard visibleWindows.indices.contains(index) else { return }
1520
- controller.selectSingle(visibleWindows[index].id)
1521
- }
1522
- }
1523
- .frame(
1524
- width: rowWidth,
1525
- height: CGFloat(visibleWindows.count + 1) * Self.sidebarWindowRowStride
1526
- )
1527
- }
1528
- }
1529
- .frame(
1530
- width: rowWidth,
1531
- height: CGFloat(visibleWindows.count + 1) * Self.sidebarWindowRowStride,
1532
- alignment: .topLeading
1533
- )
1534
- }
1535
- .coordinateSpace(name: "layerSidebar")
1536
-
1537
- Spacer(minLength: 8)
1538
- collapsibleSection(title: "SETS", count: controller.windowSets.count, isExpanded: $showSets) {
1539
- windowSetsSection(editor: editor)
1540
- }
1541
- collapsibleSection(title: "EXPLORER", count: editor.canvasExplorerRegions.count, isExpanded: $showExplorer) {
1542
- canvasExplorer(editor: editor)
1543
- }
1544
- Spacer(minLength: 8)
1545
- sidebarMiniMap(editor: editor)
1546
- }
1547
- .padding(.horizontal, 8)
1548
- .padding(.vertical, 8)
1549
- .frame(width: sidebarWidth)
1550
- .onPreferenceChange(LayerRowFrameKey.self) { layerRowFrames = $0 }
1551
- }
1552
-
1553
- private func visibleWindowRow(win: ScreenMapWindowEntry) -> some View {
1554
- let isSelected = controller.selectedWindowIds.contains(win.id)
1555
- let rowWidth = max(sidebarWidth - 16, 1)
1556
- return HStack(spacing: 6) {
1557
- Circle()
1558
- .fill(isSelected ? Palette.running : Palette.textMuted.opacity(0.55))
1559
- .frame(width: 4, height: 4)
1560
- VStack(alignment: .leading, spacing: 1) {
1561
- Text(win.app)
1562
- .font(Typo.monoBold(8))
1563
- .foregroundColor(isSelected ? Palette.running : Palette.textDim)
1564
- .lineLimit(1)
1565
- if !win.title.isEmpty {
1566
- Text(win.title)
1567
- .font(Typo.mono(7))
1568
- .foregroundColor(Palette.textMuted)
1569
- .lineLimit(1)
1570
- }
1571
- }
1572
- Spacer(minLength: 4)
1573
- if win.hasEdits {
1574
- Circle()
1575
- .fill(Color.orange.opacity(0.85))
1576
- .frame(width: 4, height: 4)
1577
- }
1578
- }
1579
- .padding(.vertical, 3)
1580
- .padding(.leading, 20)
1581
- .padding(.trailing, 6)
1582
- .frame(width: rowWidth, height: Self.sidebarWindowRowHeight, alignment: .leading)
1583
- .clipped()
1584
- .background(
1585
- RoundedRectangle(cornerRadius: 4)
1586
- .fill(isSelected ? Palette.running.opacity(0.08) : Color.clear)
1587
- )
1588
- .contentShape(Rectangle())
1589
- .highPriorityGesture(TapGesture().onEnded {
1590
- controller.selectSingle(win.id)
1591
- })
1592
- .accessibilityElement(children: .combine)
1593
- .accessibilityLabel(win.title.isEmpty ? win.app : "\(win.app), \(win.title)")
1594
- .accessibilityAddTraits(.isButton)
1595
- }
1596
-
1597
- @ViewBuilder
1598
- private func layerRow(layer: Int, editor: ScreenMapEditorState) -> some View {
1599
- let displayName = editor.layerDisplayName(for: layer)
1600
- let fullName = editor.layerNames[layer]
1601
- let color = Self.layerColor(for: layer)
1602
- let isActive = editor.isLayerSelected(layer)
1603
- let isDropTarget = dropTargetLayer == layer
1604
- let layerWindows = editor.layerTreeWindows(for: layer)
1605
-
1606
- VStack(spacing: 0) {
1607
- layerTreeHeader(label: fullName ?? displayName,
1608
- count: layerWindows.count,
1609
- isActive: isActive,
1610
- color: color,
1611
- isExpandable: true,
1612
- isExpanded: expandedLayers.contains(layer),
1613
- onToggleExpand: { toggleExpandedLayer(layer) }) {
1614
- selectSidebarLayer(layer, editor: editor)
1615
- }
1616
-
1617
- if expandedLayers.contains(layer) {
1618
- VStack(spacing: 0) {
1619
- ForEach(layerWindows) { win in
1620
- let isSelected = controller.selectedWindowIds.contains(win.id)
1621
- let isDragging = sidebarDragWindowId == win.id
1622
- HStack(spacing: 4) {
1623
- Rectangle()
1624
- .fill(color.opacity(0.4))
1625
- .frame(width: 1, height: 12)
1626
- .padding(.leading, 8)
1627
- Text(win.app)
1628
- .font(Typo.mono(8))
1629
- .foregroundColor(isSelected ? Palette.running : Palette.textDim)
1630
- .lineLimit(1)
1631
- Spacer()
1632
- if win.hasEdits {
1633
- Circle()
1634
- .fill(Color.orange)
1635
- .frame(width: 4, height: 4)
1636
- }
1637
- }
1638
- .padding(.vertical, 2)
1639
- .padding(.horizontal, 4)
1640
- .background(
1641
- RoundedRectangle(cornerRadius: 3)
1642
- .fill(isSelected ? Palette.running.opacity(0.08) : Color.clear)
1643
- )
1644
- .contentShape(Rectangle())
1645
- .opacity(isDragging ? 0.4 : 1.0)
1646
- .offset(isDragging ? sidebarDragOffset : .zero)
1647
- .zIndex(isDragging ? 10 : 0)
1648
- .gesture(
1649
- DragGesture(minimumDistance: 4, coordinateSpace: .named("layerSidebar"))
1650
- .onChanged { value in
1651
- handleSidebarWindowDragChanged(value, sourceLayer: layer, windowId: win.id)
1652
- }
1653
- .onEnded { _ in
1654
- finishSidebarWindowDrag(win, editor: editor)
1655
- }
1656
- )
1657
- .simultaneousGesture(TapGesture().onEnded {
1658
- selectSidebarWindow(win.id)
1659
- })
1660
- }
1661
- }
1662
- .padding(.leading, 4)
1663
- .padding(.top, 2)
1664
- }
1665
- }
1666
- .overlay(
1667
- RoundedRectangle(cornerRadius: 4)
1668
- .strokeBorder(isDropTarget ? Palette.running : Color.clear, lineWidth: 1.5)
1669
- )
1670
- .background(
1671
- GeometryReader { geo in
1672
- Color.clear.preference(key: LayerRowFrameKey.self,
1673
- value: [layer: geo.frame(in: .named("layerSidebar"))])
1674
- }
1675
- )
1676
- }
1677
-
1678
- private func toggleExpandedLayer(_ layer: Int) {
1679
- if expandedLayers.contains(layer) {
1680
- expandedLayers.remove(layer)
1681
- } else {
1682
- expandedLayers.insert(layer)
1683
- }
1684
- }
1685
-
1686
- private func selectSidebarLayer(_ layer: Int, editor: ScreenMapEditorState) {
1687
- if NSEvent.modifierFlags.contains(.command) {
1688
- editor.toggleLayerSelection(layer)
1689
- } else {
1690
- editor.selectLayer(layer)
1691
- }
1692
- expandedLayers.insert(layer)
1693
- }
1694
-
1695
- private func selectSidebarWindow(_ windowId: UInt32) {
1696
- if NSEvent.modifierFlags.contains(.command) {
1697
- controller.toggleSelection(windowId)
1698
- } else {
1699
- controller.selectSingle(windowId)
1700
- }
1701
- }
1702
-
1703
- private func resolveSidebarDropTarget(at point: CGPoint, excluding layer: Int) -> Int? {
1704
- for (candidate, frame) in layerRowFrames where candidate != layer {
1705
- if frame.contains(point) {
1706
- return candidate
1707
- }
1708
- }
1709
- return nil
1710
- }
1711
-
1712
- private func handleSidebarWindowDragChanged(_ value: DragGesture.Value, sourceLayer: Int, windowId: UInt32) {
1713
- sidebarDragWindowId = windowId
1714
- sidebarDragOffset = value.translation
1715
- controller.selectSingle(windowId)
1716
- dropTargetLayer = resolveSidebarDropTarget(at: value.location, excluding: sourceLayer)
1717
- }
1718
-
1719
- private func finishSidebarWindowDrag(_ win: ScreenMapWindowEntry, editor: ScreenMapEditorState) {
1720
- if let targetLayer = dropTargetLayer {
1721
- editor.reassignLayer(windowId: win.id, toLayer: targetLayer, fitToAvailable: true)
1722
- controller.flash("Moved to L\(targetLayer)")
1723
- }
1724
- sidebarDragWindowId = nil
1725
- sidebarDragOffset = .zero
1726
- dropTargetLayer = nil
1727
- }
1728
-
1729
- private func collapsibleSection<Content: View>(title: String, count: Int, isExpanded: Binding<Bool>,
1730
- @ViewBuilder content: @escaping () -> Content) -> some View {
1731
- VStack(spacing: 0) {
1732
- HStack(spacing: 4) {
1733
- Image(systemName: isExpanded.wrappedValue ? "chevron.down" : "chevron.right")
1734
- .font(.system(size: 6, weight: .bold))
1735
- .foregroundColor(Palette.textMuted)
1736
- Text(title)
1737
- .font(Typo.monoBold(8))
1738
- .foregroundColor(Palette.textMuted)
1739
- Text("\(count)")
1740
- .font(Typo.mono(7))
1741
- .foregroundColor(Palette.textDim)
1742
- Spacer()
1743
- }
1744
- .padding(.vertical, 4)
1745
- .contentShape(Rectangle())
1746
- .simultaneousGesture(TapGesture().onEnded { isExpanded.wrappedValue.toggle() })
1747
-
1748
- if isExpanded.wrappedValue {
1749
- content()
1750
- .padding(.top, 4)
1751
- }
1752
- }
1753
- .padding(.bottom, 4)
1754
- }
1755
-
1756
- private func windowSetsSection(editor: ScreenMapEditorState) -> some View {
1757
- let sets = controller.windowSets
1758
- let canSave = !controller.selectedWindowIds.isEmpty
1759
-
1760
- return VStack(alignment: .leading, spacing: 4) {
1761
- if sets.isEmpty {
1762
- HStack {
1763
- Text("No sets yet.")
1764
- .font(Typo.mono(7))
1765
- .foregroundColor(Palette.textMuted)
1766
- Spacer()
1767
- Button {
1768
- controller.createWindowSetFromSelection()
1769
- } label: {
1770
- Text("u save")
1771
- .font(Typo.monoBold(7))
1772
- .foregroundColor(canSave ? Self.shelfGreen : Palette.textMuted)
1773
- }
1774
- .buttonStyle(.plain)
1775
- .disabled(!canSave)
1776
- }
1777
- } else {
1778
- ForEach(sets) { set in
1779
- windowSetRow(set: set, editor: editor)
1780
- }
1781
- }
1782
- }
1783
- }
1784
-
1785
- private func windowSetRow(set: ScreenMapWindowSet, editor: ScreenMapEditorState) -> some View {
1786
- let liveCount = editor.windows(matching: set.windowIds).count
1787
- let isActive = controller.activeWindowSetID == set.id
1788
-
1789
- return HStack(spacing: 6) {
1790
- Button {
1791
- controller.focusWindowSet(set)
1792
- } label: {
1793
- HStack(spacing: 6) {
1794
- Circle()
1795
- .fill(isActive ? Self.shelfGreen : Palette.textMuted.opacity(0.7))
1796
- .frame(width: 6, height: 6)
1797
- VStack(alignment: .leading, spacing: 1) {
1798
- Text(set.name)
1799
- .font(Typo.monoBold(8))
1800
- .foregroundColor(isActive ? Palette.text : Palette.textDim)
1801
- .lineLimit(1)
1802
- Text("\(liveCount) window\(liveCount == 1 ? "" : "s")")
1803
- .font(Typo.mono(7))
1804
- .foregroundColor(Palette.textMuted)
1805
- .lineLimit(1)
1806
- }
1807
- Spacer()
1808
- }
1809
- .padding(.horizontal, 6)
1810
- .padding(.vertical, 4)
1811
- .background(
1812
- RoundedRectangle(cornerRadius: 4)
1813
- .fill(isActive ? Self.shelfGreen.opacity(0.08) : Palette.surface.opacity(0.35))
1814
- .overlay(
1815
- RoundedRectangle(cornerRadius: 4)
1816
- .strokeBorder(isActive ? Self.shelfGreen.opacity(0.2) : Color.white.opacity(0.04), lineWidth: 0.5)
1817
- )
1818
- )
1819
- }
1820
- .buttonStyle(.plain)
1821
-
1822
- Button {
1823
- controller.deleteWindowSet(set)
1824
- } label: {
1825
- Image(systemName: "xmark")
1826
- .font(.system(size: 7, weight: .bold))
1827
- .foregroundColor(Palette.textMuted)
1828
- .frame(width: 16, height: 16)
1829
- .background(
1830
- RoundedRectangle(cornerRadius: 3)
1831
- .fill(Palette.surface.opacity(0.8))
1832
- .overlay(
1833
- RoundedRectangle(cornerRadius: 3)
1834
- .strokeBorder(Palette.border, lineWidth: 0.5)
1835
- )
1836
- )
1837
- }
1838
- .buttonStyle(.plain)
1839
- }
1840
- }
1841
-
1842
- private func layerTreeHeader(label: String, count: Int, isActive: Bool, color: Color,
1843
- isExpandable: Bool = false, isExpanded: Bool = false,
1844
- onToggleExpand: (() -> Void)? = nil,
1845
- action: @escaping () -> Void) -> some View {
1846
- HStack(spacing: 0) {
1847
- if isExpandable {
1848
- Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
1849
- .font(.system(size: 7, weight: .bold))
1850
- .foregroundColor(Palette.textMuted)
1851
- .frame(width: 16, height: 16)
1852
- .onTapGesture { onToggleExpand?() }
1853
- }
1854
- HStack(spacing: 5) {
1855
- Circle()
1856
- .fill(color)
1857
- .frame(width: 6, height: 6)
1858
- Text(label)
1859
- .font(Typo.monoBold(9))
1860
- .lineLimit(1)
1861
- Spacer()
1862
- Text("\(count)")
1863
- .font(Typo.mono(8))
1864
- .foregroundColor(isActive ? Palette.text.opacity(0.7) : Palette.textMuted)
1865
- }
1866
- .foregroundColor(isActive ? Palette.text : Palette.textDim)
1867
- }
1868
- .padding(.leading, isExpandable ? 0 : 16)
1869
- .padding(.trailing, 8)
1870
- .padding(.vertical, 5)
1871
- .frame(maxWidth: .infinity, alignment: .leading)
1872
- .background(
1873
- RoundedRectangle(cornerRadius: 6)
1874
- .fill(isActive ? color.opacity(0.12) : Color.clear)
1875
- )
1876
- .contentShape(Rectangle())
1877
- .simultaneousGesture(TapGesture().onEnded { action() })
1878
- }
1879
-
1880
- // MARK: - Canvas
1881
-
1882
- private func canvasStageAspectRatio(editor: ScreenMapEditorState?, displays: [DisplayGeometry]) -> CGFloat {
1883
- let scopedDisplays: [DisplayGeometry]
1884
- if let focusedDisplayIndex = editor?.focusedDisplayIndex {
1885
- scopedDisplays = displays.filter { $0.index == focusedDisplayIndex }
1886
- } else {
1887
- scopedDisplays = displays
1888
- }
1889
-
1890
- let displayBounds = scopedDisplays.map(\.cgRect).reduce(nil as CGRect?) { partial, rect in
1891
- partial.map { $0.union(rect) } ?? rect
1892
- }
1893
- let bounds = displayBounds ?? editor?.canvasWorldBounds ?? CGRect(x: 0, y: 0, width: 16, height: 10)
1894
- let rawAspect = bounds.width / max(bounds.height, 1)
1895
- return min(max(rawAspect, Self.canvasStageMinAspect), Self.canvasStageMaxAspect)
1896
- }
1897
-
1898
- private func screenMapCanvas(editor: ScreenMapEditorState?) -> some View {
1899
- let isFocused = editor?.focusedDisplayIndex != nil
1900
- let canvasWindows = editor?.renderedCanvasWindows ?? []
1901
- let displays = editor?.displays ?? []
1902
- let zoomLevel = editor?.zoomLevel ?? 1.0
1903
- let panOffset = editor?.panOffset ?? .zero
1904
- let canvasShape = RoundedRectangle(cornerRadius: 6, style: .continuous)
1905
- let stageAspectRatio = canvasStageAspectRatio(editor: editor, displays: displays)
1906
- let usesProjectionWindows = editor?.isShowingAll ?? true
1907
-
1908
- return GeometryReader { geo in
1909
- let metrics = CanvasMetrics(editor: editor, displays: displays, viewportSize: geo.size)
1910
- let syncKey = CanvasSyncKey(
1911
- viewportSize: geo.size,
1912
- worldBounds: metrics.worldBounds,
1913
- zoomLevel: zoomLevel,
1914
- navigationRevision: editor?.canvasNavigationRevision ?? 0
1915
- )
1916
- ZStack(alignment: .topLeading) {
1917
- // Per-display background rectangles
1918
- if isFocused, editor?.focusedDisplay != nil {
1919
- focusedDisplayBackground(mapSize: metrics.mapSize)
1920
- } else if !displays.isEmpty {
1921
- multiDisplayBackgrounds(displays: displays, editor: editor, metrics: metrics)
1922
- } else {
1923
- singleDisplayBackground(mapSize: metrics.mapSize)
1924
- }
1925
-
1926
- // Ghost outlines for edited windows
1927
- ForEach(canvasWindows.filter(\.hasEdits)) { win in
1928
- let rect = metrics.mapRect(for: win.originalFrame)
1929
-
1930
- RoundedRectangle(cornerRadius: 2)
1931
- .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
1932
- .foregroundColor(Palette.textMuted.opacity(0.4))
1933
- .frame(width: rect.width, height: rect.height)
1934
- .offset(x: rect.minX, y: rect.minY)
1935
- }
1936
-
1937
- if !usesProjectionWindows {
1938
- // Live windows back-to-front
1939
- ForEach(Array(canvasWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
1940
- windowTile(win: win, editor: editor, metrics: metrics)
1941
- }
1942
- }
1943
- }
1944
- .frame(width: metrics.mapSize.width, height: metrics.mapSize.height)
1945
- .offset(x: metrics.centerOffset.x + panOffset.x, y: metrics.centerOffset.y + panOffset.y)
1946
- .onAppear {
1947
- syncCanvasGeometry(editor: editor, metrics: metrics)
1948
- }
1949
- .onChange(of: syncKey) { _ in
1950
- syncCanvasGeometry(editor: editor, metrics: metrics)
1951
- }
1952
- }
1953
- .padding(8)
1954
- .frame(maxWidth: Self.canvasStageMaxWidth, maxHeight: Self.canvasStageMaxHeight)
1955
- .aspectRatio(stageAspectRatio, contentMode: .fit)
1956
- .contentShape(canvasShape)
1957
- .clipShape(canvasShape)
1958
- .clipped()
1959
- .background(
1960
- ZStack {
1961
- canvasShape
1962
- .fill(Color.black.opacity(0.25))
1963
- canvasShape
1964
- .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
1965
- Canvas { context, size in
1966
- let spacing: CGFloat = 20
1967
- let dotColor = Color.white.opacity(0.04)
1968
- for x in stride(from: spacing, to: size.width, by: spacing) {
1969
- for y in stride(from: spacing, to: size.height, by: spacing) {
1970
- context.fill(
1971
- Path(ellipseIn: CGRect(x: x - 0.5, y: y - 0.5, width: 1, height: 1)),
1972
- with: .color(dotColor)
1973
- )
1974
- }
1975
- }
1976
- }
1977
- }
1978
- )
1979
- .overlay(alignment: .topLeading) {
1980
- Group {
1981
- if let editor = editor {
1982
- let projection = CanvasProjection(editor: editor)
1983
- let projectionLabelIds = projectionLabelWindowIds(
1984
- windows: canvasWindows,
1985
- projection: projection,
1986
- selectedIds: controller.selectedWindowIds
1987
- )
1988
-
1989
- ZStack(alignment: .topLeading) {
1990
- if usesProjectionWindows {
1991
- ForEach(Array(canvasWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
1992
- let rect = projection.mapRect(for: win.virtualFrame)
1993
- let radius = min(max(rect.width, rect.height) * 0.012, 3)
1994
- let isSelected = controller.selectedWindowIds.contains(win.id)
1995
- let fill = isSelected
1996
- ? Palette.running.opacity(0.10)
1997
- : win.hasEdits ? Color.orange.opacity(0.10) : Color.white.opacity(0.018)
1998
- let stroke = isSelected
1999
- ? Palette.running.opacity(0.55)
2000
- : win.hasEdits ? Color.orange.opacity(0.5) : Color.white.opacity(0.10)
2001
-
2002
- RoundedRectangle(cornerRadius: radius)
2003
- .fill(fill)
2004
- .overlay(
2005
- RoundedRectangle(cornerRadius: radius)
2006
- .strokeBorder(stroke, lineWidth: isSelected ? 0.9 : 0.7)
2007
- )
2008
- .overlay {
2009
- if projectionLabelIds.contains(win.id) {
2010
- projectionWindowLabel(win: win, rect: rect, isSelected: isSelected)
2011
- }
2012
- }
2013
- .frame(width: rect.width, height: rect.height)
2014
- .contentShape(Rectangle())
2015
- .offset(
2016
- x: Self.canvasPadding + projection.mapOrigin.x + projection.panOffset.x + rect.minX,
2017
- y: Self.canvasPadding + projection.mapOrigin.y + projection.panOffset.y + rect.minY
2018
- )
2019
- .zIndex(isSelected ? 2 : 0)
2020
- }
2021
- }
2022
-
2023
- ForEach(editor.displays, id: \.index) { display in
2024
- let rect = projection.mapRect(for: display.cgRect, minimumSize: 12)
2025
- let isFocused = editor.focusedDisplayIndex == display.index
2026
- RoundedRectangle(cornerRadius: 5)
2027
- .strokeBorder(
2028
- isFocused ? Palette.running.opacity(0.36) : Color.white.opacity(0.22),
2029
- lineWidth: isFocused ? 1 : 0.8
2030
- )
2031
- .overlay(alignment: .topLeading) {
2032
- Text("\(editor.spatialNumber(for: display.index))")
2033
- .font(Typo.monoBold(8))
2034
- .foregroundColor(isFocused ? Palette.running : Palette.textMuted)
2035
- .padding(.horizontal, 4)
2036
- .padding(.vertical, 2)
2037
- .background(
2038
- RoundedRectangle(cornerRadius: 3)
2039
- .fill(Color.black.opacity(0.42))
2040
- )
2041
- .padding(5)
2042
- }
2043
- .frame(width: rect.width, height: rect.height)
2044
- .offset(
2045
- x: Self.canvasPadding + projection.mapOrigin.x + projection.panOffset.x + rect.minX,
2046
- y: Self.canvasPadding + projection.mapOrigin.y + projection.panOffset.y + rect.minY
2047
- )
2048
- .allowsHitTesting(false)
2049
- .zIndex(-1)
2050
- }
2051
- }
2052
- .allowsHitTesting(false)
2053
- }
2054
- }
2055
- }
2056
- .clipShape(canvasShape)
2057
- .clipped()
2058
- .overlay(alignment: .top) {
2059
- if let editor = controller.editor, editor.displays.count > 1 {
2060
- displayToolbar(editor: editor)
2061
- .padding(.top, 8)
2062
- }
2063
- }
2064
- .overlay(alignment: .bottomLeading) {
2065
- canvasContextBadge
2066
- }
2067
- .overlay(
2068
- GeometryReader { geo in
2069
- Color.clear.onAppear {
2070
- let frame = geo.frame(in: .global)
2071
- screenMapCanvasOrigin = frame.origin
2072
- screenMapCanvasSize = frame.size
2073
- }
2074
- .onChange(of: geo.frame(in: .global)) { newFrame in
2075
- screenMapCanvasOrigin = newFrame.origin
2076
- screenMapCanvasSize = newFrame.size
2077
- }
2078
- }
2079
- )
2080
- }
2081
-
2082
- // MARK: - Display Backgrounds
2083
-
2084
- private func focusedDisplayBackground(mapSize: CGSize) -> some View {
2085
- ZStack(alignment: .topLeading) {
2086
- RoundedRectangle(cornerRadius: 6)
2087
- .fill(Palette.bg.opacity(0.5))
2088
- .overlay(
2089
- RoundedRectangle(cornerRadius: 6)
2090
- .strokeBorder(Palette.running.opacity(0.3), lineWidth: 1)
2091
- )
2092
- .contentShape(Rectangle())
2093
- .onTapGesture { controller.clearSelection() }
2094
- }
2095
- .frame(width: mapSize.width, height: mapSize.height)
2096
- }
2097
-
2098
- private func multiDisplayBackgrounds(displays: [DisplayGeometry], editor: ScreenMapEditorState?, metrics: CanvasMetrics) -> some View {
2099
- ForEach(displays, id: \.index) { disp in
2100
- let frame = metrics.mapRect(for: disp.cgRect, minimumSize: 12)
2101
- let bezel: CGFloat = 3
2102
-
2103
- ZStack {
2104
- RoundedRectangle(cornerRadius: 8)
2105
- .fill(Color.white.opacity(0.07))
2106
- .overlay(
2107
- RoundedRectangle(cornerRadius: 8)
2108
- .strokeBorder(Color.white.opacity(0.18), lineWidth: 1.5)
2109
- )
2110
- RoundedRectangle(cornerRadius: 5)
2111
- .fill(Palette.bg.opacity(0.55))
2112
- .overlay(
2113
- RoundedRectangle(cornerRadius: 5)
2114
- .strokeBorder(Color.black.opacity(0.4), lineWidth: 0.5)
2115
- )
2116
- .padding(bezel)
2117
-
2118
- // Display number badge (top-left corner)
2119
- VStack {
2120
- HStack {
2121
- ZStack {
2122
- Circle()
2123
- .fill(Color.white.opacity(0.3))
2124
- .frame(width: 16, height: 16)
2125
- Text("\(editor?.spatialNumber(for: disp.index) ?? (disp.index + 1))")
2126
- .font(.system(size: 8, weight: .bold, design: .monospaced))
2127
- .foregroundColor(.black)
2128
- }
2129
- .padding(.top, bezel + 4)
2130
- .padding(.leading, bezel + 4)
2131
- Spacer()
2132
- }
2133
- Spacer()
2134
- }
2135
- }
2136
- .contentShape(Rectangle())
2137
- .onTapGesture {
2138
- controller.setDisplayFocus(disp.index)
2139
- }
2140
- .frame(width: frame.width, height: frame.height)
2141
- .offset(x: frame.minX, y: frame.minY)
2142
- }
2143
- }
2144
-
2145
- private func singleDisplayBackground(mapSize: CGSize) -> some View {
2146
- ZStack(alignment: .topLeading) {
2147
- RoundedRectangle(cornerRadius: 6)
2148
- .fill(Palette.bg.opacity(0.5))
2149
- .overlay(
2150
- RoundedRectangle(cornerRadius: 6)
2151
- .strokeBorder(Palette.border, lineWidth: 0.5)
2152
- )
2153
- .contentShape(Rectangle())
2154
- .onTapGesture { controller.clearSelection() }
2155
-
2156
- }
2157
- .frame(width: mapSize.width, height: mapSize.height)
2158
- }
2159
-
2160
- // MARK: - Window Tile
2161
-
2162
- private func projectionLabelWindowIds(
2163
- windows: [ScreenMapWindowEntry],
2164
- projection: CanvasProjection,
2165
- selectedIds: Set<UInt32>
2166
- ) -> Set<UInt32> {
2167
- if !selectedIds.isEmpty {
2168
- return Set(windows.compactMap { win in
2169
- guard selectedIds.contains(win.id) else { return nil }
2170
- let rect = projection.mapRect(for: win.virtualFrame)
2171
- return rect.width > 54 && rect.height > 24 ? win.id : nil
2172
- })
2173
- }
2174
-
2175
- var acceptedIds = Set<UInt32>()
2176
- var occupiedLabelRects: [CGRect] = []
2177
-
2178
- for (frontOrder, win) in windows.sorted(by: { $0.zIndex < $1.zIndex }).enumerated() {
2179
- let rect = projection.mapRect(for: win.virtualFrame)
2180
- let isSelected = selectedIds.contains(win.id)
2181
- guard isSelected || frontOrder < 14 else { continue }
2182
- guard rect.width > 54, rect.height > 24 else { continue }
2183
-
2184
- let labelRect = projectionLabelCollisionRect(for: rect, includesTitle: rect.width > 130 && rect.height > 38 && !win.title.isEmpty)
2185
- let collides = occupiedLabelRects.contains { occupied in
2186
- occupied.insetBy(dx: -8, dy: -5).intersects(labelRect)
2187
- }
2188
-
2189
- if isSelected || !collides {
2190
- acceptedIds.insert(win.id)
2191
- occupiedLabelRects.append(labelRect)
2192
- }
2193
- }
2194
-
2195
- return acceptedIds
2196
- }
2197
-
2198
- private func projectionLabelCollisionRect(for rect: CGRect, includesTitle: Bool) -> CGRect {
2199
- let width = min(max(48, rect.width - 10), includesTitle ? 170 : 96)
2200
- let height: CGFloat = includesTitle ? 27 : 15
2201
- return CGRect(
2202
- x: rect.midX - width / 2,
2203
- y: rect.midY - height / 2,
2204
- width: width,
2205
- height: height
2206
- )
2207
- }
2208
-
2209
- @ViewBuilder
2210
- private func projectionWindowLabel(win: ScreenMapWindowEntry, rect: CGRect, isSelected: Bool) -> some View {
2211
- if rect.width > 54, rect.height > 24 {
2212
- Group {
2213
- if isSelected {
2214
- VStack(alignment: .leading, spacing: 2) {
2215
- HStack(spacing: 4) {
2216
- Circle()
2217
- .fill(Palette.running)
2218
- .frame(width: 4, height: 4)
2219
- Text(win.app)
2220
- .font(Typo.monoBold(9))
2221
- .foregroundColor(Palette.running)
2222
- .lineLimit(1)
2223
- }
2224
- if rect.width > 120, rect.height > 42, !win.title.isEmpty {
2225
- Text(win.title)
2226
- .font(Typo.mono(7))
2227
- .foregroundColor(Palette.textMuted.opacity(0.86))
2228
- .lineLimit(1)
2229
- }
2230
- }
2231
- .padding(.horizontal, 6)
2232
- .padding(.vertical, 4)
2233
- .background(
2234
- RoundedRectangle(cornerRadius: 4)
2235
- .fill(Color.black.opacity(0.48))
2236
- )
2237
- .overlay(
2238
- RoundedRectangle(cornerRadius: 4)
2239
- .strokeBorder(Palette.running.opacity(0.42), lineWidth: 0.75)
2240
- )
2241
- .frame(maxWidth: rect.width, maxHeight: rect.height)
2242
- } else {
2243
- VStack(spacing: 1) {
2244
- Text(win.app)
2245
- .font(Typo.monoBold(max(7, min(10, rect.height * 0.13))))
2246
- .foregroundColor(Palette.text.opacity(0.78))
2247
- .lineLimit(1)
2248
- if rect.width > 130, rect.height > 38, !win.title.isEmpty {
2249
- Text(win.title)
2250
- .font(Typo.mono(max(6, min(8, rect.height * 0.09))))
2251
- .foregroundColor(Palette.textMuted.opacity(0.75))
2252
- .lineLimit(1)
2253
- }
2254
- }
2255
- .padding(.horizontal, 5)
2256
- .padding(.vertical, 3)
2257
- .frame(maxWidth: rect.width, maxHeight: rect.height)
2258
- }
2259
- }
2260
- .allowsHitTesting(false)
2261
- }
2262
- }
2263
-
2264
- @ViewBuilder
2265
- private func windowTile(win: ScreenMapWindowEntry, editor: ScreenMapEditorState?, metrics: CanvasMetrics) -> some View {
2266
- let rect = metrics.mapRect(for: win.virtualFrame)
2267
- let w = rect.width
2268
- let h = rect.height
2269
- let isSelected = controller.selectedWindowIds.contains(win.id)
2270
- let isDragging = editor?.draggingWindowId == win.id
2271
- let isInActiveLayer = editor?.isLayerSelected(win.layer) ?? true
2272
- let winLayerColor = Self.layerColor(for: win.layer)
2273
- let isSearchHighlighted = controller.searchHighlightedWindowId == win.id
2274
- let usesFlatStyle = editor?.isShowingAll ?? true
2275
-
2276
- let fillColor = isSearchHighlighted
2277
- ? Self.shelfGreen.opacity(0.2)
2278
- : isSelected
2279
- ? Palette.running.opacity(0.18)
2280
- : win.hasEdits ? Color.orange.opacity(0.14) : usesFlatStyle ? Palette.surface.opacity(0.68) : winLayerColor.opacity(0.16)
2281
- let borderColor = isSearchHighlighted
2282
- ? Self.shelfGreen.opacity(0.8)
2283
- : isSelected
2284
- ? Palette.running.opacity(0.8)
2285
- : win.hasEdits ? Color.orange.opacity(0.65) : usesFlatStyle ? Palette.border.opacity(0.55) : winLayerColor.opacity(0.42)
2286
-
2287
- RoundedRectangle(cornerRadius: 2)
2288
- .fill(fillColor)
2289
- .overlay(
2290
- RoundedRectangle(cornerRadius: 2)
2291
- .strokeBorder(borderColor, lineWidth: isSearchHighlighted ? 2 : isSelected ? 1.5 : 0.5)
2292
- )
2293
- .overlay(alignment: .leading) {
2294
- if !usesFlatStyle {
2295
- Rectangle()
2296
- .fill(winLayerColor)
2297
- .frame(width: 2)
2298
- }
2299
- }
2300
- .clipShape(RoundedRectangle(cornerRadius: 2))
2301
- .overlay {
2302
- ZStack {
2303
- VStack(spacing: 1) {
2304
- Text(win.app)
2305
- .font(Typo.monoBold(max(7, min(10, h * 0.15))))
2306
- .foregroundColor(isSelected ? Palette.running : Palette.text)
2307
- .lineLimit(1)
2308
- if h > 30 {
2309
- Text(win.title)
2310
- .font(Typo.mono(max(6, min(8, h * 0.1))))
2311
- .foregroundColor(Palette.textDim)
2312
- .lineLimit(1)
2313
- }
2314
- if h > 50 {
2315
- Text("\(Int(win.virtualFrame.width))x\(Int(win.virtualFrame.height))")
2316
- .font(Typo.mono(6))
2317
- .foregroundColor(Palette.textMuted)
2318
- }
2319
- }
2320
- .padding(.leading, 4)
2321
- .padding(2)
2322
-
2323
- if h > 40, let tileIcon = Self.inferTileIcon(for: win, displays: editor?.displays ?? []) {
2324
- VStack {
2325
- HStack {
2326
- Spacer()
2327
- Image(systemName: tileIcon)
2328
- .font(.system(size: 6))
2329
- .foregroundColor(Color.white.opacity(0.3))
2330
- .padding(2)
2331
- }
2332
- Spacer()
2333
- }
2334
- }
2335
-
2336
- if h > 50, let session = Self.extractLatticesSession(from: win.title) {
2337
- VStack {
2338
- Spacer()
2339
- HStack {
2340
- Text("[\(session)]")
2341
- .font(Typo.mono(6))
2342
- .foregroundColor(Palette.running.opacity(0.7))
2343
- .lineLimit(1)
2344
- .padding(.leading, 4)
2345
- .padding(.bottom, 2)
2346
- Spacer()
2347
- }
2348
- }
2349
- }
2350
- }
2351
- }
2352
- .frame(width: w, height: h)
2353
- .contentShape(Rectangle())
2354
- .onTapGesture {
2355
- if NSEvent.modifierFlags.contains(.command) {
2356
- controller.toggleSelection(win.id)
2357
- } else {
2358
- controller.selectSingle(win.id)
2359
- }
2360
- }
2361
- .overlay {
2362
- if isSelected && w > 30 && h > 20 {
2363
- resizeHandles(width: w, height: h)
2364
- }
2365
- }
2366
- .onHover { isHovering in
2367
- hoveredWindowId = isHovering ? win.id : (hoveredWindowId == win.id ? nil : hoveredWindowId)
2368
- }
2369
- .overlay {
2370
- if isSearchHighlighted {
2371
- RoundedRectangle(cornerRadius: 2)
2372
- .strokeBorder(Self.shelfGreen.opacity(0.6), lineWidth: 2)
2373
- .shadow(color: Self.shelfGreen.opacity(0.5), radius: 6)
2374
- }
2375
- }
2376
- .offset(x: rect.minX, y: rect.minY)
2377
- .opacity(isInActiveLayer ? 1.0 : 0.3)
2378
- .shadow(color: isDragging ? Palette.running.opacity(0.4) : .clear,
2379
- radius: isDragging ? 6 : 0)
2380
- }
2381
-
2382
- @ViewBuilder
2383
- private func resizeHandles(width w: CGFloat, height h: CGFloat) -> some View {
2384
- let dotSize: CGFloat = 5
2385
- let barW: CGFloat = 8
2386
- let barH: CGFloat = 3
2387
- let handleColor = Palette.running.opacity(0.7)
2388
- let halfDot = dotSize / 2
2389
-
2390
- ZStack {
2391
- // Corner dots
2392
- Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
2393
- .position(x: halfDot, y: halfDot)
2394
- Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
2395
- .position(x: w - halfDot, y: halfDot)
2396
- Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
2397
- .position(x: halfDot, y: h - halfDot)
2398
- Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
2399
- .position(x: w - halfDot, y: h - halfDot)
2400
-
2401
- // Edge midpoint bars
2402
- if w > 50 {
2403
- RoundedRectangle(cornerRadius: 1).fill(handleColor)
2404
- .frame(width: barW, height: barH)
2405
- .position(x: w / 2, y: 1.5)
2406
- RoundedRectangle(cornerRadius: 1).fill(handleColor)
2407
- .frame(width: barW, height: barH)
2408
- .position(x: w / 2, y: h - 1.5)
2409
- }
2410
- if h > 40 {
2411
- RoundedRectangle(cornerRadius: 1).fill(handleColor)
2412
- .frame(width: barH, height: barW)
2413
- .position(x: 1.5, y: h / 2)
2414
- RoundedRectangle(cornerRadius: 1).fill(handleColor)
2415
- .frame(width: barH, height: barW)
2416
- .position(x: w - 1.5, y: h / 2)
2417
- }
2418
- }
2419
- .allowsHitTesting(false)
2420
- }
2421
-
2422
- // MARK: - Canvas Viewport Controls
2423
-
2424
- private func canvasViewportDock(editor: ScreenMapEditorState) -> some View {
2425
- VStack(alignment: .trailing, spacing: 6) {
2426
- HStack(spacing: 4) {
2427
- ForEach(ScreenMapViewportPreset.allCases) { preset in
2428
- canvasViewportPresetPill(preset, isActive: editor.activeViewportPreset == preset)
2429
- }
2430
- }
2431
- canvasZoomControls(editor: editor)
2432
- }
2433
- }
2434
-
2435
- private func canvasViewportPresetPill(_ preset: ScreenMapViewportPreset, isActive: Bool) -> some View {
2436
- Button {
2437
- controller.focusViewportPreset(preset)
2438
- } label: {
2439
- HStack(spacing: 4) {
2440
- Text(preset.keyHint)
2441
- .font(Typo.monoBold(8))
2442
- .foregroundColor(isActive ? Color.black : Palette.textDim)
2443
- Text(preset.shortLabel)
2444
- .font(Typo.monoBold(8))
2445
- .foregroundColor(isActive ? Color.black : Palette.text)
2446
- }
2447
- .padding(.horizontal, 7)
2448
- .padding(.vertical, 4)
2449
- .background(
2450
- RoundedRectangle(cornerRadius: 5)
2451
- .fill(isActive ? Self.shelfGreen.opacity(0.95) : Color(red: 0.1, green: 0.1, blue: 0.11).opacity(0.88))
2452
- .overlay(
2453
- RoundedRectangle(cornerRadius: 5)
2454
- .strokeBorder(isActive ? Self.shelfGreen.opacity(0.95) : Palette.border, lineWidth: 0.5)
2455
- )
2456
- )
2457
- }
2458
- .buttonStyle(.plain)
2459
- }
2460
-
2461
- private func canvasZoomControls(editor: ScreenMapEditorState) -> some View {
2462
- let pct = Int(editor.zoomLevel * 100)
2463
- return HStack(spacing: 0) {
2464
- Button {
2465
- controller.adjustZoom(by: -0.25)
2466
- } label: {
2467
- Image(systemName: "minus")
2468
- .font(.system(size: 9, weight: .medium))
2469
- .frame(width: 22, height: 20)
2470
- .contentShape(Rectangle())
2471
- }
2472
- .buttonStyle(.plain)
2473
-
2474
- Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
2475
-
2476
- Button {
2477
- controller.clearSelection()
2478
- controller.focusViewportPreset(.overview)
2479
- } label: {
2480
- Text("\(pct)%")
2481
- .font(Typo.mono(9))
2482
- .frame(width: 40, height: 20)
2483
- .contentShape(Rectangle())
2484
- }
2485
- .buttonStyle(.plain)
2486
-
2487
- Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
2488
-
2489
- Button {
2490
- controller.adjustZoom(by: 0.25)
2491
- } label: {
2492
- Image(systemName: "plus")
2493
- .font(.system(size: 9, weight: .medium))
2494
- .frame(width: 22, height: 20)
2495
- .contentShape(Rectangle())
2496
- }
2497
- .buttonStyle(.plain)
2498
- }
2499
- .foregroundColor(Palette.textMuted)
2500
- .background(
2501
- RoundedRectangle(cornerRadius: 5)
2502
- .fill(Color(red: 0.1, green: 0.1, blue: 0.11).opacity(0.85))
2503
- .overlay(
2504
- RoundedRectangle(cornerRadius: 5)
2505
- .strokeBorder(Palette.border, lineWidth: 0.5)
2506
- )
2507
- )
2508
- }
2509
-
2510
- private static let shelfGreen = Color(red: 0.18, green: 0.82, blue: 0.48)
2511
-
2512
- // MARK: - Canvas Status Bar
2513
-
2514
- private var canvasStatusBar: some View {
2515
- VStack(spacing: 0) {
2516
- Rectangle().fill(Color.white.opacity(0.04)).frame(height: 0.5)
2517
- HStack(spacing: 6) {
2518
- if let editor = controller.editor {
2519
- Circle().fill(Palette.running).frame(width: 5, height: 5)
2520
- Text("DESKTOP")
2521
- .font(Typo.monoBold(8))
2522
- .foregroundColor(Palette.running)
2523
- Text("·").foregroundColor(Palette.textMuted).font(Typo.mono(7))
2524
- Text("\(editor.renderedCanvasWindows.count) windows")
2525
- .font(Typo.mono(8))
2526
- .foregroundColor(Palette.textDim)
2527
- if let focused = editor.focusedDisplay {
2528
- Text("·").foregroundColor(Palette.textMuted).font(Typo.mono(7))
2529
- Text(focused.label)
2530
- .font(Typo.mono(8))
2531
- .foregroundColor(Palette.textMuted)
2532
- .lineLimit(1)
2533
- }
2534
- Spacer()
2535
- let editCount = editor.windows.filter { $0.hasEdits }.count
2536
- if editCount > 0 {
2537
- Text("\(editCount) pending")
2538
- .font(Typo.mono(7))
2539
- .foregroundColor(Color.orange.opacity(0.7))
2540
- }
2541
- if let ref = editor.lastActionRef {
2542
- Text(ref)
2543
- .font(Typo.monoBold(8))
2544
- .foregroundColor(Self.shelfGreen.opacity(0.6))
2545
- }
2546
- }
2547
- }
2548
- .padding(.horizontal, 10)
2549
- .padding(.vertical, 4)
2550
- }
2551
- .background(Color(red: 0.08, green: 0.08, blue: 0.09))
2552
- }
2553
-
2554
- // MARK: - Footer Bar
2555
-
2556
- // MARK: - Status Bar
2557
-
2558
- private var footerBar: some View {
2559
- VStack(spacing: 0) {
2560
- Rectangle().fill(Palette.borderLit).frame(height: 0.5)
2561
- HStack(spacing: 0) {
2562
- // Left: server health + settings
2563
- HStack(spacing: 6) {
2564
- Circle()
2565
- .fill(daemon.isListening ? Palette.running : Palette.kill)
2566
- .frame(width: 6, height: 6)
2567
- if daemon.isListening {
2568
- Text("Serving")
2569
- .font(Typo.monoBold(9))
2570
- .foregroundColor(Palette.running.opacity(0.8))
2571
- Text(":9399")
2572
- .font(Typo.mono(9))
2573
- .foregroundColor(Palette.textMuted)
2574
- if daemon.clientCount > 0 {
2575
- Text("·")
2576
- .foregroundColor(Palette.textMuted)
2577
- Text("\(daemon.clientCount) client\(daemon.clientCount == 1 ? "" : "s")")
2578
- .font(Typo.mono(9))
2579
- .foregroundColor(Palette.textDim)
2580
- }
2581
- } else {
2582
- Text("Offline")
2583
- .font(Typo.monoBold(9))
2584
- .foregroundColor(Palette.kill.opacity(0.7))
2585
- }
2586
-
2587
- Text("·").foregroundColor(Palette.textMuted)
2588
-
2589
- statusBarButton(icon: "gearshape", label: "Settings") {
2590
- onNavigate?(.settings)
2591
- }
2592
- }
2593
-
2594
- Spacer()
2595
- if let editor = controller.editor {
2596
- if editor.pendingEditCount > 0 {
2597
- Button {
2598
- controller.applyEditsFromButton()
2599
- } label: {
2600
- HStack(spacing: 4) {
2601
- Text("↩")
2602
- .font(Typo.monoBold(9))
2603
- .foregroundColor(Self.shelfGreen)
2604
- Text("\(editor.pendingEditCount) pending")
2605
- .font(Typo.monoBold(9))
2606
- .foregroundColor(Color.orange.opacity(0.8))
2607
- }
2608
- }
2609
- .buttonStyle(.plain)
2610
- .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
2611
- }
2612
- if let ref = editor.lastActionRef {
2613
- Text(ref)
2614
- .font(Typo.monoBold(8))
2615
- .foregroundColor(Self.shelfGreen.opacity(0.6))
2616
- }
2617
- }
2618
- Spacer()
2619
-
2620
- // Quick keyboard hints
2621
- HStack(spacing: 6) {
2622
- if !controller.selectedWindowIds.isEmpty {
2623
- footerHint("⌘↩", label: "show")
2624
- }
2625
- footerHint("/", label: "search")
2626
- footerHint("q", label: "quit")
2627
- }
2628
- .padding(.trailing, 8)
2629
-
2630
- // Right: docs + logs
2631
- HStack(spacing: 10) {
2632
- statusBarButton(icon: "terminal", label: piChat.isVisible ? "Hide Pi" : "Pi") {
2633
- withAnimation(.easeOut(duration: 0.16)) {
2634
- piChat.toggleVisibility()
2635
- }
2636
- }
2637
- statusBarButton(icon: "book", label: "Docs") {
2638
- onNavigate?(.docs)
2639
- }
2640
- statusBarButton(icon: "text.alignleft", label: "Logs") {
2641
- DiagnosticWindow.shared.toggle()
2642
- }
2643
- }
2644
- }
2645
- .padding(.horizontal, 10)
2646
- .padding(.vertical, 4)
2647
- }
2648
- .background(Color(red: 0.08, green: 0.08, blue: 0.09))
2649
- }
2650
-
2651
- private func statusBarButton(icon: String, label: String, action: @escaping () -> Void) -> some View {
2652
- Button(action: action) {
2653
- HStack(spacing: 4) {
2654
- Image(systemName: icon)
2655
- .font(.system(size: 9))
2656
- Text(label)
2657
- .font(Typo.mono(9))
2658
- }
2659
- .foregroundColor(Palette.textMuted)
2660
- .contentShape(Rectangle())
2661
- }
2662
- .buttonStyle(.plain)
2663
- .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
2664
- }
2665
-
2666
- private func chordHint(key: String, label: String) -> some View {
2667
- HStack(spacing: 4) {
2668
- Text(key)
2669
- .font(Typo.mono(9))
2670
- .foregroundColor(Palette.text)
2671
- .padding(.horizontal, 4)
2672
- .padding(.vertical, 2)
2673
- .background(
2674
- RoundedRectangle(cornerRadius: 3)
2675
- .fill(Palette.surface)
2676
- .overlay(
2677
- RoundedRectangle(cornerRadius: 3)
2678
- .strokeBorder(Palette.border, lineWidth: 0.5)
2679
- )
2680
- )
2681
- Text(label)
2682
- .font(Typo.mono(9))
2683
- .foregroundColor(Palette.textMuted)
2684
- }
2685
- }
2686
-
2687
- // MARK: - Sidebar Mini-Map
2688
-
2689
- @ViewBuilder
2690
- private func sidebarMiniMap(editor: ScreenMapEditorState) -> some View {
2691
- let displays = editor.displays
2692
- let windows = editor.renderedCanvasWindows
2693
- let miniW: CGFloat = sidebarWidth - 28
2694
- let miniH: CGFloat = 118
2695
- let metrics = MiniMapMetrics(
2696
- worldBounds: editor.canvasWorldBounds,
2697
- canvasSize: CGSize(width: miniW, height: miniH)
2698
- )
2699
-
2700
- sidebarPanel {
2701
- VStack(alignment: .leading, spacing: 6) {
2702
- HStack(spacing: 6) {
2703
- Text("MAP")
2704
- .font(Typo.monoBold(8))
2705
- .foregroundColor(Palette.textMuted)
2706
- Spacer()
2707
- Text("drag to pan")
2708
- .font(Typo.mono(7))
2709
- .foregroundColor(Palette.textMuted)
2710
- }
2711
-
2712
- ZStack(alignment: .topLeading) {
2713
- RoundedRectangle(cornerRadius: 6)
2714
- .fill(Color.black.opacity(0.28))
2715
-
2716
- ZStack(alignment: .topLeading) {
2717
- RoundedRectangle(cornerRadius: 5)
2718
- .fill(Palette.bg.opacity(0.35))
2719
- .frame(width: metrics.drawSize.width, height: metrics.drawSize.height)
2720
- .offset(x: metrics.offset.x, y: metrics.offset.y)
2721
-
2722
- ForEach(displays, id: \.index) { disp in
2723
- let rect = metrics.rect(for: disp.cgRect, minimumSize: 12)
2724
- let isFocused = editor.focusedDisplayIndex == nil || editor.focusedDisplayIndex == disp.index
2725
-
2726
- RoundedRectangle(cornerRadius: 3)
2727
- .fill(isFocused ? Color.white.opacity(0.05) : Color.white.opacity(0.02))
2728
- .overlay(
2729
- RoundedRectangle(cornerRadius: 3)
2730
- .strokeBorder(
2731
- editor.focusedDisplayIndex == disp.index ? Palette.running.opacity(0.55) : Color.white.opacity(0.12),
2732
- lineWidth: editor.focusedDisplayIndex == disp.index ? 1 : 0.5
2733
- )
2734
- )
2735
- .frame(width: rect.width, height: rect.height)
2736
- .offset(x: rect.minX, y: rect.minY)
2737
- }
2738
-
2739
- ForEach(Array(windows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
2740
- let rect = metrics.rect(for: win.virtualFrame, minimumSize: 2)
2741
- let isSelected = controller.selectedWindowIds.contains(win.id)
2742
-
2743
- RoundedRectangle(cornerRadius: 1.5)
2744
- .fill((isSelected ? Palette.running : Self.layerColor(for: win.layer)).opacity(isSelected ? 0.35 : 0.18))
2745
- .overlay(
2746
- RoundedRectangle(cornerRadius: 1.5)
2747
- .strokeBorder(isSelected ? Palette.running.opacity(0.85) : Color.white.opacity(0.12), lineWidth: isSelected ? 1 : 0.5)
2748
- )
2749
- .frame(width: rect.width, height: rect.height)
2750
- .offset(x: rect.minX, y: rect.minY)
2751
- }
2752
-
2753
- let viewportRect = metrics.rect(for: editor.viewportWorldRect, minimumSize: 12)
2754
-
2755
- RoundedRectangle(cornerRadius: 4)
2756
- .strokeBorder(Palette.running.opacity(0.9), lineWidth: 1.25)
2757
- .background(
2758
- RoundedRectangle(cornerRadius: 4)
2759
- .fill(Palette.running.opacity(0.08))
2760
- )
2761
- .frame(width: viewportRect.width, height: viewportRect.height)
2762
- .offset(x: viewportRect.minX, y: viewportRect.minY)
2763
- }
2764
- }
2765
- .frame(width: miniW, height: miniH)
2766
- .clipShape(RoundedRectangle(cornerRadius: 6))
2767
- .contentShape(Rectangle())
2768
- .gesture(
2769
- DragGesture(minimumDistance: 0)
2770
- .onChanged { value in
2771
- controller.recenterViewport(at: metrics.worldPoint(for: value.location))
2772
- }
2773
- )
2774
-
2775
- HStack(spacing: 6) {
2776
- mapScopePill("ALL", isActive: editor.focusedDisplayIndex == nil) {
2777
- controller.clearSelection()
2778
- controller.focusViewportPreset(.overview)
2779
- }
2780
- ForEach(editor.spatialDisplayOrder, id: \.index) { disp in
2781
- mapScopePill("\(editor.spatialNumber(for: disp.index))", isActive: editor.focusedDisplayIndex == disp.index) {
2782
- controller.focusCanvas(
2783
- on: editor.displayRegion(for: disp.index)?.rect ?? disp.cgRect,
2784
- focusDisplay: disp.index,
2785
- zoomToFit: true
2786
- )
2787
- }
2788
- }
2789
- }
2790
- }
2791
- }
2792
- }
2793
-
2794
- private func canvasExplorer(editor: ScreenMapEditorState) -> some View {
2795
- let regions = editor.canvasExplorerRegions
2796
-
2797
- return VStack(alignment: .leading, spacing: 4) {
2798
- ForEach(regions.prefix(8)) { region in
2799
- canvasExplorerRow(region: region)
2800
- }
2801
- }
2802
- }
2803
-
2804
- private func canvasExplorerRow(region: ScreenMapCanvasRegion) -> some View {
2805
- let tint: Color = {
2806
- switch region.kind {
2807
- case .overview: return Palette.running
2808
- case .display: return Color.blue.opacity(0.8)
2809
- case .layer: return Self.layerColor(for: region.layer ?? 0)
2810
- }
2811
- }()
2812
-
2813
- return Button {
2814
- controller.jumpToCanvasRegion(region)
2815
- controller.flash(region.title)
2816
- } label: {
2817
- HStack(spacing: 6) {
2818
- Circle()
2819
- .fill(tint)
2820
- .frame(width: 6, height: 6)
2821
- VStack(alignment: .leading, spacing: 1) {
2822
- Text(region.title)
2823
- .font(Typo.monoBold(8))
2824
- .foregroundColor(Palette.text)
2825
- .lineLimit(1)
2826
- Text(region.subtitle)
2827
- .font(Typo.mono(7))
2828
- .foregroundColor(Palette.textMuted)
2829
- .lineLimit(1)
2830
- }
2831
- Spacer()
2832
- Text("\(region.count)")
2833
- .font(Typo.mono(7))
2834
- .foregroundColor(Palette.textDim)
2835
- }
2836
- .padding(.horizontal, 6)
2837
- .padding(.vertical, 4)
2838
- .background(
2839
- RoundedRectangle(cornerRadius: 4)
2840
- .fill(tint.opacity(0.08))
2841
- .overlay(
2842
- RoundedRectangle(cornerRadius: 4)
2843
- .strokeBorder(tint.opacity(0.18), lineWidth: 0.5)
2844
- )
2845
- )
2846
- }
2847
- .buttonStyle(.plain)
2848
- }
2849
-
2850
- private func mapScopePill(_ label: String, isActive: Bool, action: @escaping () -> Void) -> some View {
2851
- Button(action: action) {
2852
- Text(label)
2853
- .font(Typo.monoBold(7))
2854
- .foregroundColor(isActive ? Palette.running : Palette.textDim)
2855
- .padding(.horizontal, 6)
2856
- .padding(.vertical, 3)
2857
- .background(
2858
- RoundedRectangle(cornerRadius: 4)
2859
- .fill(isActive ? Palette.running.opacity(0.12) : Palette.surface.opacity(0.7))
2860
- .overlay(
2861
- RoundedRectangle(cornerRadius: 4)
2862
- .strokeBorder(isActive ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
2863
- )
2864
- )
2865
- }
2866
- .buttonStyle(.plain)
2867
- }
2868
-
2869
- // MARK: - Flash Overlay
2870
-
2871
- @ViewBuilder
2872
- private var flashOverlay: some View {
2873
- if let msg = controller.flashMessage {
2874
- VStack {
2875
- Spacer()
2876
- HStack(spacing: 6) {
2877
- Image(systemName: "rectangle.3.group")
2878
- .font(.system(size: 11))
2879
- Text(msg)
2880
- .font(Typo.monoBold(11))
2881
- }
2882
- .foregroundColor(Palette.text)
2883
- .padding(.horizontal, 14)
2884
- .padding(.vertical, 8)
2885
- .background(
2886
- RoundedRectangle(cornerRadius: 8, style: .continuous)
2887
- .fill(Palette.surface)
2888
- .overlay(
2889
- RoundedRectangle(cornerRadius: 8, style: .continuous)
2890
- .strokeBorder(Palette.running.opacity(0.3), lineWidth: 0.5)
2891
- )
2892
- .shadow(color: .black.opacity(0.2), radius: 8, y: 2)
2893
- )
2894
- .padding(.bottom, 60)
2895
- }
2896
- .transition(.opacity.combined(with: .move(edge: .bottom)))
2897
- .animation(.easeOut(duration: 0.2), value: controller.flashMessage)
2898
- .allowsHitTesting(false)
2899
- }
2900
- }
2901
-
2902
- private var divider: some View {
2903
- Rectangle()
2904
- .fill(Palette.border)
2905
- .frame(height: 0.5)
2906
- }
2907
-
2908
- // MARK: - Helpers
2909
-
2910
- @ViewBuilder
2911
- private func sidebarPanel<Content: View>(@ViewBuilder content: () -> Content) -> some View {
2912
- content()
2913
- .padding(6)
2914
- .background(
2915
- RoundedRectangle(cornerRadius: 6)
2916
- .fill(Color.black.opacity(0.4))
2917
- .overlay(
2918
- RoundedRectangle(cornerRadius: 6)
2919
- .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
2920
- )
2921
- )
2922
- }
2923
-
2924
- private func syncCanvasGeometry(editor: ScreenMapEditorState?, metrics: CanvasMetrics) {
2925
- let previous = editor.map {
2926
- (
2927
- fitScale: $0.fitScale,
2928
- scale: $0.scale,
2929
- mapOrigin: $0.mapOrigin,
2930
- viewportSize: $0.viewportSize,
2931
- screenSize: $0.screenSize,
2932
- bboxOrigin: $0.bboxOrigin
2933
- )
2934
- }
2935
- editor?.fitScale = metrics.fitScale
2936
- editor?.scale = metrics.effectiveScale
2937
- editor?.mapOrigin = metrics.centerOffset
2938
- editor?.viewportSize = metrics.syncedViewportSize
2939
- editor?.screenSize = metrics.worldBounds.size
2940
- editor?.bboxOrigin = metrics.worldBounds.origin
2941
- controller.applyPendingCanvasNavigationIfNeeded()
2942
- if let editor {
2943
- let boundedPan = boundedPanOffset(editor.panOffset, editor: editor)
2944
- if boundedPan != editor.panOffset {
2945
- editor.panOffset = boundedPan
2946
- }
2947
- }
2948
- if let editor,
2949
- let previous,
2950
- previous.fitScale != editor.fitScale ||
2951
- previous.scale != editor.scale ||
2952
- previous.mapOrigin != editor.mapOrigin ||
2953
- previous.viewportSize != editor.viewportSize ||
2954
- previous.screenSize != editor.screenSize ||
2955
- previous.bboxOrigin != editor.bboxOrigin {
2956
- DispatchQueue.main.async {
2957
- editor.objectWillChange.send()
2958
- controller.objectWillChange.send()
2959
- }
2960
- }
2961
- }
2962
-
2963
- private func boundedPanOffset(_ proposed: CGPoint, editor: ScreenMapEditorState) -> CGPoint {
2964
- guard editor.scale > 0,
2965
- editor.viewportSize.width > 0,
2966
- editor.viewportSize.height > 0,
2967
- editor.screenSize.width > 0,
2968
- editor.screenSize.height > 0 else {
2969
- return proposed
2970
- }
2971
-
2972
- func clampAxis(_ value: CGFloat, mapOrigin: CGFloat, content: CGFloat, viewport: CGFloat) -> CGFloat {
2973
- let effectiveScale = max(editor.fitScale * editor.zoomLevel, editor.scale)
2974
- let mapSize = content * effectiveScale
2975
- let minVisible = min(Self.canvasPanMinVisiblePixels, max(viewport / 2, 0))
2976
- let minValue = minVisible - mapOrigin - mapSize
2977
- let maxValue = viewport - minVisible - mapOrigin
2978
- return min(max(value, minValue), maxValue)
2979
- }
2980
-
2981
- return CGPoint(
2982
- x: clampAxis(proposed.x,
2983
- mapOrigin: editor.mapOrigin.x,
2984
- content: editor.screenSize.width,
2985
- viewport: editor.viewportSize.width),
2986
- y: clampAxis(proposed.y,
2987
- mapOrigin: editor.mapOrigin.y,
2988
- content: editor.screenSize.height,
2989
- viewport: editor.viewportSize.height)
2990
- )
2991
- }
2992
-
2993
- // MARK: - Layer Colors
2994
-
2995
- private static let layerColors: [Color] = [
2996
- .green, .cyan, .orange, .purple, .pink, .yellow, .mint, .indigo
2997
- ]
2998
-
2999
- private static let inspectorLogTimeFormatter: DateFormatter = {
3000
- let formatter = DateFormatter()
3001
- formatter.dateFormat = "HH:mm:ss"
3002
- return formatter
3003
- }()
3004
-
3005
- private static func layerColor(for layer: Int) -> Color {
3006
- layerColors[layer % layerColors.count]
3007
- }
3008
-
3009
- private static func inferTileIcon(for win: ScreenMapWindowEntry, displays: [DisplayGeometry]) -> String? {
3010
- guard let disp = displays.first(where: { $0.index == win.displayIndex }) else { return nil }
3011
- let screenW = disp.cgRect.width
3012
- let screenH = disp.cgRect.height
3013
- let relX = win.virtualFrame.origin.x - disp.cgRect.origin.x
3014
- let relY = win.virtualFrame.origin.y - disp.cgRect.origin.y
3015
- let winW = win.virtualFrame.width
3016
- let winH = win.virtualFrame.height
3017
- let tolerance: CGFloat = 30
3018
-
3019
- for pos in TilePosition.allCases {
3020
- let (fx, fy, fw, fh) = pos.rect
3021
- let expectedX = fx * screenW
3022
- let expectedY = fy * screenH
3023
- let expectedW = fw * screenW
3024
- let expectedH = fh * screenH
3025
- if abs(relX - expectedX) < tolerance && abs(relY - expectedY) < tolerance
3026
- && abs(winW - expectedW) < tolerance && abs(winH - expectedH) < tolerance {
3027
- return pos.icon
3028
- }
3029
- }
3030
- return nil
3031
- }
3032
-
3033
- private static func extractLatticesSession(from title: String) -> String? {
3034
- guard let range = title.range(of: #"\[lattices:([^\]]+)\]"#, options: .regularExpression) else { return nil }
3035
- let match = String(title[range])
3036
- return String(match.dropFirst(9).dropLast(1))
3037
- }
3038
-
3039
- // MARK: - Layer Preview
3040
-
3041
- private func handlePreviewChange(isPreviewing: Bool) {
3042
- guard isPreviewing, let editor = controller.editor else { return }
3043
- let screens = NSScreen.screens
3044
- guard !screens.isEmpty else { return }
3045
-
3046
- let primaryHeight = screens.first?.frame.height ?? 0
3047
-
3048
- // Scope preview to the focused display's screen, or union of all
3049
- let targetFrame: NSRect
3050
- let cgOrigin: CGPoint
3051
- if let focusedIdx = editor.focusedDisplayIndex, focusedIdx < screens.count {
3052
- let screen = screens[focusedIdx]
3053
- targetFrame = screen.frame
3054
- cgOrigin = CGPoint(x: screen.frame.origin.x,
3055
- y: primaryHeight - screen.frame.maxY)
3056
- } else {
3057
- var union = screens[0].frame
3058
- for screen in screens.dropFirst() { union = union.union(screen.frame) }
3059
- targetFrame = union
3060
- cgOrigin = CGPoint(x: union.origin.x,
3061
- y: primaryHeight - (union.origin.y + union.height))
3062
- }
3063
-
3064
- let visible = editor.focusedVisibleWindows
3065
- let label = editor.layerLabel
3066
- let captures = controller.previewCaptures
3067
-
3068
- let overlay = ScreenMapPreviewOverlay(
3069
- windows: visible, layerLabel: label, captures: captures,
3070
- screenFrame: targetFrame,
3071
- screenCGOrigin: cgOrigin
3072
- )
3073
- let hostingView = NSHostingView(rootView: overlay)
3074
- controller.showPreviewWindow(contentView: hostingView, frame: targetFrame)
3075
- }
3076
-
3077
- // MARK: - Key Handler
3078
-
3079
- private func installKeyHandler() {
3080
- eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { event in
3081
- // Only handle keys when our window is the key window
3082
- guard let win = ScreenMapWindowController.shared.nsWindow,
3083
- win.isKeyWindow else { return event }
3084
- if isEditableTextResponder(win.firstResponder) {
3085
- return event
3086
- }
3087
- // Track space key for canvas drag-to-pan
3088
- if event.keyCode == 49 && !controller.isSearchActive {
3089
- if event.type == .keyDown {
3090
- if !isSpaceHeld {
3091
- isSpaceHeld = true
3092
- NSCursor.openHand.push()
3093
- }
3094
- return nil
3095
- } else if event.type == .keyUp {
3096
- guard isSpaceHeld else { return nil }
3097
- isSpaceHeld = false
3098
- canvasPanStart = nil
3099
- NSCursor.pop()
3100
- return nil
3101
- }
3102
- }
3103
- guard event.type == .keyDown else { return event }
3104
- let consumed = controller.handleKey(event.keyCode, modifiers: event.modifierFlags)
3105
- return consumed ? nil : event
3106
- }
3107
- }
3108
-
3109
- private func isEditableTextResponder(_ responder: NSResponder?) -> Bool {
3110
- if let textView = responder as? NSTextView {
3111
- return textView.isEditable || textView.isFieldEditor
3112
- }
3113
-
3114
- if let textField = responder as? NSTextField {
3115
- return textField.isEditable
3116
- }
3117
-
3118
- guard let responder else { return false }
3119
- let className = NSStringFromClass(type(of: responder))
3120
- return className.contains("FieldEditor") || className.contains("TextView")
3121
- }
3122
-
3123
- private func removeKeyHandler() {
3124
- if let monitor = eventMonitor {
3125
- NSEvent.removeMonitor(monitor)
3126
- eventMonitor = nil
3127
- }
3128
- }
3129
-
3130
- // MARK: - Mouse Monitors
3131
-
3132
- private func installMouseMonitors() {
3133
- let dragThreshold: CGFloat = 4
3134
-
3135
- mouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { event in
3136
- guard let eventWindow = event.window,
3137
- eventWindow === ScreenMapWindowController.shared.nsWindow else { return event }
3138
- let flippedPt = flippedScreenPoint(event)
3139
- guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else { return event }
3140
-
3141
- // Space+click → begin canvas pan
3142
- if isSpaceHeld,
3143
- let editor = controller.editor {
3144
- canvasPanStart = event.locationInWindow
3145
- canvasPanStartOffset = editor.panOffset
3146
- NSCursor.closedHand.push()
3147
- return nil
3148
- }
3149
-
3150
- if let editor = controller.editor,
3151
- let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor) {
3152
- screenMapClickWindowId = nil
3153
- if NSEvent.modifierFlags.contains(.command) {
3154
- controller.toggleSelection(hit.id)
3155
- } else if !controller.isSelected(hit.id) {
3156
- controller.selectSingle(hit.id)
3157
- }
3158
- canvasPanStart = event.locationInWindow
3159
- canvasPanStartOffset = editor.panOffset
3160
- return nil
3161
- } else {
3162
- screenMapClickWindowId = nil
3163
- }
3164
-
3165
- if let editor = controller.editor {
3166
- canvasPanStart = event.locationInWindow
3167
- canvasPanStartOffset = editor.panOffset
3168
- return nil
3169
- }
3170
-
3171
- return event
3172
- }
3173
-
3174
- mouseDragMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
3175
- // Empty-canvas drag, or Space+drag, pans the viewport.
3176
- if let start = canvasPanStart, let editor = controller.editor {
3177
- let dx = event.locationInWindow.x - start.x
3178
- let dy = event.locationInWindow.y - start.y
3179
- let bounded = boundedPanOffset(
3180
- CGPoint(x: canvasPanStartOffset.x + dx, y: canvasPanStartOffset.y - dy),
3181
- editor: editor
3182
- )
3183
- if bounded != editor.panOffset {
3184
- editor.activeViewportPreset = nil
3185
- editor.panOffset = bounded
3186
- }
3187
- return nil
3188
- }
3189
-
3190
- guard let hitId = screenMapClickWindowId,
3191
- let editor = controller.editor else { return event }
3192
- let dx = event.locationInWindow.x - screenMapClickPoint.x
3193
- let dy = event.locationInWindow.y - screenMapClickPoint.y
3194
- guard sqrt(dx * dx + dy * dy) >= dragThreshold else { return event }
3195
-
3196
- if editor.draggingWindowId != hitId {
3197
- editor.draggingWindowId = hitId
3198
- if let idx = editor.windows.firstIndex(where: { $0.id == hitId }) {
3199
- editor.dragStartFrame = editor.windows[idx].virtualFrame
3200
- }
3201
- controller.selectSingle(hitId)
3202
- }
3203
-
3204
- let effScale = editor.effectiveScale
3205
- guard let startFrame = editor.dragStartFrame,
3206
- effScale > 0,
3207
- let idx = editor.windows.firstIndex(where: { $0.id == hitId }) else { return event }
3208
- let screenDx = dx / effScale
3209
- let screenDy = -dy / effScale // CG coords: Y flipped
3210
- let mode = editor.canvasDragMode
3211
- let minW: CGFloat = 100
3212
- let minH: CGFloat = 50
3213
-
3214
- var newFrame = startFrame
3215
-
3216
- switch mode {
3217
- case .move:
3218
- newFrame.origin.x = startFrame.origin.x + screenDx
3219
- newFrame.origin.y = startFrame.origin.y + screenDy
3220
-
3221
- case .resizeRight:
3222
- newFrame.size.width = max(minW, startFrame.width + screenDx)
3223
- case .resizeLeft:
3224
- let dw = min(screenDx, startFrame.width - minW)
3225
- newFrame.origin.x = startFrame.origin.x + dw
3226
- newFrame.size.width = startFrame.width - dw
3227
- case .resizeBottom:
3228
- newFrame.size.height = max(minH, startFrame.height + screenDy)
3229
- case .resizeTop:
3230
- let dh = min(screenDy, startFrame.height - minH)
3231
- newFrame.origin.y = startFrame.origin.y + dh
3232
- newFrame.size.height = startFrame.height - dh
3233
-
3234
- case .resizeTopLeft:
3235
- let dw = min(screenDx, startFrame.width - minW)
3236
- newFrame.origin.x = startFrame.origin.x + dw
3237
- newFrame.size.width = startFrame.width - dw
3238
- let dh = min(screenDy, startFrame.height - minH)
3239
- newFrame.origin.y = startFrame.origin.y + dh
3240
- newFrame.size.height = startFrame.height - dh
3241
- case .resizeTopRight:
3242
- newFrame.size.width = max(minW, startFrame.width + screenDx)
3243
- let dh = min(screenDy, startFrame.height - minH)
3244
- newFrame.origin.y = startFrame.origin.y + dh
3245
- newFrame.size.height = startFrame.height - dh
3246
- case .resizeBottomLeft:
3247
- let dw = min(screenDx, startFrame.width - minW)
3248
- newFrame.origin.x = startFrame.origin.x + dw
3249
- newFrame.size.width = startFrame.width - dw
3250
- newFrame.size.height = max(minH, startFrame.height + screenDy)
3251
- case .resizeBottomRight:
3252
- newFrame.size.width = max(minW, startFrame.width + screenDx)
3253
- newFrame.size.height = max(minH, startFrame.height + screenDy)
3254
- }
3255
-
3256
- editor.syncLayoutFrame(at: idx, to: newFrame)
3257
- return nil
3258
- }
3259
-
3260
- mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
3261
- // End canvas pan.
3262
- if canvasPanStart != nil {
3263
- canvasPanStart = nil
3264
- if isSpaceHeld {
3265
- NSCursor.pop() // pop closedHand, openHand remains
3266
- }
3267
- return event
3268
- }
3269
- if screenMapClickWindowId != nil {
3270
- if let editor = controller.editor, editor.draggingWindowId != nil {
3271
- editor.draggingWindowId = nil
3272
- editor.dragStartFrame = nil
3273
- editor.canvasDragMode = .move
3274
- }
3275
- screenMapClickWindowId = nil
3276
- }
3277
- return event
3278
- }
3279
-
3280
- rightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { event in
3281
- guard let eventWindow = event.window,
3282
- eventWindow === ScreenMapWindowController.shared.nsWindow,
3283
- let editor = controller.editor else { return event }
3284
-
3285
- let flippedPt = flippedScreenPoint(event)
3286
- guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else { return event }
3287
-
3288
- if let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor) {
3289
- if !controller.isSelected(hit.id) {
3290
- controller.selectSingle(hit.id)
3291
- }
3292
- showLayerContextMenu(for: hit.id, at: event.locationInWindow, in: eventWindow, editor: editor)
3293
- return nil
3294
- }
3295
- return event
3296
- }
3297
-
3298
- scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
3299
- guard let eventWindow = event.window,
3300
- eventWindow === ScreenMapWindowController.shared.nsWindow,
3301
- let editor = controller.editor else { return event }
3302
-
3303
- // Let search overlay handle its own scroll
3304
- if controller.isSearchActive {
3305
- let screenPt = event.locationInWindow
3306
- let windowPt = eventWindow.convertPoint(toScreen: screenPt)
3307
- let flippedY = NSScreen.main.map { $0.frame.height - windowPt.y } ?? windowPt.y
3308
- let testPt = CGPoint(x: windowPt.x, y: flippedY)
3309
- if searchOverlayFrame.contains(testPt) {
3310
- return event // pass to SwiftUI ScrollView
3311
- }
3312
- }
3313
-
3314
- let flippedPt = flippedScreenPoint(event)
3315
- guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else { return event }
3316
-
3317
- let isZoom = event.modifierFlags.contains(.command)
3318
-
3319
- if isZoom {
3320
- let zoomDelta: CGFloat = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY * 0.01 : event.scrollingDeltaY * 0.05
3321
- let oldZoom = editor.zoomLevel
3322
- let newZoom = max(ScreenMapEditorState.minZoom, min(ScreenMapEditorState.maxZoom, oldZoom + zoomDelta))
3323
- guard newZoom != oldZoom else { return nil }
3324
-
3325
- let canvasLocal = CGPoint(
3326
- x: flippedPt.x - screenMapCanvasOrigin.x,
3327
- y: flippedPt.y - screenMapCanvasOrigin.y
3328
- )
3329
- let canvasCenterX = screenMapCanvasSize.width / 2
3330
- let canvasCenterY = screenMapCanvasSize.height / 2
3331
- let cursorFromCenter = CGPoint(
3332
- x: canvasLocal.x - canvasCenterX,
3333
- y: canvasLocal.y - canvasCenterY
3334
- )
3335
-
3336
- let ratio = newZoom / oldZoom
3337
- let newPanX = cursorFromCenter.x - ratio * (cursorFromCenter.x - editor.panOffset.x)
3338
- let newPanY = cursorFromCenter.y - ratio * (cursorFromCenter.y - editor.panOffset.y)
3339
-
3340
- editor.activeViewportPreset = nil
3341
- editor.zoomLevel = newZoom
3342
- editor.scale = editor.fitScale * newZoom
3343
- let bounded = boundedPanOffset(CGPoint(x: newPanX, y: newPanY), editor: editor)
3344
- if bounded != editor.panOffset {
3345
- editor.activeViewportPreset = nil
3346
- editor.panOffset = bounded
3347
- }
3348
- } else {
3349
- guard editor.zoomLevel > 1.0001 else {
3350
- if editor.panOffset != .zero {
3351
- editor.panOffset = .zero
3352
- }
3353
- return nil
3354
- }
3355
- let bounded = boundedPanOffset(
3356
- CGPoint(
3357
- x: editor.panOffset.x + event.scrollingDeltaX,
3358
- y: editor.panOffset.y - event.scrollingDeltaY
3359
- ),
3360
- editor: editor
3361
- )
3362
- if bounded != editor.panOffset {
3363
- editor.activeViewportPreset = nil
3364
- editor.panOffset = bounded
3365
- }
3366
- }
3367
- return nil
3368
- }
3369
-
3370
- mouseMovedMonitor = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved) { event in
3371
- guard let eventWindow = event.window,
3372
- eventWindow === ScreenMapWindowController.shared.nsWindow,
3373
- let editor = controller.editor else {
3374
- resetCursorIfNeeded()
3375
- return event
3376
- }
3377
-
3378
- let flippedPt = flippedScreenPoint(event)
3379
- guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else {
3380
- resetCursorIfNeeded()
3381
- return event
3382
- }
3383
-
3384
- if let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor) {
3385
- let mode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
3386
- if mode != editor.currentCursorMode {
3387
- if editor.currentCursorMode != .move { NSCursor.pop() }
3388
- editor.currentCursorMode = mode
3389
- switch mode {
3390
- case .resizeLeft, .resizeRight:
3391
- NSCursor.resizeLeftRight.push()
3392
- case .resizeTop, .resizeBottom:
3393
- NSCursor.resizeUpDown.push()
3394
- case .resizeTopLeft, .resizeTopRight, .resizeBottomLeft, .resizeBottomRight:
3395
- NSCursor.crosshair.push()
3396
- case .move:
3397
- break
3398
- }
3399
- }
3400
- } else {
3401
- resetCursorIfNeeded()
3402
- }
3403
- return event
3404
- }
3405
- }
3406
-
3407
- private func resetCursorIfNeeded() {
3408
- guard let editor = controller.editor else { return }
3409
- if editor.currentCursorMode != .move {
3410
- NSCursor.pop()
3411
- editor.currentCursorMode = .move
3412
- }
3413
- }
3414
-
3415
- private func removeMouseMonitors() {
3416
- if let m = mouseDownMonitor { NSEvent.removeMonitor(m); mouseDownMonitor = nil }
3417
- if let m = mouseDragMonitor { NSEvent.removeMonitor(m); mouseDragMonitor = nil }
3418
- if let m = mouseUpMonitor { NSEvent.removeMonitor(m); mouseUpMonitor = nil }
3419
- if let m = rightClickMonitor { NSEvent.removeMonitor(m); rightClickMonitor = nil }
3420
- if let m = scrollWheelMonitor { NSEvent.removeMonitor(m); scrollWheelMonitor = nil }
3421
- if let m = mouseMovedMonitor { NSEvent.removeMonitor(m); mouseMovedMonitor = nil }
3422
- resetCursorIfNeeded()
3423
- }
3424
-
3425
- // MARK: - Hit Test / Coordinate Conversion
3426
-
3427
- private func canvasHit(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> CanvasHit? {
3428
- guard isCanvasPoint(flippedScreenPt) else { return nil }
3429
- let projection = CanvasProjection(editor: editor)
3430
- guard projection.scale > 0 else { return nil }
3431
- let canvasLocal = CGPoint(
3432
- x: flippedScreenPt.x - screenMapCanvasOrigin.x,
3433
- y: flippedScreenPt.y - screenMapCanvasOrigin.y
3434
- )
3435
- let mapPoint = projection.mapPoint(forCanvasPoint: canvasLocal)
3436
- let sorted = editor.renderedCanvasWindows.sorted(by: { $0.zIndex < $1.zIndex })
3437
- for win in sorted {
3438
- let mapRect = projection.mapRect(for: win.virtualFrame)
3439
- if mapRect.contains(mapPoint) {
3440
- return CanvasHit(id: win.id, mapRect: mapRect, mapPoint: mapPoint)
3441
- }
3442
- }
3443
- return nil
3444
- }
3445
-
3446
- private func isCanvasPoint(_ point: CGPoint) -> Bool {
3447
- CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize).contains(point)
3448
- }
3449
-
3450
- private func isCanvasInteractionEvent(_ event: NSEvent, flippedPoint: CGPoint) -> Bool {
3451
- guard isCanvasPoint(flippedPoint),
3452
- let contentWidth = event.window?.contentView?.bounds.width else {
3453
- return false
3454
- }
3455
-
3456
- let x = event.locationInWindow.x
3457
- let leftBoundary = sidebarWidth + 8
3458
- let rightBoundary = max(leftBoundary, contentWidth - inspectorWidth - 8)
3459
- return x >= leftBoundary && x <= rightBoundary
3460
- }
3461
-
3462
- private func flippedScreenPoint(_ event: NSEvent) -> CGPoint {
3463
- guard let nsWindow = event.window else { return .zero }
3464
- let loc = event.locationInWindow
3465
- let windowHeight = nsWindow.contentView?.frame.height ?? nsWindow.frame.height
3466
- return CGPoint(x: loc.x, y: windowHeight - loc.y)
3467
- }
3468
-
3469
- private func detectDragMode(mapPoint: CGPoint, windowMapRect: CGRect) -> CanvasDragMode {
3470
- let w = windowMapRect.width
3471
- let h = windowMapRect.height
3472
- let threshold = max(4, min(8, min(w, h) * 0.25))
3473
-
3474
- let nearLeft = mapPoint.x - windowMapRect.minX < threshold
3475
- let nearRight = windowMapRect.maxX - mapPoint.x < threshold
3476
- let nearTop = mapPoint.y - windowMapRect.minY < threshold
3477
- let nearBottom = windowMapRect.maxY - mapPoint.y < threshold
3478
-
3479
- // Corners take priority
3480
- if nearTop && nearLeft { return .resizeTopLeft }
3481
- if nearTop && nearRight { return .resizeTopRight }
3482
- if nearBottom && nearLeft { return .resizeBottomLeft }
3483
- if nearBottom && nearRight { return .resizeBottomRight }
3484
-
3485
- // Edges
3486
- if nearLeft { return .resizeLeft }
3487
- if nearRight { return .resizeRight }
3488
- if nearTop { return .resizeTop }
3489
- if nearBottom { return .resizeBottom }
3490
-
3491
- return .move
3492
- }
3493
-
3494
- // MARK: - Context Menu
3495
-
3496
- private func showLayerContextMenu(for windowId: UInt32, at point: NSPoint, in window: NSWindow, editor: ScreenMapEditorState) {
3497
- guard let winIdx = editor.windows.firstIndex(where: { $0.id == windowId }) else { return }
3498
- let win = editor.windows[winIdx]
3499
- let currentLayer = win.layer
3500
-
3501
- let menu = NSMenu()
3502
- let header = NSMenuItem(title: "\(win.app) — Layer \(currentLayer)", action: nil, keyEquivalent: "")
3503
- header.isEnabled = false
3504
- menu.addItem(header)
3505
- menu.addItem(.separator())
3506
-
3507
- // Focus window on screen
3508
- let focusItem = NSMenuItem(title: "Show on Screen ⌘↩", action: nil, keyEquivalent: "")
3509
- focusItem.representedObject = ScreenMapFocusMenuAction(windowId: windowId, controller: controller)
3510
- focusItem.action = #selector(ScreenMapMenuTarget.performFocus(_:))
3511
- focusItem.target = ScreenMapMenuTarget.shared
3512
- menu.addItem(focusItem)
3513
-
3514
- menu.addItem(.separator())
3515
-
3516
- // Move to Layer → submenu
3517
- let moveItem = NSMenuItem(title: "Move to Layer", action: nil, keyEquivalent: "")
3518
- let layerSubmenu = NSMenu()
3519
-
3520
- for layer in editor.effectiveLayers where layer != currentLayer {
3521
- let name = editor.layerDisplayName(for: layer)
3522
- let count = editor.effectiveWindowCount(for: layer)
3523
- let item = NSMenuItem(title: "\(name) (\(count) windows)", action: nil, keyEquivalent: "")
3524
- item.representedObject = ScreenMapLayerMenuAction(windowId: windowId, targetLayer: layer, editor: editor, controller: controller)
3525
- item.action = #selector(ScreenMapMenuTarget.performLayerMove(_:))
3526
- item.target = ScreenMapMenuTarget.shared
3527
- layerSubmenu.addItem(item)
3528
- }
3529
-
3530
- layerSubmenu.addItem(.separator())
3531
- let newLayerItem = NSMenuItem(title: "New Layer", action: nil, keyEquivalent: "")
3532
- newLayerItem.representedObject = ScreenMapLayerMenuAction(windowId: windowId, targetLayer: editor.layerCount, editor: editor, controller: controller)
3533
- newLayerItem.action = #selector(ScreenMapMenuTarget.performLayerMove(_:))
3534
- newLayerItem.target = ScreenMapMenuTarget.shared
3535
- layerSubmenu.addItem(newLayerItem)
3536
-
3537
- moveItem.submenu = layerSubmenu
3538
- menu.addItem(moveItem)
3539
-
3540
- // Convert window coordinates to contentView coordinates for correct menu positioning
3541
- let menuPoint: NSPoint
3542
- if let contentView = window.contentView {
3543
- menuPoint = contentView.convert(point, from: nil)
3544
- } else {
3545
- menuPoint = point
3546
- }
3547
- menu.popUp(positioning: nil, at: menuPoint, in: window.contentView)
3548
- }
3549
- }
3550
-
3551
- // MARK: - Context Menu Helpers
3552
-
3553
- struct ScreenMapLayerMenuAction {
3554
- let windowId: UInt32
3555
- let targetLayer: Int
3556
- let editor: ScreenMapEditorState
3557
- let controller: ScreenMapController
3558
- }
3559
-
3560
- struct ScreenMapFocusMenuAction {
3561
- let windowId: UInt32
3562
- let controller: ScreenMapController
3563
- }
3564
-
3565
- final class ScreenMapMenuTarget: NSObject {
3566
- static let shared = ScreenMapMenuTarget()
3567
-
3568
- @objc func performLayerMove(_ sender: NSMenuItem) {
3569
- guard let action = sender.representedObject as? ScreenMapLayerMenuAction else { return }
3570
- action.editor.reassignLayer(windowId: action.windowId, toLayer: action.targetLayer, fitToAvailable: true)
3571
- }
3572
-
3573
- @objc func performFocus(_ sender: NSMenuItem) {
3574
- guard let action = sender.representedObject as? ScreenMapFocusMenuAction else { return }
3575
- action.controller.focusWindowOnScreen(action.windowId)
3576
- }
3577
- }
3578
-
3579
- // MARK: - Preview Overlay
3580
-
3581
- struct ScreenMapPreviewOverlay: View {
3582
- let windows: [ScreenMapWindowEntry]
3583
- let layerLabel: String
3584
- let captures: [UInt32: NSImage]
3585
- let screenFrame: CGRect
3586
- let screenCGOrigin: CGPoint
3587
-
3588
- private static let layerColors: [Color] = [
3589
- .green, .cyan, .orange, .purple, .pink, .yellow, .mint, .indigo
3590
- ]
3591
-
3592
- var body: some View {
3593
- ZStack(alignment: .topLeading) {
3594
- Color.black.opacity(0.88)
3595
-
3596
- ForEach(windows) { win in
3597
- let f = win.virtualFrame
3598
- let x = f.origin.x - screenCGOrigin.x
3599
- let y = f.origin.y - screenCGOrigin.y
3600
- let w = f.width
3601
- let h = f.height
3602
- let color = Self.layerColors[win.layer % Self.layerColors.count]
3603
-
3604
- ZStack {
3605
- RoundedRectangle(cornerRadius: 6)
3606
- .fill(color.opacity(0.12))
3607
- RoundedRectangle(cornerRadius: 6)
3608
- .strokeBorder(color.opacity(0.7), lineWidth: 2)
3609
-
3610
- VStack(spacing: 4) {
3611
- Text(win.app)
3612
- .font(.system(size: 13, weight: .bold, design: .monospaced))
3613
- .foregroundColor(.white)
3614
- if !win.title.isEmpty && h > 60 {
3615
- Text(win.title)
3616
- .font(.system(size: 10, design: .monospaced))
3617
- .foregroundColor(.white.opacity(0.6))
3618
- .lineLimit(1)
3619
- }
3620
- if h > 40 {
3621
- Text("\(Int(w)) × \(Int(h))")
3622
- .font(.system(size: 11, weight: .medium, design: .monospaced))
3623
- .foregroundColor(color.opacity(0.7))
3624
- }
3625
- if win.hasEdits && h > 80 {
3626
- Text("L\(win.layer)")
3627
- .font(.system(size: 9, weight: .medium, design: .monospaced))
3628
- .foregroundColor(color.opacity(0.5))
3629
- }
3630
- }
3631
- .padding(8)
3632
- }
3633
- .shadow(color: color.opacity(0.3), radius: 8)
3634
- .frame(width: w, height: h)
3635
- .offset(x: x, y: y)
3636
- }
3637
-
3638
- VStack {
3639
- Spacer()
3640
- HStack {
3641
- Spacer()
3642
- Text("\(layerLabel) • \(windows.count) windows • click or press any key to dismiss")
3643
- .font(.system(size: 14, weight: .bold, design: .monospaced))
3644
- .foregroundColor(.white)
3645
- .padding(.horizontal, 16)
3646
- .padding(.vertical, 8)
3647
- .background(Color.black.opacity(0.7))
3648
- .cornerRadius(8)
3649
- .padding(20)
3650
- Spacer()
3651
- }
3652
- }
3653
- }
3654
- .frame(width: screenFrame.width, height: screenFrame.height)
3655
- }
3656
- }
3657
-
3658
- // MARK: - Layer Row Frame Preference Key
3659
-
3660
- private struct LayerRowFrameKey: PreferenceKey {
3661
- static var defaultValue: [Int: CGRect] = [:]
3662
- static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
3663
- value.merge(nextValue(), uniquingKeysWith: { _, new in new })
3664
- }
3665
- }
3666
-
3667
- // MARK: - Show on Screen Bezel
3668
-
3669
- struct ShowOnScreenBezelView: View {
3670
- let appName: String
3671
- let windowTitle: String
3672
- let displayName: String
3673
- let displayNumber: Int
3674
- let layerName: String
3675
- let windowSize: String
3676
- let windowsOnDisplay: Int
3677
- let layersOnDisplay: Int
3678
- let windowLocalFrame: CGRect // NS coordinates relative to tight window
3679
- let screenSize: CGSize // tight window size (not full screen)
3680
- let labelPlacement: LabelPlacement
3681
- let flush: FlushEdges
3682
- let windowSnapshot: NSImage? // pre-captured window content for screenshot tools
3683
-
3684
- enum LabelPlacement { case below, above, right, left }
3685
-
3686
- /// Which edges of the window are flush with the screen boundary
3687
- struct FlushEdges {
3688
- let top: Bool
3689
- let bottom: Bool
3690
- let left: Bool
3691
- let right: Bool
3692
- static let none = FlushEdges(top: false, bottom: false, left: false, right: false)
3693
- }
3694
-
3695
- // Inverted from OS appearance so bezel contrasts with desktop:
3696
- // Dark mode desktop → light bezel, Light mode desktop → dark bezel
3697
- @Environment(\.colorScheme) private var colorScheme
3698
-
3699
- private let accent = Color(red: 0.13, green: 0.62, blue: 0.38)
3700
-
3701
- private var bg: Color {
3702
- colorScheme == .dark
3703
- ? Color(red: 0.92, green: 0.92, blue: 0.93)
3704
- : Color(red: 0.16, green: 0.16, blue: 0.18)
3705
- }
3706
- private var textPrimary: Color {
3707
- colorScheme == .dark
3708
- ? Color(red: 0.10, green: 0.10, blue: 0.12)
3709
- : Color(red: 0.95, green: 0.95, blue: 0.97)
3710
- }
3711
- private var textSecondary: Color {
3712
- colorScheme == .dark
3713
- ? Color(red: 0.35, green: 0.35, blue: 0.38)
3714
- : Color(red: 0.68, green: 0.68, blue: 0.72)
3715
- }
3716
- private var textTertiary: Color {
3717
- colorScheme == .dark
3718
- ? Color(red: 0.55, green: 0.55, blue: 0.58)
3719
- : Color(red: 0.48, green: 0.48, blue: 0.52)
3720
- }
3721
-
3722
- // ZStack uses top-left origin; convert from NS bottom-left
3723
- private var winX: CGFloat { windowLocalFrame.origin.x }
3724
- private var winY: CGFloat { screenSize.height - windowLocalFrame.origin.y - windowLocalFrame.height }
3725
- private var winW: CGFloat { windowLocalFrame.width }
3726
- private var winH: CGFloat { windowLocalFrame.height }
3727
-
3728
- // Frame dimensions
3729
- private let edge: CGFloat = 5 // border thickness on non-flush edges
3730
- private let shelfHeight: CGFloat = 40 // info shelf thickness
3731
- private let cornerR: CGFloat = 10 // matches macOS window corners
3732
-
3733
- // Edge insets: 0 on flush edges, `edge` on free edges
3734
- private var insetTop: CGFloat { flush.top ? 0 : edge }
3735
- private var insetBottom: CGFloat { flush.bottom ? 0 : edge }
3736
- private var insetLeft: CGFloat { flush.left ? 0 : edge }
3737
- private var insetRight: CGFloat { flush.right ? 0 : edge }
3738
-
3739
- // Corner radii: 0 if either adjacent edge is flush
3740
- private var rTL: CGFloat { (flush.top || flush.left) ? 0 : cornerR }
3741
- private var rTR: CGFloat { (flush.top || flush.right) ? 0 : cornerR }
3742
- private var rBL: CGFloat { (flush.bottom || flush.left) ? 0 : cornerR }
3743
- private var rBR: CGFloat { (flush.bottom || flush.right) ? 0 : cornerR }
3744
-
3745
- var body: some View {
3746
- ZStack(alignment: .topLeading) {
3747
- Color.clear
3748
-
3749
- // Frame origin and size, accounting for flush edges and shelf placement
3750
- let frameX = winX - insetLeft + shelfOffsetX
3751
- let frameY = winY - insetTop + shelfOffsetY
3752
- let frameW = winW + insetLeft + insetRight + shelfExtraW
3753
- let frameH = winH + insetTop + insetBottom + shelfExtraH
3754
-
3755
- // Adjust corner radii for shelf side
3756
- let finalTL = adjustedCornerRadius(rTL, forShelf: labelPlacement, corner: .topLeft)
3757
- let finalTR = adjustedCornerRadius(rTR, forShelf: labelPlacement, corner: .topRight)
3758
- let finalBL = adjustedCornerRadius(rBL, forShelf: labelPlacement, corner: .bottomLeft)
3759
- let finalBR = adjustedCornerRadius(rBR, forShelf: labelPlacement, corner: .bottomRight)
3760
-
3761
- UnevenRoundedRectangle(
3762
- topLeadingRadius: finalTL,
3763
- bottomLeadingRadius: finalBL,
3764
- bottomTrailingRadius: finalBR,
3765
- topTrailingRadius: finalTR
3766
- )
3767
- .fill(bg)
3768
- .frame(width: frameW, height: frameH)
3769
- .offset(x: frameX, y: frameY)
3770
-
3771
- // Window snapshot — baked into the bezel so screenshot tools get the full composite
3772
- if let snapshot = windowSnapshot {
3773
- Image(nsImage: snapshot)
3774
- .resizable()
3775
- .interpolation(.high)
3776
- .frame(width: winW, height: winH)
3777
- .clipped()
3778
- .offset(x: winX, y: winY)
3779
- }
3780
-
3781
- // Shelf content
3782
- switch labelPlacement {
3783
- case .below:
3784
- shelfContent
3785
- .frame(width: winW + insetLeft + insetRight - 8, height: shelfHeight - 4)
3786
- .offset(x: winX - insetLeft + 4, y: winY + winH + insetBottom)
3787
- case .above:
3788
- shelfContent
3789
- .frame(width: winW + insetLeft + insetRight - 8, height: shelfHeight - 4)
3790
- .offset(x: winX - insetLeft + 4, y: winY - insetTop - shelfHeight + 4)
3791
- case .right:
3792
- sideShelfContent
3793
- .frame(width: 190, height: winH + insetTop + insetBottom)
3794
- .offset(x: winX + winW + insetRight + 4, y: winY - insetTop)
3795
- case .left:
3796
- sideShelfContent
3797
- .frame(width: 190, height: winH + insetTop + insetBottom)
3798
- .offset(x: winX - insetLeft - 194, y: winY - insetTop)
3799
- }
3800
- }
3801
- .frame(width: screenSize.width, height: screenSize.height)
3802
- }
3803
-
3804
- // MARK: - Shelf geometry helpers
3805
-
3806
- /// How much extra width/height the shelf adds to the frame
3807
- private var shelfExtraW: CGFloat {
3808
- switch labelPlacement {
3809
- case .below, .above: return 0
3810
- case .right, .left: return 200
3811
- }
3812
- }
3813
- private var shelfExtraH: CGFloat {
3814
- switch labelPlacement {
3815
- case .below, .above: return shelfHeight
3816
- case .right, .left: return 0
3817
- }
3818
- }
3819
-
3820
- /// Offset the frame origin for shelf on top/left
3821
- private var shelfOffsetX: CGFloat {
3822
- labelPlacement == .left ? -200 : 0
3823
- }
3824
- private var shelfOffsetY: CGFloat {
3825
- labelPlacement == .above ? -shelfHeight : 0
3826
- }
3827
-
3828
- private enum Corner { case topLeft, topRight, bottomLeft, bottomRight }
3829
-
3830
- /// Ensure the shelf-side corners are rounded even if the window edge is flush there
3831
- private func adjustedCornerRadius(_ base: CGFloat, forShelf shelf: LabelPlacement, corner: Corner) -> CGFloat {
3832
- // The shelf extends outward from the window, so its outer corners should be rounded
3833
- switch (shelf, corner) {
3834
- case (.below, .bottomLeft), (.below, .bottomRight):
3835
- return cornerR
3836
- case (.above, .topLeft), (.above, .topRight):
3837
- return cornerR
3838
- case (.right, .topRight), (.right, .bottomRight):
3839
- return cornerR
3840
- case (.left, .topLeft), (.left, .bottomLeft):
3841
- return cornerR
3842
- default:
3843
- return base
3844
- }
3845
- }
3846
-
3847
- // MARK: - Horizontal shelf (bottom / top)
3848
-
3849
- private var shelfContent: some View {
3850
- HStack(spacing: 8) {
3851
- // App name — distinctive rounded font
3852
- Text(appName)
3853
- .font(.system(size: 12, weight: .semibold, design: .rounded))
3854
- .foregroundColor(textPrimary)
3855
- .lineLimit(1)
3856
-
3857
- if !windowTitle.isEmpty {
3858
- Text("·")
3859
- .foregroundColor(textTertiary)
3860
- Text(windowTitle)
3861
- .font(.system(size: 10, design: .monospaced))
3862
- .foregroundColor(textSecondary)
3863
- .lineLimit(1)
3864
- .frame(maxWidth: .infinity, alignment: .leading)
3865
- } else {
3866
- Spacer()
3867
- }
3868
-
3869
- bezelTag(layerName, color: accent)
3870
- bezelTag(windowSize, color: textSecondary)
3871
-
3872
- // Display badge
3873
- HStack(spacing: 3) {
3874
- Image(systemName: "display")
3875
- .font(.system(size: 9))
3876
- .foregroundColor(textTertiary)
3877
- Text("\(displayNumber)")
3878
- .font(.system(size: 11, weight: .semibold, design: .monospaced))
3879
- .foregroundColor(textSecondary)
3880
- }
3881
- }
3882
- .padding(.horizontal, 10)
3883
- }
3884
-
3885
- // MARK: - Side shelf (right)
3886
-
3887
- private var sideShelfContent: some View {
3888
- VStack(alignment: .leading, spacing: 6) {
3889
- Text(appName)
3890
- .font(.system(size: 12, weight: .semibold, design: .rounded))
3891
- .foregroundColor(textPrimary)
3892
- .lineLimit(1)
3893
- if !windowTitle.isEmpty {
3894
- Text(windowTitle)
3895
- .font(.system(size: 9, design: .monospaced))
3896
- .foregroundColor(textSecondary)
3897
- .lineLimit(2)
3898
- }
3899
- HStack(spacing: 6) {
3900
- bezelTag(layerName, color: accent)
3901
- bezelTag(windowSize, color: textSecondary)
3902
- }
3903
- Spacer()
3904
- HStack(spacing: 4) {
3905
- Image(systemName: "display")
3906
- .font(.system(size: 9))
3907
- .foregroundColor(textTertiary)
3908
- Text("\(displayNumber)")
3909
- .font(.system(size: 13, weight: .semibold, design: .monospaced))
3910
- .foregroundColor(textSecondary)
3911
- Text(displayName)
3912
- .font(.system(size: 8, design: .monospaced))
3913
- .foregroundColor(textTertiary)
3914
- .lineLimit(1)
3915
- }
3916
- }
3917
- .padding(8)
3918
- }
3919
-
3920
- // MARK: - Helpers
3921
-
3922
- private func bezelTag(_ text: String, color: Color) -> some View {
3923
- Text(text)
3924
- .font(.system(size: 9, weight: .medium, design: .monospaced))
3925
- .foregroundColor(color)
3926
- .padding(.horizontal, 5)
3927
- .padding(.vertical, 2)
3928
- .background(
3929
- RoundedRectangle(cornerRadius: 3)
3930
- .fill(color.opacity(0.08))
3931
- .overlay(
3932
- RoundedRectangle(cornerRadius: 3)
3933
- .strokeBorder(color.opacity(0.15), lineWidth: 0.5)
3934
- )
3935
- )
3936
- }
3937
- }
3938
-
3939
- private struct SidebarWindowHitCatcher: NSViewRepresentable {
3940
- let rowHeight: CGFloat
3941
- let onClick: (Int) -> Void
3942
-
3943
- func makeNSView(context: Context) -> HitView {
3944
- let view = HitView()
3945
- view.rowHeight = rowHeight
3946
- view.onClick = onClick
3947
- return view
3948
- }
3949
-
3950
- func updateNSView(_ nsView: HitView, context: Context) {
3951
- nsView.rowHeight = rowHeight
3952
- nsView.onClick = onClick
3953
- }
3954
-
3955
- final class HitView: NSView {
3956
- var rowHeight: CGFloat = 30
3957
- var onClick: ((Int) -> Void)?
3958
-
3959
- override var isFlipped: Bool { true }
3960
-
3961
- override func mouseDown(with event: NSEvent) {
3962
- let point = convert(event.locationInWindow, from: nil)
3963
- guard point.x >= 0, point.x <= bounds.width, point.y >= 0, point.y <= bounds.height else { return }
3964
- let row = max(0, Int(point.y / max(rowHeight, 1)))
3965
- onClick?(row)
3966
- }
3967
- }
3968
- }
3969
-
3970
- // MARK: - Preference Keys
3971
-
3972
- private struct SearchOverlayFrameKey: PreferenceKey {
3973
- static var defaultValue: CGRect = .zero
3974
- static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
3975
- value = nextValue()
3976
- }
3977
- }