@lattices/cli 0.4.2 → 0.4.6

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 (146) hide show
  1. package/README.md +3 -0
  2. package/app/Info.plist +2 -2
  3. package/app/Lattices.app/Contents/Info.plist +2 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Package.swift +6 -0
  6. package/app/Sources/AppShell/App.swift +20 -0
  7. package/app/Sources/{AppDelegate.swift → AppShell/AppDelegate.swift} +94 -34
  8. package/app/Sources/{AppShellView.swift → AppShell/AppShellView.swift} +12 -1
  9. package/app/Sources/AppShell/AppUpdater.swift +92 -0
  10. package/app/Sources/AppShell/CliActionLauncher.swift +50 -0
  11. package/app/Sources/{HomeDashboardView.swift → AppShell/HomeDashboardView.swift} +18 -10
  12. package/app/Sources/AppShell/LatticesRuntime.swift +61 -0
  13. package/app/Sources/{MainView.swift → AppShell/MainView.swift} +351 -191
  14. package/app/Sources/{OnboardingView.swift → AppShell/OnboardingView.swift} +30 -16
  15. package/app/Sources/{Preferences.swift → AppShell/Preferences.swift} +78 -0
  16. package/app/Sources/{SettingsView.swift → AppShell/SettingsView.swift} +869 -152
  17. package/app/Sources/{HotkeyStore.swift → Core/Actions/HotkeyStore.swift} +9 -5
  18. package/app/Sources/{IntentEngine.swift → Core/Actions/IntentEngine.swift} +51 -27
  19. package/app/Sources/Core/Actions/IntentSchema.swift +94 -0
  20. package/app/Sources/{Intents → Core/Actions/Intents}/LatticeIntent.swift +0 -25
  21. package/app/Sources/{PaletteCommand.swift → Core/Actions/PaletteCommand.swift} +26 -6
  22. package/app/Sources/{VoiceIntentResolver.swift → Core/Actions/VoiceIntentResolver.swift} +46 -4
  23. package/app/Sources/Core/Companion/CompanionActivityLog.swift +70 -0
  24. package/app/Sources/Core/Companion/CompanionKeyboardController.swift +141 -0
  25. package/app/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +438 -0
  26. package/app/Sources/Core/Companion/LatticesCompanionCockpit.swift +555 -0
  27. package/app/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +594 -0
  28. package/app/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +204 -0
  29. package/app/Sources/Core/Companion/LatticesDeckHost.swift +1463 -0
  30. package/app/Sources/{LatticesApi.swift → Core/Daemon/LatticesApi.swift} +125 -4
  31. package/app/Sources/{AppTypeClassifier.swift → Core/Desktop/AppTypeClassifier.swift} +36 -0
  32. package/app/Sources/{DesktopModel.swift → Core/Desktop/DesktopModel.swift} +6 -8
  33. package/app/Sources/Core/Desktop/MouseFinder.swift +527 -0
  34. package/app/Sources/Core/Desktop/SessionWindowLocator.swift +139 -0
  35. package/app/Sources/Core/Desktop/WindowDragSnapController.swift +628 -0
  36. package/app/Sources/Core/Desktop/WindowPreviewCard.swift +100 -0
  37. package/app/Sources/Core/Desktop/WindowPreviewStore.swift +113 -0
  38. package/app/Sources/Core/Desktop/WindowSelectionStore.swift +76 -0
  39. package/app/Sources/{WindowTiler.swift → Core/Desktop/WindowTiler.swift} +351 -172
  40. package/app/Sources/Core/Input/MouseGestureConfig.swift +364 -0
  41. package/app/Sources/Core/Input/MouseGestureController.swift +1203 -0
  42. package/app/Sources/Core/Input/MouseInputDeviceStore.swift +98 -0
  43. package/app/Sources/Core/Input/MouseInputEventViewer.swift +272 -0
  44. package/app/Sources/Core/Input/MouseShortcutStore.swift +107 -0
  45. package/app/Sources/{CommandModeState.swift → Core/Overlays/CommandMode/CommandModeState.swift} +127 -24
  46. package/app/Sources/{CommandModeView.swift → Core/Overlays/CommandMode/CommandModeView.swift} +492 -79
  47. package/app/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +67 -0
  48. package/app/Sources/{CheatSheetHUD.swift → Core/Overlays/HUD/CheatSheetHUD.swift} +1 -0
  49. package/app/Sources/{HUDRightBar.swift → Core/Overlays/HUD/HUDRightBar.swift} +23 -201
  50. package/app/Sources/{LauncherHUD.swift → Core/Overlays/HUD/LauncherHUD.swift} +12 -26
  51. package/app/Sources/{OmniSearchView.swift → Core/Overlays/OmniSearch/OmniSearchView.swift} +136 -2
  52. package/app/Sources/{OmniSearchWindow.swift → Core/Overlays/OmniSearch/OmniSearchWindow.swift} +21 -32
  53. package/app/Sources/Core/Overlays/OverlayPanelShell.swift +241 -0
  54. package/app/Sources/{ScreenMapState.swift → Core/Overlays/ScreenMap/ScreenMapState.swift} +116 -32
  55. package/app/Sources/{ScreenMapView.swift → Core/Overlays/ScreenMap/ScreenMapView.swift} +510 -524
  56. package/app/Sources/{ScreenMapWindowController.swift → Core/Overlays/ScreenMap/ScreenMapWindowController.swift} +12 -4
  57. package/app/Sources/{VoiceCommandWindow.swift → Core/Overlays/Voice/VoiceCommandWindow.swift} +46 -53
  58. package/app/Sources/Core/Pi/PiAuthNextStepCard.swift +148 -0
  59. package/app/Sources/Core/Pi/PiAuthPromptCard.swift +90 -0
  60. package/app/Sources/{PiChatDock.swift → Core/Pi/PiChatDock.swift} +137 -74
  61. package/app/Sources/{PiChatSession.swift → Core/Pi/PiChatSession.swift} +608 -108
  62. package/app/Sources/Core/Pi/PiInstallCallout.swift +86 -0
  63. package/app/Sources/Core/Pi/PiProviderSetupCallout.swift +99 -0
  64. package/app/Sources/{PiWorkspaceView.swift → Core/Pi/PiWorkspaceView.swift} +174 -77
  65. package/app/Sources/{PermissionChecker.swift → Core/System/PermissionChecker.swift} +76 -2
  66. package/app/Sources/Core/System/SystemTelemetryMonitor.swift +273 -0
  67. package/app/Sources/{HandsOffSession.swift → Core/Voice/HandsOffSession.swift} +15 -4
  68. package/app/Sources/{WorkspaceManager.swift → Core/Workspace/WorkspaceManager.swift} +288 -0
  69. package/bin/assistant-intelligence.ts +874 -0
  70. package/bin/handsoff-infer.ts +16 -209
  71. package/bin/handsoff-worker.ts +45 -258
  72. package/bin/lattices-app.ts +62 -0
  73. package/bin/lattices-dev +4 -0
  74. package/bin/lattices.ts +125 -14
  75. package/docs/agents.md +14 -0
  76. package/docs/api.md +55 -0
  77. package/docs/app.md +3 -0
  78. package/docs/companion-deck.md +180 -0
  79. package/docs/component-extraction-roadmap.md +392 -0
  80. package/docs/config.md +25 -0
  81. package/docs/tiling-reference.md +55 -0
  82. package/docs/voice-error-model.md +73 -0
  83. package/package.json +4 -1
  84. package/app/Sources/App.swift +0 -10
  85. package/app/Sources/CommandPaletteWindow.swift +0 -134
  86. package/app/Sources/MouseFinder.swift +0 -222
  87. /package/app/Sources/{KeyRecorderView.swift → AppShell/KeyRecorderView.swift} +0 -0
  88. /package/app/Sources/{MainWindow.swift → AppShell/MainWindow.swift} +0 -0
  89. /package/app/Sources/{SettingsWindow.swift → AppShell/SettingsWindow.swift} +0 -0
  90. /package/app/Sources/{HotkeyManager.swift → Core/Actions/HotkeyManager.swift} +0 -0
  91. /package/app/Sources/{Intents → Core/Actions/Intents}/CreateLayerIntent.swift +0 -0
  92. /package/app/Sources/{Intents → Core/Actions/Intents}/DistributeIntent.swift +0 -0
  93. /package/app/Sources/{Intents → Core/Actions/Intents}/FocusIntent.swift +0 -0
  94. /package/app/Sources/{Intents → Core/Actions/Intents}/HelpIntent.swift +0 -0
  95. /package/app/Sources/{Intents → Core/Actions/Intents}/KillIntent.swift +0 -0
  96. /package/app/Sources/{Intents → Core/Actions/Intents}/LaunchIntent.swift +0 -0
  97. /package/app/Sources/{Intents → Core/Actions/Intents}/ListSessionsIntent.swift +0 -0
  98. /package/app/Sources/{Intents → Core/Actions/Intents}/ListWindowsIntent.swift +0 -0
  99. /package/app/Sources/{Intents → Core/Actions/Intents}/ScanIntent.swift +0 -0
  100. /package/app/Sources/{Intents → Core/Actions/Intents}/SearchIntent.swift +0 -0
  101. /package/app/Sources/{Intents → Core/Actions/Intents}/SwitchLayerIntent.swift +0 -0
  102. /package/app/Sources/{Intents → Core/Actions/Intents}/TileIntent.swift +0 -0
  103. /package/app/Sources/{DaemonProtocol.swift → Core/Daemon/DaemonProtocol.swift} +0 -0
  104. /package/app/Sources/{DaemonServer.swift → Core/Daemon/DaemonServer.swift} +0 -0
  105. /package/app/Sources/{AccessibilityTextExtractor.swift → Core/Desktop/AccessibilityTextExtractor.swift} +0 -0
  106. /package/app/Sources/{DesktopModelTypes.swift → Core/Desktop/DesktopModelTypes.swift} +0 -0
  107. /package/app/Sources/{InventoryManager.swift → Core/Desktop/InventoryManager.swift} +0 -0
  108. /package/app/Sources/{InventoryPath.swift → Core/Desktop/InventoryPath.swift} +0 -0
  109. /package/app/Sources/{OcrModel.swift → Core/Desktop/OcrModel.swift} +0 -0
  110. /package/app/Sources/{OcrStore.swift → Core/Desktop/OcrStore.swift} +0 -0
  111. /package/app/Sources/{PlacementSpec.swift → Core/Desktop/PlacementSpec.swift} +0 -0
  112. /package/app/Sources/{TilePickerView.swift → Core/Desktop/TilePickerView.swift} +0 -0
  113. /package/app/Sources/{AppWindowShell.swift → Core/Overlays/AppWindowShell.swift} +0 -0
  114. /package/app/Sources/{CommandModeWindow.swift → Core/Overlays/CommandMode/CommandModeWindow.swift} +0 -0
  115. /package/app/Sources/{CommandPaletteView.swift → Core/Overlays/CommandPalette/CommandPaletteView.swift} +0 -0
  116. /package/app/Sources/{HUDBottomBar.swift → Core/Overlays/HUD/HUDBottomBar.swift} +0 -0
  117. /package/app/Sources/{HUDController.swift → Core/Overlays/HUD/HUDController.swift} +0 -0
  118. /package/app/Sources/{HUDLeftBar.swift → Core/Overlays/HUD/HUDLeftBar.swift} +0 -0
  119. /package/app/Sources/{HUDMinimap.swift → Core/Overlays/HUD/HUDMinimap.swift} +0 -0
  120. /package/app/Sources/{HUDState.swift → Core/Overlays/HUD/HUDState.swift} +0 -0
  121. /package/app/Sources/{HUDTopBar.swift → Core/Overlays/HUD/HUDTopBar.swift} +0 -0
  122. /package/app/Sources/{LayerBezel.swift → Core/Overlays/HUD/LayerBezel.swift} +0 -0
  123. /package/app/Sources/{OmniSearchState.swift → Core/Overlays/OmniSearch/OmniSearchState.swift} +0 -0
  124. /package/app/Sources/{DiagnosticLog.swift → Core/System/DiagnosticLog.swift} +0 -0
  125. /package/app/Sources/{EventBus.swift → Core/System/EventBus.swift} +0 -0
  126. /package/app/Sources/{ProcessModel.swift → Core/System/ProcessModel.swift} +0 -0
  127. /package/app/Sources/{ProcessQuery.swift → Core/System/ProcessQuery.swift} +0 -0
  128. /package/app/Sources/{AdvisorLearningStore.swift → Core/Voice/AdvisorLearningStore.swift} +0 -0
  129. /package/app/Sources/{AgentSession.swift → Core/Voice/AgentSession.swift} +0 -0
  130. /package/app/Sources/{AudioProvider.swift → Core/Voice/AudioProvider.swift} +0 -0
  131. /package/app/Sources/{VoiceChatView.swift → Core/Voice/VoiceChatView.swift} +0 -0
  132. /package/app/Sources/{VoxClient.swift → Core/Voice/VoxClient.swift} +0 -0
  133. /package/app/Sources/{Project.swift → Core/Workspace/Project.swift} +0 -0
  134. /package/app/Sources/{ProjectScanner.swift → Core/Workspace/ProjectScanner.swift} +0 -0
  135. /package/app/Sources/{SessionLayerStore.swift → Core/Workspace/SessionLayerStore.swift} +0 -0
  136. /package/app/Sources/{SessionManager.swift → Core/Workspace/SessionManager.swift} +0 -0
  137. /package/app/Sources/{Terminal.swift → Core/Workspace/Terminal/Terminal.swift} +0 -0
  138. /package/app/Sources/{TerminalQuery.swift → Core/Workspace/Terminal/TerminalQuery.swift} +0 -0
  139. /package/app/Sources/{TerminalSynthesizer.swift → Core/Workspace/Terminal/TerminalSynthesizer.swift} +0 -0
  140. /package/app/Sources/{TmuxModel.swift → Core/Workspace/Tmux/TmuxModel.swift} +0 -0
  141. /package/app/Sources/{TmuxQuery.swift → Core/Workspace/Tmux/TmuxQuery.swift} +0 -0
  142. /package/app/Sources/{ActionRow.swift → UI/ActionRow.swift} +0 -0
  143. /package/app/Sources/{OrphanRow.swift → UI/OrphanRow.swift} +0 -0
  144. /package/app/Sources/{ProjectRow.swift → UI/ProjectRow.swift} +0 -0
  145. /package/app/Sources/{TabGroupRow.swift → UI/TabGroupRow.swift} +0 -0
  146. /package/app/Sources/{Theme.swift → UI/Theme.swift} +0 -0
@@ -4,6 +4,140 @@ import AppKit
4
4
  // MARK: - Screen Map View (Standalone)
5
5
 
6
6
  struct ScreenMapView: View {
7
+ private static let canvasPadding: CGFloat = 8
8
+ private static let canvasFitInsets = CGSize(width: 24, height: 16)
9
+ private static let canvasViewportInsets = CGSize(width: 16, height: 16)
10
+
11
+ private struct CanvasMetrics: Equatable {
12
+ let worldBounds: CGRect
13
+ let fitScale: CGFloat
14
+ let effectiveScale: CGFloat
15
+ let mapSize: CGSize
16
+ let centerOffset: CGPoint
17
+ let syncedViewportSize: CGSize
18
+
19
+ init(editor: ScreenMapEditorState?, displays: [DisplayGeometry], viewportSize: CGSize) {
20
+ let fallbackBounds: CGRect = {
21
+ guard let first = displays.first else {
22
+ let size = NSScreen.main?.frame.size ?? CGSize(width: 1920, height: 1080)
23
+ return CGRect(origin: .zero, size: size)
24
+ }
25
+ return displays.dropFirst().reduce(first.cgRect) { $0.union($1.cgRect) }
26
+ }()
27
+
28
+ worldBounds = editor?.canvasWorldBounds ?? fallbackBounds
29
+
30
+ let fitArea = CGSize(
31
+ width: max(viewportSize.width - ScreenMapView.canvasFitInsets.width, 1),
32
+ height: max(viewportSize.height - ScreenMapView.canvasFitInsets.height, 1)
33
+ )
34
+ syncedViewportSize = CGSize(
35
+ width: max(viewportSize.width - ScreenMapView.canvasViewportInsets.width, 1),
36
+ height: max(viewportSize.height - ScreenMapView.canvasViewportInsets.height, 1)
37
+ )
38
+
39
+ fitScale = min(
40
+ fitArea.width / max(worldBounds.width, 1),
41
+ fitArea.height / max(worldBounds.height, 1)
42
+ )
43
+ effectiveScale = fitScale * (editor?.zoomLevel ?? 1)
44
+ mapSize = CGSize(width: worldBounds.width * effectiveScale, height: worldBounds.height * effectiveScale)
45
+ centerOffset = CGPoint(
46
+ x: (viewportSize.width - mapSize.width) / 2,
47
+ y: (viewportSize.height - mapSize.height) / 2
48
+ )
49
+ }
50
+
51
+ func mapRect(for worldRect: CGRect, minimumSize: CGFloat = 4) -> CGRect {
52
+ CGRect(
53
+ x: (worldRect.origin.x - worldBounds.origin.x) * effectiveScale,
54
+ y: (worldRect.origin.y - worldBounds.origin.y) * effectiveScale,
55
+ width: max(worldRect.width * effectiveScale, minimumSize),
56
+ height: max(worldRect.height * effectiveScale, minimumSize)
57
+ )
58
+ }
59
+ }
60
+
61
+ private struct CanvasProjection {
62
+ let scale: CGFloat
63
+ let bboxOrigin: CGPoint
64
+ let mapOrigin: CGPoint
65
+ let panOffset: CGPoint
66
+
67
+ init(editor: ScreenMapEditorState) {
68
+ scale = editor.effectiveScale
69
+ bboxOrigin = editor.bboxOrigin
70
+ mapOrigin = editor.mapOrigin
71
+ panOffset = editor.panOffset
72
+ }
73
+
74
+ func mapRect(for worldRect: CGRect, minimumSize: CGFloat = 4) -> CGRect {
75
+ CGRect(
76
+ x: (worldRect.origin.x - bboxOrigin.x) * scale,
77
+ y: (worldRect.origin.y - bboxOrigin.y) * scale,
78
+ width: max(worldRect.width * scale, minimumSize),
79
+ height: max(worldRect.height * scale, minimumSize)
80
+ )
81
+ }
82
+
83
+ func mapPoint(forCanvasPoint canvasPoint: CGPoint) -> CGPoint {
84
+ CGPoint(
85
+ x: canvasPoint.x - ScreenMapView.canvasPadding - mapOrigin.x - panOffset.x,
86
+ y: canvasPoint.y - ScreenMapView.canvasPadding - mapOrigin.y - panOffset.y
87
+ )
88
+ }
89
+ }
90
+
91
+ private struct CanvasHit {
92
+ let id: UInt32
93
+ let mapRect: CGRect
94
+ let mapPoint: CGPoint
95
+ }
96
+
97
+ private struct CanvasSyncKey: Equatable {
98
+ let viewportSize: CGSize
99
+ let worldBounds: CGRect
100
+ let zoomLevel: CGFloat
101
+ let navigationRevision: Int
102
+ }
103
+
104
+ private struct MiniMapMetrics {
105
+ let worldBounds: CGRect
106
+ let scale: CGFloat
107
+ let drawSize: CGSize
108
+ let offset: CGPoint
109
+
110
+ init(worldBounds: CGRect, canvasSize: CGSize) {
111
+ self.worldBounds = worldBounds
112
+ let scaleW = canvasSize.width / max(worldBounds.width, 1)
113
+ let scaleH = canvasSize.height / max(worldBounds.height, 1)
114
+ scale = min(scaleW, scaleH)
115
+ drawSize = CGSize(width: worldBounds.width * scale, height: worldBounds.height * scale)
116
+ offset = CGPoint(
117
+ x: (canvasSize.width - drawSize.width) / 2,
118
+ y: (canvasSize.height - drawSize.height) / 2
119
+ )
120
+ }
121
+
122
+ func rect(for worldRect: CGRect, minimumSize: CGFloat) -> CGRect {
123
+ CGRect(
124
+ x: (worldRect.origin.x - worldBounds.origin.x) * scale + offset.x,
125
+ y: (worldRect.origin.y - worldBounds.origin.y) * scale + offset.y,
126
+ width: max(worldRect.width * scale, minimumSize),
127
+ height: max(worldRect.height * scale, minimumSize)
128
+ )
129
+ }
130
+
131
+ func worldPoint(for localPoint: CGPoint) -> CGPoint {
132
+ let localX = min(max(localPoint.x - offset.x, 0), drawSize.width)
133
+ let localY = min(max(localPoint.y - offset.y, 0), drawSize.height)
134
+ return CGPoint(
135
+ x: worldBounds.origin.x + localX / max(scale, 0.0001),
136
+ y: worldBounds.origin.y + localY / max(scale, 0.0001)
137
+ )
138
+ }
139
+ }
140
+
7
141
  @ObservedObject var controller: ScreenMapController
8
142
  var onNavigate: ((AppPage) -> Void)? = nil
9
143
  @ObservedObject private var daemon = DaemonServer.shared
@@ -18,7 +152,6 @@ struct ScreenMapView: View {
18
152
  @State private var scrollWheelMonitor: Any?
19
153
  @State private var screenMapCanvasOrigin: CGPoint = .zero
20
154
  @State private var screenMapCanvasSize: CGSize = .zero
21
- @State private var screenMapTitleBarHeight: CGFloat = 0 // reserved for coordinate math
22
155
  @State private var screenMapClickWindowId: UInt32? = nil
23
156
  @State private var screenMapClickPoint: NSPoint = .zero
24
157
  @State private var hoveredWindowId: UInt32?
@@ -28,6 +161,9 @@ struct ScreenMapView: View {
28
161
  @State private var sidebarDragWindowId: UInt32? = nil
29
162
  @State private var sidebarDragOffset: CGSize = .zero
30
163
  @State private var expandedLayers: Set<Int> = []
164
+ @State private var showUnnamedLayers: Bool = false
165
+ @State private var showSets: Bool = false
166
+ @State private var showExplorer: Bool = false
31
167
  @State private var mouseMovedMonitor: Any?
32
168
  @State private var sidebarWidth: CGFloat = 180
33
169
  @State private var isDraggingSidebar: Bool = false
@@ -40,7 +176,6 @@ struct ScreenMapView: View {
40
176
  @State private var isSpaceHeld: Bool = false
41
177
  @State private var spaceDragStart: NSPoint? = nil
42
178
  @State private var spaceDragPanStart: CGPoint = .zero
43
- @State private var flagsMonitor: Any?
44
179
  @State private var searchOverlayFrame: CGRect = .zero
45
180
 
46
181
  var body: some View {
@@ -73,17 +208,7 @@ struct ScreenMapView: View {
73
208
  if controller.isSearchActive, let editor = controller.editor {
74
209
  floatingSearchOverlay(editor: editor)
75
210
  }
76
- // Viewport controls — bottom-right corner of canvas
77
- if let editor = controller.editor {
78
- VStack {
79
- Spacer()
80
- HStack {
81
- Spacer()
82
- canvasViewportDock(editor: editor)
83
- .padding(10)
84
- }
85
- }
86
- }
211
+ // Viewport controls removed accessible via keyboard shortcuts
87
212
  }
88
213
  if let editor = controller.editor {
89
214
  panelResizeHandle(isActive: $isDraggingInspector, width: $inspectorWidth,
@@ -117,10 +242,7 @@ struct ScreenMapView: View {
117
242
  private func displayToolbar(editor: ScreenMapEditorState) -> some View {
118
243
  HStack(spacing: 4) {
119
244
  Button {
120
- editor.cyclePreviousDisplay()
121
- controller.focusViewportPreset(editor.activeViewportPreset ?? .main, flashView: false)
122
- controller.flash(editor.focusedDisplay?.label ?? "All displays")
123
- controller.objectWillChange.send()
245
+ controller.stepDisplayFocus(.previous)
124
246
  } label: {
125
247
  Image(systemName: "chevron.left")
126
248
  .font(.system(size: 8, weight: .semibold))
@@ -131,9 +253,7 @@ struct ScreenMapView: View {
131
253
  .buttonStyle(.plain)
132
254
 
133
255
  Button {
134
- editor.focusDisplay(nil)
135
- controller.focusViewportPreset(editor.activeViewportPreset ?? .main, flashView: false)
136
- controller.objectWillChange.send()
256
+ controller.setDisplayFocus(nil)
137
257
  } label: {
138
258
  displayToolbarPill(name: "All", isActive: editor.focusedDisplayIndex == nil)
139
259
  }
@@ -142,9 +262,7 @@ struct ScreenMapView: View {
142
262
  ForEach(Array(editor.spatialDisplayOrder.enumerated()), id: \.element.index) { spatialPos, disp in
143
263
  let isActive = editor.focusedDisplayIndex == disp.index
144
264
  Button {
145
- editor.focusDisplay(disp.index)
146
- controller.focusViewportPreset(editor.activeViewportPreset ?? .main, flashView: false)
147
- controller.objectWillChange.send()
265
+ controller.setDisplayFocus(disp.index)
148
266
  } label: {
149
267
  displayToolbarPill(
150
268
  badge: spatialPos + 1,
@@ -156,10 +274,7 @@ struct ScreenMapView: View {
156
274
  }
157
275
 
158
276
  Button {
159
- editor.cycleNextDisplay()
160
- controller.focusViewportPreset(editor.activeViewportPreset ?? .main, flashView: false)
161
- controller.flash(editor.focusedDisplay?.label ?? "All displays")
162
- controller.objectWillChange.send()
277
+ controller.stepDisplayFocus(.next)
163
278
  } label: {
164
279
  Image(systemName: "chevron.right")
165
280
  .font(.system(size: 8, weight: .semibold))
@@ -1316,8 +1431,6 @@ struct ScreenMapView: View {
1316
1431
  // MARK: - Layer Sidebar
1317
1432
 
1318
1433
  private func layerSidebar(editor: ScreenMapEditorState) -> some View {
1319
- let layers = editor.effectiveLayers
1320
-
1321
1434
  return VStack(spacing: 0) {
1322
1435
  // Header
1323
1436
  HStack {
@@ -1337,146 +1450,61 @@ struct ScreenMapView: View {
1337
1450
  }
1338
1451
  .padding(.bottom, 8)
1339
1452
 
1340
- // "All" row
1453
+ // Layer list
1341
1454
  ScrollView(.vertical, showsIndicators: false) {
1455
+ let namedLayers = editor.namedEffectiveLayers
1456
+ let unnamedLayers = editor.unnamedEffectiveLayers
1457
+
1342
1458
  VStack(spacing: 2) {
1343
1459
  layerTreeHeader(
1344
1460
  label: "All",
1345
- count: editor.focusedDisplayIndex != nil
1346
- ? editor.windows.filter { $0.displayIndex == editor.focusedDisplayIndex! }.count
1347
- : editor.windows.count,
1461
+ count: editor.scopedWindowCount,
1348
1462
  isActive: editor.isShowingAll,
1349
1463
  color: Palette.running
1350
1464
  ) {
1351
1465
  editor.selectLayer(nil)
1352
- controller.objectWillChange.send()
1353
1466
  }
1354
1467
 
1355
- // Per-layer tree nodes
1356
- ForEach(layers, id: \.self) { layer in
1357
- let displayName = editor.layerDisplayName(for: layer)
1358
- let fullName = editor.layerNames[layer]
1359
- let color = Self.layerColor(for: layer)
1360
- let isActive = editor.isLayerSelected(layer)
1361
- let isDropTarget = dropTargetLayer == layer
1362
- let layerWindows = layerWindowsForTree(editor: editor, layer: layer)
1363
-
1364
- VStack(spacing: 0) {
1365
- layerTreeHeader(label: fullName ?? displayName,
1366
- count: layerWindows.count,
1367
- isActive: isActive,
1368
- color: color,
1369
- isExpandable: true,
1370
- isExpanded: expandedLayers.contains(layer),
1371
- onToggleExpand: {
1372
- if expandedLayers.contains(layer) {
1373
- expandedLayers.remove(layer)
1374
- } else {
1375
- expandedLayers.insert(layer)
1376
- }
1377
- }) {
1378
- if NSEvent.modifierFlags.contains(.command) {
1379
- editor.toggleLayerSelection(layer)
1380
- } else {
1381
- editor.selectLayer(layer)
1382
- }
1383
- // Auto-expand on selection
1384
- expandedLayers.insert(layer)
1385
- controller.objectWillChange.send()
1386
- }
1468
+ ForEach(namedLayers, id: \.self) { layer in
1469
+ layerRow(layer: layer, editor: editor)
1470
+ }
1387
1471
 
1388
- // Window children (shown when layer is expanded)
1389
- if expandedLayers.contains(layer) {
1390
- VStack(spacing: 0) {
1391
- ForEach(layerWindows) { win in
1392
- let isSelected = controller.selectedWindowIds.contains(win.id)
1393
- let isDragging = sidebarDragWindowId == win.id
1394
- HStack(spacing: 4) {
1395
- Rectangle()
1396
- .fill(color.opacity(0.4))
1397
- .frame(width: 1, height: 12)
1398
- .padding(.leading, 8)
1399
- Text(win.app)
1400
- .font(Typo.mono(8))
1401
- .foregroundColor(isSelected ? Palette.running : Palette.textDim)
1402
- .lineLimit(1)
1403
- Spacer()
1404
- if win.hasEdits {
1405
- Circle()
1406
- .fill(Color.orange)
1407
- .frame(width: 4, height: 4)
1408
- }
1409
- }
1410
- .padding(.vertical, 2)
1411
- .padding(.horizontal, 4)
1412
- .background(
1413
- RoundedRectangle(cornerRadius: 3)
1414
- .fill(isSelected ? Palette.running.opacity(0.08) : Color.clear)
1415
- )
1416
- .contentShape(Rectangle())
1417
- .opacity(isDragging ? 0.4 : 1.0)
1418
- .offset(isDragging ? sidebarDragOffset : .zero)
1419
- .zIndex(isDragging ? 10 : 0)
1420
- .gesture(
1421
- DragGesture(minimumDistance: 4, coordinateSpace: .named("layerSidebar"))
1422
- .onChanged { value in
1423
- sidebarDragWindowId = win.id
1424
- sidebarDragOffset = value.translation
1425
- controller.selectSingle(win.id)
1426
- // Hit-test layer rows
1427
- let pt = value.location
1428
- var hit: Int? = nil
1429
- for (l, frame) in layerRowFrames {
1430
- if l != layer && frame.contains(pt) {
1431
- hit = l
1432
- break
1433
- }
1434
- }
1435
- dropTargetLayer = hit
1436
- }
1437
- .onEnded { _ in
1438
- if let targetLayer = dropTargetLayer {
1439
- editor.reassignLayer(windowId: win.id, toLayer: targetLayer, fitToAvailable: true)
1440
- controller.flash("Moved to L\(targetLayer)")
1441
- controller.objectWillChange.send()
1442
- }
1443
- sidebarDragWindowId = nil
1444
- sidebarDragOffset = .zero
1445
- dropTargetLayer = nil
1446
- }
1447
- )
1448
- .onTapGesture {
1449
- if NSEvent.modifierFlags.contains(.command) {
1450
- controller.toggleSelection(win.id)
1451
- } else {
1452
- controller.selectSingle(win.id)
1453
- }
1454
- }
1455
- }
1456
- }
1457
- .padding(.leading, 4)
1458
- .padding(.top, 2)
1459
- }
1472
+ if !unnamedLayers.isEmpty {
1473
+ HStack(spacing: 4) {
1474
+ let totalWindows = unnamedLayers.reduce(0) { $0 + editor.layerTreeWindows(for: $1).count }
1475
+ Image(systemName: showUnnamedLayers ? "chevron.down" : "chevron.right")
1476
+ .font(.system(size: 6, weight: .bold))
1477
+ .foregroundColor(Palette.textMuted)
1478
+ Text("\(unnamedLayers.count) more")
1479
+ .font(Typo.mono(8))
1480
+ .foregroundColor(Palette.textMuted)
1481
+ Text( \(totalWindows)w")
1482
+ .font(Typo.mono(7))
1483
+ .foregroundColor(Palette.textDim)
1484
+ Spacer()
1460
1485
  }
1461
- .overlay(
1462
- RoundedRectangle(cornerRadius: 4)
1463
- .strokeBorder(isDropTarget ? Palette.running : Color.clear, lineWidth: 1.5)
1464
- )
1465
- .background(
1466
- GeometryReader { geo in
1467
- Color.clear.preference(key: LayerRowFrameKey.self,
1468
- value: [layer: geo.frame(in: .named("layerSidebar"))])
1486
+ .padding(.vertical, 4)
1487
+ .padding(.horizontal, 4)
1488
+ .contentShape(Rectangle())
1489
+ .simultaneousGesture(TapGesture().onEnded { showUnnamedLayers.toggle() })
1490
+
1491
+ if showUnnamedLayers {
1492
+ ForEach(unnamedLayers, id: \.self) { layer in
1493
+ layerRow(layer: layer, editor: editor)
1469
1494
  }
1470
- )
1495
+ }
1471
1496
  }
1472
1497
  }
1473
1498
  }
1474
1499
  .coordinateSpace(name: "layerSidebar")
1475
1500
 
1476
1501
  Spacer(minLength: 8)
1477
- windowSetsSection(editor: editor)
1478
- Spacer(minLength: 8)
1479
- canvasExplorer(editor: editor)
1502
+ collapsibleSection(title: "SETS", count: controller.windowSets.count, isExpanded: $showSets) {
1503
+ windowSetsSection(editor: editor)
1504
+ }
1505
+ collapsibleSection(title: "EXPLORER", count: editor.canvasExplorerRegions.count, isExpanded: $showExplorer) {
1506
+ canvasExplorer(editor: editor)
1507
+ }
1480
1508
  Spacer(minLength: 8)
1481
1509
  sidebarMiniMap(editor: editor)
1482
1510
  }
@@ -1486,68 +1514,192 @@ struct ScreenMapView: View {
1486
1514
  .onPreferenceChange(LayerRowFrameKey.self) { layerRowFrames = $0 }
1487
1515
  }
1488
1516
 
1489
- private func layerWindowsForTree(editor: ScreenMapEditorState, layer: Int) -> [ScreenMapWindowEntry] {
1490
- var wins = editor.windows.filter { $0.layer == layer }
1491
- if let dIdx = editor.focusedDisplayIndex {
1492
- wins = wins.filter { $0.displayIndex == dIdx }
1517
+ @ViewBuilder
1518
+ private func layerRow(layer: Int, editor: ScreenMapEditorState) -> some View {
1519
+ let displayName = editor.layerDisplayName(for: layer)
1520
+ let fullName = editor.layerNames[layer]
1521
+ let color = Self.layerColor(for: layer)
1522
+ let isActive = editor.isLayerSelected(layer)
1523
+ let isDropTarget = dropTargetLayer == layer
1524
+ let layerWindows = editor.layerTreeWindows(for: layer)
1525
+
1526
+ VStack(spacing: 0) {
1527
+ layerTreeHeader(label: fullName ?? displayName,
1528
+ count: layerWindows.count,
1529
+ isActive: isActive,
1530
+ color: color,
1531
+ isExpandable: true,
1532
+ isExpanded: expandedLayers.contains(layer),
1533
+ onToggleExpand: { toggleExpandedLayer(layer) }) {
1534
+ selectSidebarLayer(layer, editor: editor)
1535
+ }
1536
+
1537
+ if expandedLayers.contains(layer) {
1538
+ VStack(spacing: 0) {
1539
+ ForEach(layerWindows) { win in
1540
+ let isSelected = controller.selectedWindowIds.contains(win.id)
1541
+ let isDragging = sidebarDragWindowId == win.id
1542
+ HStack(spacing: 4) {
1543
+ Rectangle()
1544
+ .fill(color.opacity(0.4))
1545
+ .frame(width: 1, height: 12)
1546
+ .padding(.leading, 8)
1547
+ Text(win.app)
1548
+ .font(Typo.mono(8))
1549
+ .foregroundColor(isSelected ? Palette.running : Palette.textDim)
1550
+ .lineLimit(1)
1551
+ Spacer()
1552
+ if win.hasEdits {
1553
+ Circle()
1554
+ .fill(Color.orange)
1555
+ .frame(width: 4, height: 4)
1556
+ }
1557
+ }
1558
+ .padding(.vertical, 2)
1559
+ .padding(.horizontal, 4)
1560
+ .background(
1561
+ RoundedRectangle(cornerRadius: 3)
1562
+ .fill(isSelected ? Palette.running.opacity(0.08) : Color.clear)
1563
+ )
1564
+ .contentShape(Rectangle())
1565
+ .opacity(isDragging ? 0.4 : 1.0)
1566
+ .offset(isDragging ? sidebarDragOffset : .zero)
1567
+ .zIndex(isDragging ? 10 : 0)
1568
+ .gesture(
1569
+ DragGesture(minimumDistance: 4, coordinateSpace: .named("layerSidebar"))
1570
+ .onChanged { value in
1571
+ handleSidebarWindowDragChanged(value, sourceLayer: layer, windowId: win.id)
1572
+ }
1573
+ .onEnded { _ in
1574
+ finishSidebarWindowDrag(win, editor: editor)
1575
+ }
1576
+ )
1577
+ .simultaneousGesture(TapGesture().onEnded {
1578
+ selectSidebarWindow(win.id)
1579
+ })
1580
+ }
1581
+ }
1582
+ .padding(.leading, 4)
1583
+ .padding(.top, 2)
1584
+ }
1493
1585
  }
1494
- return wins.sorted { $0.zIndex < $1.zIndex }
1586
+ .overlay(
1587
+ RoundedRectangle(cornerRadius: 4)
1588
+ .strokeBorder(isDropTarget ? Palette.running : Color.clear, lineWidth: 1.5)
1589
+ )
1590
+ .background(
1591
+ GeometryReader { geo in
1592
+ Color.clear.preference(key: LayerRowFrameKey.self,
1593
+ value: [layer: geo.frame(in: .named("layerSidebar"))])
1594
+ }
1595
+ )
1495
1596
  }
1496
1597
 
1497
- private func windowSetsSection(editor: ScreenMapEditorState) -> some View {
1498
- let sets = controller.windowSets
1499
- let canSave = !controller.selectedWindowIds.isEmpty
1598
+ private func toggleExpandedLayer(_ layer: Int) {
1599
+ if expandedLayers.contains(layer) {
1600
+ expandedLayers.remove(layer)
1601
+ } else {
1602
+ expandedLayers.insert(layer)
1603
+ }
1604
+ }
1500
1605
 
1501
- return VStack(alignment: .leading, spacing: 4) {
1502
- HStack(spacing: 6) {
1503
- Text("SETS")
1606
+ private func selectSidebarLayer(_ layer: Int, editor: ScreenMapEditorState) {
1607
+ if NSEvent.modifierFlags.contains(.command) {
1608
+ editor.toggleLayerSelection(layer)
1609
+ } else {
1610
+ editor.selectLayer(layer)
1611
+ }
1612
+ expandedLayers.insert(layer)
1613
+ }
1614
+
1615
+ private func selectSidebarWindow(_ windowId: UInt32) {
1616
+ if NSEvent.modifierFlags.contains(.command) {
1617
+ controller.toggleSelection(windowId)
1618
+ } else {
1619
+ controller.selectSingle(windowId)
1620
+ }
1621
+ }
1622
+
1623
+ private func resolveSidebarDropTarget(at point: CGPoint, excluding layer: Int) -> Int? {
1624
+ for (candidate, frame) in layerRowFrames where candidate != layer {
1625
+ if frame.contains(point) {
1626
+ return candidate
1627
+ }
1628
+ }
1629
+ return nil
1630
+ }
1631
+
1632
+ private func handleSidebarWindowDragChanged(_ value: DragGesture.Value, sourceLayer: Int, windowId: UInt32) {
1633
+ sidebarDragWindowId = windowId
1634
+ sidebarDragOffset = value.translation
1635
+ controller.selectSingle(windowId)
1636
+ dropTargetLayer = resolveSidebarDropTarget(at: value.location, excluding: sourceLayer)
1637
+ }
1638
+
1639
+ private func finishSidebarWindowDrag(_ win: ScreenMapWindowEntry, editor: ScreenMapEditorState) {
1640
+ if let targetLayer = dropTargetLayer {
1641
+ editor.reassignLayer(windowId: win.id, toLayer: targetLayer, fitToAvailable: true)
1642
+ controller.flash("Moved to L\(targetLayer)")
1643
+ }
1644
+ sidebarDragWindowId = nil
1645
+ sidebarDragOffset = .zero
1646
+ dropTargetLayer = nil
1647
+ }
1648
+
1649
+ private func collapsibleSection<Content: View>(title: String, count: Int, isExpanded: Binding<Bool>,
1650
+ @ViewBuilder content: @escaping () -> Content) -> some View {
1651
+ VStack(spacing: 0) {
1652
+ HStack(spacing: 4) {
1653
+ Image(systemName: isExpanded.wrappedValue ? "chevron.down" : "chevron.right")
1654
+ .font(.system(size: 6, weight: .bold))
1655
+ .foregroundColor(Palette.textMuted)
1656
+ Text(title)
1504
1657
  .font(Typo.monoBold(8))
1505
1658
  .foregroundColor(Palette.textMuted)
1506
- Text("\(sets.count)")
1659
+ Text("\(count)")
1507
1660
  .font(Typo.mono(7))
1508
1661
  .foregroundColor(Palette.textDim)
1509
1662
  Spacer()
1510
- Button {
1511
- controller.createWindowSetFromSelection()
1512
- } label: {
1513
- Text("u save")
1514
- .font(Typo.monoBold(7))
1515
- .foregroundColor(canSave ? Self.shelfGreen : Palette.textMuted)
1516
- .padding(.horizontal, 5)
1517
- .padding(.vertical, 3)
1518
- .background(
1519
- RoundedRectangle(cornerRadius: 4)
1520
- .fill(canSave ? Self.shelfGreen.opacity(0.12) : Palette.surface.opacity(0.7))
1521
- .overlay(
1522
- RoundedRectangle(cornerRadius: 4)
1523
- .strokeBorder(canSave ? Self.shelfGreen.opacity(0.25) : Palette.border, lineWidth: 0.5)
1524
- )
1525
- )
1526
- }
1527
- .buttonStyle(.plain)
1528
- .disabled(!canSave)
1529
1663
  }
1664
+ .padding(.vertical, 4)
1665
+ .contentShape(Rectangle())
1666
+ .simultaneousGesture(TapGesture().onEnded { isExpanded.wrappedValue.toggle() })
1530
1667
 
1668
+ if isExpanded.wrappedValue {
1669
+ content()
1670
+ .padding(.top, 4)
1671
+ }
1672
+ }
1673
+ .padding(.bottom, 4)
1674
+ }
1675
+
1676
+ private func windowSetsSection(editor: ScreenMapEditorState) -> some View {
1677
+ let sets = controller.windowSets
1678
+ let canSave = !controller.selectedWindowIds.isEmpty
1679
+
1680
+ return VStack(alignment: .leading, spacing: 4) {
1531
1681
  if sets.isEmpty {
1532
- Text("Save a selection to create a reusable cluster.")
1533
- .font(Typo.mono(7))
1534
- .foregroundColor(Palette.textMuted)
1535
- .padding(.top, 2)
1682
+ HStack {
1683
+ Text("No sets yet.")
1684
+ .font(Typo.mono(7))
1685
+ .foregroundColor(Palette.textMuted)
1686
+ Spacer()
1687
+ Button {
1688
+ controller.createWindowSetFromSelection()
1689
+ } label: {
1690
+ Text("u save")
1691
+ .font(Typo.monoBold(7))
1692
+ .foregroundColor(canSave ? Self.shelfGreen : Palette.textMuted)
1693
+ }
1694
+ .buttonStyle(.plain)
1695
+ .disabled(!canSave)
1696
+ }
1536
1697
  } else {
1537
1698
  ForEach(sets) { set in
1538
1699
  windowSetRow(set: set, editor: editor)
1539
1700
  }
1540
1701
  }
1541
1702
  }
1542
- .padding(6)
1543
- .background(
1544
- RoundedRectangle(cornerRadius: 6)
1545
- .fill(Color.black.opacity(0.4))
1546
- .overlay(
1547
- RoundedRectangle(cornerRadius: 6)
1548
- .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
1549
- )
1550
- )
1551
1703
  }
1552
1704
 
1553
1705
  private func windowSetRow(set: ScreenMapWindowSet, editor: ScreenMapEditorState) -> some View {
@@ -1642,118 +1794,60 @@ struct ScreenMapView: View {
1642
1794
  .fill(isActive ? color.opacity(0.12) : Color.clear)
1643
1795
  )
1644
1796
  .contentShape(Rectangle())
1645
- .onTapGesture { action() }
1797
+ .simultaneousGesture(TapGesture().onEnded { action() })
1646
1798
  }
1647
1799
 
1648
1800
  // MARK: - Canvas
1649
1801
 
1650
1802
  private func screenMapCanvas(editor: ScreenMapEditorState?) -> some View {
1651
1803
  let isFocused = editor?.focusedDisplayIndex != nil
1652
- let allWindows = isFocused ? (editor?.focusedVisibleWindows ?? []) : (editor?.visibleWindows ?? [])
1804
+ let canvasWindows = editor?.renderedCanvasWindows ?? []
1653
1805
  let displays = editor?.displays ?? []
1654
1806
  let zoomLevel = editor?.zoomLevel ?? 1.0
1655
1807
  let panOffset = editor?.panOffset ?? .zero
1656
1808
 
1657
1809
  return GeometryReader { geo in
1658
- let availW = geo.size.width - 24
1659
- let availH = geo.size.height - 16
1660
-
1661
- let bboxPad: CGFloat = (!isFocused && displays.count > 1) ? 40 : 0
1662
- let bbox: CGRect = {
1663
- if let editor {
1664
- return editor.canvasWorldBounds
1665
- }
1666
- guard !displays.isEmpty else {
1667
- let s = NSScreen.main?.frame ?? CGRect(x: 0, y: 0, width: 1920, height: 1080)
1668
- return CGRect(origin: .zero, size: s.size)
1669
- }
1670
- var union = displays[0].cgRect
1671
- for d in displays.dropFirst() { union = union.union(d.cgRect) }
1672
- return union.insetBy(dx: -bboxPad, dy: -bboxPad)
1673
- }()
1674
- let bboxOriginPt = bbox.origin
1675
- let screenW = bbox.width
1676
- let screenH = bbox.height
1677
-
1678
- let fitScale = min(availW / screenW, availH / screenH)
1679
- let effScale = fitScale * zoomLevel
1680
- let mapW = screenW * effScale
1681
- let mapH = screenH * effScale
1682
- let centerX = (geo.size.width - mapW) / 2
1683
- let centerY = (geo.size.height - mapH) / 2
1810
+ let metrics = CanvasMetrics(editor: editor, displays: displays, viewportSize: geo.size)
1811
+ let syncKey = CanvasSyncKey(
1812
+ viewportSize: geo.size,
1813
+ worldBounds: metrics.worldBounds,
1814
+ zoomLevel: zoomLevel,
1815
+ navigationRevision: editor?.canvasNavigationRevision ?? 0
1816
+ )
1684
1817
 
1685
1818
  ZStack(alignment: .topLeading) {
1686
1819
  // Per-display background rectangles
1687
- if isFocused, let focused = editor?.focusedDisplay, let editor = editor {
1688
- focusedDisplayBackground(focused: focused, editor: editor, mapW: mapW, mapH: mapH)
1820
+ if isFocused, editor?.focusedDisplay != nil {
1821
+ focusedDisplayBackground(mapSize: metrics.mapSize)
1689
1822
  } else if displays.count > 1 {
1690
- multiDisplayBackgrounds(displays: displays, editor: editor, effScale: effScale, bboxOrigin: bboxOriginPt)
1823
+ multiDisplayBackgrounds(displays: displays, editor: editor, metrics: metrics)
1691
1824
  } else {
1692
- singleDisplayBackground(displays: displays, mapW: mapW, mapH: mapH)
1825
+ singleDisplayBackground(mapSize: metrics.mapSize)
1693
1826
  }
1694
1827
 
1695
1828
  // Ghost outlines for edited windows
1696
- ForEach(allWindows.filter(\.hasEdits)) { win in
1697
- let f = win.originalFrame
1698
- let x = (f.origin.x - bboxOriginPt.x) * effScale
1699
- let y = (f.origin.y - bboxOriginPt.y) * effScale
1700
- let w = max(f.width * effScale, 4)
1701
- let h = max(f.height * effScale, 4)
1829
+ ForEach(canvasWindows.filter(\.hasEdits)) { win in
1830
+ let rect = metrics.mapRect(for: win.originalFrame)
1702
1831
 
1703
1832
  RoundedRectangle(cornerRadius: 2)
1704
1833
  .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
1705
1834
  .foregroundColor(Palette.textMuted.opacity(0.4))
1706
- .frame(width: w, height: h)
1707
- .offset(x: x, y: y)
1835
+ .frame(width: rect.width, height: rect.height)
1836
+ .offset(x: rect.minX, y: rect.minY)
1708
1837
  }
1709
1838
 
1710
1839
  // Live windows back-to-front
1711
- ForEach(Array(allWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
1712
- windowTile(win: win, editor: editor, scale: effScale, bboxOrigin: bboxOriginPt)
1840
+ ForEach(Array(canvasWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
1841
+ windowTile(win: win, editor: editor, metrics: metrics)
1713
1842
  }
1714
1843
  }
1715
- .frame(width: mapW, height: mapH)
1716
- .offset(x: centerX + panOffset.x, y: centerY + panOffset.y)
1844
+ .frame(width: metrics.mapSize.width, height: metrics.mapSize.height)
1845
+ .offset(x: metrics.centerOffset.x + panOffset.x, y: metrics.centerOffset.y + panOffset.y)
1717
1846
  .onAppear {
1718
- syncCanvasGeometry(editor: editor, fitScale: fitScale, scale: effScale,
1719
- offsetX: centerX, offsetY: centerY,
1720
- viewportSize: CGSize(width: max(geo.size.width - 16, 1), height: max(geo.size.height - 16, 1)),
1721
- screenSize: CGSize(width: screenW, height: screenH),
1722
- bboxOrigin: bboxOriginPt)
1723
- }
1724
- .onChange(of: geo.size) { _ in
1725
- let newFitScale = min((geo.size.width - 24) / screenW, (geo.size.height - 16) / screenH)
1726
- let newEffScale = newFitScale * zoomLevel
1727
- let newMapW = screenW * newEffScale
1728
- let newMapH = screenH * newEffScale
1729
- let newCX = (geo.size.width - newMapW) / 2
1730
- let newCY = (geo.size.height - newMapH) / 2
1731
- syncCanvasGeometry(editor: editor, fitScale: newFitScale, scale: newEffScale,
1732
- offsetX: newCX, offsetY: newCY,
1733
- viewportSize: CGSize(width: max(geo.size.width - 16, 1), height: max(geo.size.height - 16, 1)),
1734
- screenSize: CGSize(width: screenW, height: screenH),
1735
- bboxOrigin: bboxOriginPt)
1736
- }
1737
- .onChange(of: bbox) { _ in
1738
- syncCanvasGeometry(editor: editor, fitScale: fitScale, scale: effScale,
1739
- offsetX: centerX, offsetY: centerY,
1740
- viewportSize: CGSize(width: max(geo.size.width - 16, 1), height: max(geo.size.height - 16, 1)),
1741
- screenSize: CGSize(width: screenW, height: screenH),
1742
- bboxOrigin: bboxOriginPt)
1743
- }
1744
- .onChange(of: zoomLevel) { _ in
1745
- syncCanvasGeometry(editor: editor, fitScale: fitScale, scale: effScale,
1746
- offsetX: centerX, offsetY: centerY,
1747
- viewportSize: CGSize(width: max(geo.size.width - 16, 1), height: max(geo.size.height - 16, 1)),
1748
- screenSize: CGSize(width: screenW, height: screenH),
1749
- bboxOrigin: bboxOriginPt)
1750
- }
1751
- .onChange(of: editor?.canvasNavigationRevision ?? 0) { _ in
1752
- syncCanvasGeometry(editor: editor, fitScale: fitScale, scale: effScale,
1753
- offsetX: centerX, offsetY: centerY,
1754
- viewportSize: CGSize(width: max(geo.size.width - 16, 1), height: max(geo.size.height - 16, 1)),
1755
- screenSize: CGSize(width: screenW, height: screenH),
1756
- bboxOrigin: bboxOriginPt)
1847
+ syncCanvasGeometry(editor: editor, metrics: metrics)
1848
+ }
1849
+ .onChange(of: syncKey) { _ in
1850
+ syncCanvasGeometry(editor: editor, metrics: metrics)
1757
1851
  }
1758
1852
  }
1759
1853
  .padding(8)
@@ -1805,7 +1899,7 @@ struct ScreenMapView: View {
1805
1899
 
1806
1900
  // MARK: - Display Backgrounds
1807
1901
 
1808
- private func focusedDisplayBackground(focused: DisplayGeometry, editor: ScreenMapEditorState, mapW: CGFloat, mapH: CGFloat) -> some View {
1902
+ private func focusedDisplayBackground(mapSize: CGSize) -> some View {
1809
1903
  ZStack(alignment: .topLeading) {
1810
1904
  RoundedRectangle(cornerRadius: 6)
1811
1905
  .fill(Palette.bg.opacity(0.5))
@@ -1816,15 +1910,12 @@ struct ScreenMapView: View {
1816
1910
  .contentShape(Rectangle())
1817
1911
  .onTapGesture { controller.clearSelection() }
1818
1912
  }
1819
- .frame(width: mapW, height: mapH)
1913
+ .frame(width: mapSize.width, height: mapSize.height)
1820
1914
  }
1821
1915
 
1822
- private func multiDisplayBackgrounds(displays: [DisplayGeometry], editor: ScreenMapEditorState?, effScale: CGFloat, bboxOrigin: CGPoint) -> some View {
1916
+ private func multiDisplayBackgrounds(displays: [DisplayGeometry], editor: ScreenMapEditorState?, metrics: CanvasMetrics) -> some View {
1823
1917
  ForEach(displays, id: \.index) { disp in
1824
- let dx = (disp.cgRect.origin.x - bboxOrigin.x) * effScale
1825
- let dy = (disp.cgRect.origin.y - bboxOrigin.y) * effScale
1826
- let dw = disp.cgRect.width * effScale
1827
- let dh = disp.cgRect.height * effScale
1918
+ let frame = metrics.mapRect(for: disp.cgRect, minimumSize: 12)
1828
1919
  let bezel: CGFloat = 3
1829
1920
 
1830
1921
  ZStack {
@@ -1862,15 +1953,14 @@ struct ScreenMapView: View {
1862
1953
  }
1863
1954
  .contentShape(Rectangle())
1864
1955
  .onTapGesture {
1865
- editor?.focusDisplay(disp.index)
1866
- controller.objectWillChange.send()
1956
+ controller.setDisplayFocus(disp.index)
1867
1957
  }
1868
- .frame(width: dw, height: dh)
1869
- .offset(x: dx, y: dy)
1958
+ .frame(width: frame.width, height: frame.height)
1959
+ .offset(x: frame.minX, y: frame.minY)
1870
1960
  }
1871
1961
  }
1872
1962
 
1873
- private func singleDisplayBackground(displays: [DisplayGeometry], mapW: CGFloat, mapH: CGFloat) -> some View {
1963
+ private func singleDisplayBackground(mapSize: CGSize) -> some View {
1874
1964
  ZStack(alignment: .topLeading) {
1875
1965
  RoundedRectangle(cornerRadius: 6)
1876
1966
  .fill(Palette.bg.opacity(0.5))
@@ -1882,18 +1972,16 @@ struct ScreenMapView: View {
1882
1972
  .onTapGesture { controller.clearSelection() }
1883
1973
 
1884
1974
  }
1885
- .frame(width: mapW, height: mapH)
1975
+ .frame(width: mapSize.width, height: mapSize.height)
1886
1976
  }
1887
1977
 
1888
1978
  // MARK: - Window Tile
1889
1979
 
1890
1980
  @ViewBuilder
1891
- private func windowTile(win: ScreenMapWindowEntry, editor: ScreenMapEditorState?, scale: CGFloat, bboxOrigin: CGPoint = .zero) -> some View {
1892
- let f = win.virtualFrame
1893
- let x = (f.origin.x - bboxOrigin.x) * scale
1894
- let y = (f.origin.y - bboxOrigin.y) * scale
1895
- let w = max(f.width * scale, 4)
1896
- let h = max(f.height * scale, 4)
1981
+ private func windowTile(win: ScreenMapWindowEntry, editor: ScreenMapEditorState?, metrics: CanvasMetrics) -> some View {
1982
+ let rect = metrics.mapRect(for: win.virtualFrame)
1983
+ let w = rect.width
1984
+ let h = rect.height
1897
1985
  let isSelected = controller.selectedWindowIds.contains(win.id)
1898
1986
  let isDragging = editor?.draggingWindowId == win.id
1899
1987
  let isInActiveLayer = editor?.isLayerSelected(win.layer) ?? true
@@ -1999,7 +2087,7 @@ struct ScreenMapView: View {
1999
2087
  .shadow(color: Self.shelfGreen.opacity(0.5), radius: 6)
2000
2088
  }
2001
2089
  }
2002
- .offset(x: x, y: y)
2090
+ .offset(x: rect.minX, y: rect.minY)
2003
2091
  .opacity(isInActiveLayer ? 1.0 : 0.3)
2004
2092
  .shadow(color: isDragging ? Palette.running.opacity(0.4) : .clear,
2005
2093
  radius: isDragging ? 6 : 0)
@@ -2088,11 +2176,7 @@ struct ScreenMapView: View {
2088
2176
  let pct = Int(editor.zoomLevel * 100)
2089
2177
  return HStack(spacing: 0) {
2090
2178
  Button {
2091
- let newZoom = max(ScreenMapEditorState.minZoom, editor.zoomLevel - 0.25)
2092
- editor.activeViewportPreset = nil
2093
- editor.zoomLevel = newZoom
2094
- editor.objectWillChange.send()
2095
- controller.objectWillChange.send()
2179
+ controller.adjustZoom(by: -0.25)
2096
2180
  } label: {
2097
2181
  Image(systemName: "minus")
2098
2182
  .font(.system(size: 9, weight: .medium))
@@ -2116,11 +2200,7 @@ struct ScreenMapView: View {
2116
2200
  Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
2117
2201
 
2118
2202
  Button {
2119
- let newZoom = min(ScreenMapEditorState.maxZoom, editor.zoomLevel + 0.25)
2120
- editor.activeViewportPreset = nil
2121
- editor.zoomLevel = newZoom
2122
- editor.objectWillChange.send()
2123
- controller.objectWillChange.send()
2203
+ controller.adjustZoom(by: 0.25)
2124
2204
  } label: {
2125
2205
  Image(systemName: "plus")
2126
2206
  .font(.system(size: 9, weight: .medium))
@@ -2325,164 +2405,115 @@ struct ScreenMapView: View {
2325
2405
  @ViewBuilder
2326
2406
  private func sidebarMiniMap(editor: ScreenMapEditorState) -> some View {
2327
2407
  let displays = editor.displays
2328
- let windows = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.visibleWindows
2329
- let world = editor.canvasWorldBounds
2330
- let viewport = editor.viewportWorldRect
2408
+ let windows = editor.renderedCanvasWindows
2331
2409
  let miniW: CGFloat = sidebarWidth - 28
2332
2410
  let miniH: CGFloat = 118
2333
- let scaleW = miniW / max(world.width, 1)
2334
- let scaleH = miniH / max(world.height, 1)
2335
- let scale = min(scaleW, scaleH)
2336
- let drawW = world.width * scale
2337
- let drawH = world.height * scale
2338
- let offsetX = (miniW - drawW) / 2
2339
- let offsetY = (miniH - drawH) / 2
2340
-
2341
- VStack(alignment: .leading, spacing: 6) {
2342
- HStack(spacing: 6) {
2343
- Text("MAP")
2344
- .font(Typo.monoBold(8))
2345
- .foregroundColor(Palette.textMuted)
2346
- Spacer()
2347
- Text("drag to pan")
2348
- .font(Typo.mono(7))
2349
- .foregroundColor(Palette.textMuted)
2350
- }
2411
+ let metrics = MiniMapMetrics(
2412
+ worldBounds: editor.canvasWorldBounds,
2413
+ canvasSize: CGSize(width: miniW, height: miniH)
2414
+ )
2351
2415
 
2352
- ZStack(alignment: .topLeading) {
2353
- RoundedRectangle(cornerRadius: 6)
2354
- .fill(Color.black.opacity(0.28))
2416
+ sidebarPanel {
2417
+ VStack(alignment: .leading, spacing: 6) {
2418
+ HStack(spacing: 6) {
2419
+ Text("MAP")
2420
+ .font(Typo.monoBold(8))
2421
+ .foregroundColor(Palette.textMuted)
2422
+ Spacer()
2423
+ Text("drag to pan")
2424
+ .font(Typo.mono(7))
2425
+ .foregroundColor(Palette.textMuted)
2426
+ }
2355
2427
 
2356
2428
  ZStack(alignment: .topLeading) {
2357
- RoundedRectangle(cornerRadius: 5)
2358
- .fill(Palette.bg.opacity(0.35))
2359
- .frame(width: drawW, height: drawH)
2360
- .offset(x: offsetX, y: offsetY)
2429
+ RoundedRectangle(cornerRadius: 6)
2430
+ .fill(Color.black.opacity(0.28))
2361
2431
 
2362
- ForEach(displays, id: \.index) { disp in
2363
- let dx = (disp.cgRect.origin.x - world.origin.x) * scale + offsetX
2364
- let dy = (disp.cgRect.origin.y - world.origin.y) * scale + offsetY
2365
- let dw = disp.cgRect.width * scale
2366
- let dh = disp.cgRect.height * scale
2367
- let isFocused = editor.focusedDisplayIndex == nil || editor.focusedDisplayIndex == disp.index
2432
+ ZStack(alignment: .topLeading) {
2433
+ RoundedRectangle(cornerRadius: 5)
2434
+ .fill(Palette.bg.opacity(0.35))
2435
+ .frame(width: metrics.drawSize.width, height: metrics.drawSize.height)
2436
+ .offset(x: metrics.offset.x, y: metrics.offset.y)
2368
2437
 
2369
- RoundedRectangle(cornerRadius: 3)
2370
- .fill(isFocused ? Color.white.opacity(0.05) : Color.white.opacity(0.02))
2371
- .overlay(
2372
- RoundedRectangle(cornerRadius: 3)
2373
- .strokeBorder(
2374
- editor.focusedDisplayIndex == disp.index ? Palette.running.opacity(0.55) : Color.white.opacity(0.12),
2375
- lineWidth: editor.focusedDisplayIndex == disp.index ? 1 : 0.5
2376
- )
2377
- )
2378
- .frame(width: max(dw, 12), height: max(dh, 12))
2379
- .offset(x: dx, y: dy)
2380
- }
2438
+ ForEach(displays, id: \.index) { disp in
2439
+ let rect = metrics.rect(for: disp.cgRect, minimumSize: 12)
2440
+ let isFocused = editor.focusedDisplayIndex == nil || editor.focusedDisplayIndex == disp.index
2381
2441
 
2382
- ForEach(Array(windows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
2383
- let rect = win.virtualFrame
2384
- let x = (rect.origin.x - world.origin.x) * scale + offsetX
2385
- let y = (rect.origin.y - world.origin.y) * scale + offsetY
2386
- let w = max(rect.width * scale, 2)
2387
- let h = max(rect.height * scale, 2)
2388
- let isSelected = controller.selectedWindowIds.contains(win.id)
2442
+ RoundedRectangle(cornerRadius: 3)
2443
+ .fill(isFocused ? Color.white.opacity(0.05) : Color.white.opacity(0.02))
2444
+ .overlay(
2445
+ RoundedRectangle(cornerRadius: 3)
2446
+ .strokeBorder(
2447
+ editor.focusedDisplayIndex == disp.index ? Palette.running.opacity(0.55) : Color.white.opacity(0.12),
2448
+ lineWidth: editor.focusedDisplayIndex == disp.index ? 1 : 0.5
2449
+ )
2450
+ )
2451
+ .frame(width: rect.width, height: rect.height)
2452
+ .offset(x: rect.minX, y: rect.minY)
2453
+ }
2389
2454
 
2390
- RoundedRectangle(cornerRadius: 1.5)
2391
- .fill((isSelected ? Palette.running : Self.layerColor(for: win.layer)).opacity(isSelected ? 0.35 : 0.18))
2392
- .overlay(
2393
- RoundedRectangle(cornerRadius: 1.5)
2394
- .strokeBorder(isSelected ? Palette.running.opacity(0.85) : Color.white.opacity(0.12), lineWidth: isSelected ? 1 : 0.5)
2395
- )
2396
- .frame(width: w, height: h)
2397
- .offset(x: x, y: y)
2398
- }
2455
+ ForEach(Array(windows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
2456
+ let rect = metrics.rect(for: win.virtualFrame, minimumSize: 2)
2457
+ let isSelected = controller.selectedWindowIds.contains(win.id)
2399
2458
 
2400
- let viewportX = (viewport.origin.x - world.origin.x) * scale + offsetX
2401
- let viewportY = (viewport.origin.y - world.origin.y) * scale + offsetY
2402
- let viewportW = max(viewport.width * scale, 12)
2403
- let viewportH = max(viewport.height * scale, 12)
2459
+ RoundedRectangle(cornerRadius: 1.5)
2460
+ .fill((isSelected ? Palette.running : Self.layerColor(for: win.layer)).opacity(isSelected ? 0.35 : 0.18))
2461
+ .overlay(
2462
+ RoundedRectangle(cornerRadius: 1.5)
2463
+ .strokeBorder(isSelected ? Palette.running.opacity(0.85) : Color.white.opacity(0.12), lineWidth: isSelected ? 1 : 0.5)
2464
+ )
2465
+ .frame(width: rect.width, height: rect.height)
2466
+ .offset(x: rect.minX, y: rect.minY)
2467
+ }
2404
2468
 
2405
- RoundedRectangle(cornerRadius: 4)
2406
- .strokeBorder(Palette.running.opacity(0.9), lineWidth: 1.25)
2407
- .background(
2408
- RoundedRectangle(cornerRadius: 4)
2409
- .fill(Palette.running.opacity(0.08))
2410
- )
2411
- .frame(width: viewportW, height: viewportH)
2412
- .offset(x: viewportX, y: viewportY)
2413
- }
2414
- }
2415
- .frame(width: miniW, height: miniH)
2416
- .clipShape(RoundedRectangle(cornerRadius: 6))
2417
- .contentShape(Rectangle())
2418
- .gesture(
2419
- DragGesture(minimumDistance: 0)
2420
- .onChanged { value in
2421
- let localX = min(max(value.location.x - offsetX, 0), drawW)
2422
- let localY = min(max(value.location.y - offsetY, 0), drawH)
2423
- let worldPoint = CGPoint(
2424
- x: world.origin.x + localX / max(scale, 0.0001),
2425
- y: world.origin.y + localY / max(scale, 0.0001)
2426
- )
2427
- controller.recenterViewport(at: worldPoint)
2469
+ let viewportRect = metrics.rect(for: editor.viewportWorldRect, minimumSize: 12)
2470
+
2471
+ RoundedRectangle(cornerRadius: 4)
2472
+ .strokeBorder(Palette.running.opacity(0.9), lineWidth: 1.25)
2473
+ .background(
2474
+ RoundedRectangle(cornerRadius: 4)
2475
+ .fill(Palette.running.opacity(0.08))
2476
+ )
2477
+ .frame(width: viewportRect.width, height: viewportRect.height)
2478
+ .offset(x: viewportRect.minX, y: viewportRect.minY)
2428
2479
  }
2429
- )
2480
+ }
2481
+ .frame(width: miniW, height: miniH)
2482
+ .clipShape(RoundedRectangle(cornerRadius: 6))
2483
+ .contentShape(Rectangle())
2484
+ .gesture(
2485
+ DragGesture(minimumDistance: 0)
2486
+ .onChanged { value in
2487
+ controller.recenterViewport(at: metrics.worldPoint(for: value.location))
2488
+ }
2489
+ )
2430
2490
 
2431
- HStack(spacing: 6) {
2432
- mapScopePill("ALL", isActive: editor.focusedDisplayIndex == nil) {
2433
- controller.focusCanvas(on: editor.canvasWorldBounds, focusDisplay: nil, zoomToFit: true)
2434
- }
2435
- ForEach(editor.spatialDisplayOrder, id: \.index) { disp in
2436
- mapScopePill("\(editor.spatialNumber(for: disp.index))", isActive: editor.focusedDisplayIndex == disp.index) {
2437
- controller.focusCanvas(
2438
- on: editor.canvasExplorerRegions.first(where: { $0.kind == .display && $0.displayIndex == disp.index })?.rect ?? disp.cgRect,
2439
- focusDisplay: disp.index,
2440
- zoomToFit: true
2441
- )
2491
+ HStack(spacing: 6) {
2492
+ mapScopePill("ALL", isActive: editor.focusedDisplayIndex == nil) {
2493
+ controller.focusCanvas(on: editor.canvasWorldBounds, focusDisplay: nil, zoomToFit: true)
2494
+ }
2495
+ ForEach(editor.spatialDisplayOrder, id: \.index) { disp in
2496
+ mapScopePill("\(editor.spatialNumber(for: disp.index))", isActive: editor.focusedDisplayIndex == disp.index) {
2497
+ controller.focusCanvas(
2498
+ on: editor.displayRegion(for: disp.index)?.rect ?? disp.cgRect,
2499
+ focusDisplay: disp.index,
2500
+ zoomToFit: true
2501
+ )
2502
+ }
2442
2503
  }
2443
2504
  }
2444
2505
  }
2445
2506
  }
2446
- .padding(6)
2447
- .background(
2448
- RoundedRectangle(cornerRadius: 6)
2449
- .fill(Color.black.opacity(0.4))
2450
- .overlay(
2451
- RoundedRectangle(cornerRadius: 6)
2452
- .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
2453
- )
2454
- )
2455
2507
  }
2456
2508
 
2457
2509
  private func canvasExplorer(editor: ScreenMapEditorState) -> some View {
2458
2510
  let regions = editor.canvasExplorerRegions
2459
2511
 
2460
2512
  return VStack(alignment: .leading, spacing: 4) {
2461
- HStack {
2462
- Text("EXPLORER")
2463
- .font(Typo.monoBold(8))
2464
- .foregroundColor(Palette.textMuted)
2465
- Spacer()
2466
- if let viewport = controller.editor?.viewportWorldRect {
2467
- Text("\(Int(viewport.midX)),\(Int(viewport.midY))")
2468
- .font(Typo.mono(7))
2469
- .foregroundColor(Palette.textMuted)
2470
- }
2471
- }
2472
-
2473
2513
  ForEach(regions.prefix(8)) { region in
2474
2514
  canvasExplorerRow(region: region)
2475
2515
  }
2476
2516
  }
2477
- .padding(6)
2478
- .background(
2479
- RoundedRectangle(cornerRadius: 6)
2480
- .fill(Color.black.opacity(0.4))
2481
- .overlay(
2482
- RoundedRectangle(cornerRadius: 6)
2483
- .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
2484
- )
2485
- )
2486
2517
  }
2487
2518
 
2488
2519
  private func canvasExplorerRow(region: ScreenMapCanvasRegion) -> some View {
@@ -2591,16 +2622,27 @@ struct ScreenMapView: View {
2591
2622
 
2592
2623
  // MARK: - Helpers
2593
2624
 
2594
- private func syncCanvasGeometry(editor: ScreenMapEditorState?, fitScale: CGFloat? = nil, scale: CGFloat,
2595
- offsetX: CGFloat, offsetY: CGFloat,
2596
- viewportSize: CGSize,
2597
- screenSize: CGSize, bboxOrigin: CGPoint = .zero) {
2598
- if let fs = fitScale { editor?.fitScale = fs }
2599
- editor?.scale = scale
2600
- editor?.mapOrigin = CGPoint(x: offsetX, y: offsetY)
2601
- editor?.viewportSize = viewportSize
2602
- editor?.screenSize = screenSize
2603
- editor?.bboxOrigin = bboxOrigin
2625
+ @ViewBuilder
2626
+ private func sidebarPanel<Content: View>(@ViewBuilder content: () -> Content) -> some View {
2627
+ content()
2628
+ .padding(6)
2629
+ .background(
2630
+ RoundedRectangle(cornerRadius: 6)
2631
+ .fill(Color.black.opacity(0.4))
2632
+ .overlay(
2633
+ RoundedRectangle(cornerRadius: 6)
2634
+ .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
2635
+ )
2636
+ )
2637
+ }
2638
+
2639
+ private func syncCanvasGeometry(editor: ScreenMapEditorState?, metrics: CanvasMetrics) {
2640
+ editor?.fitScale = metrics.fitScale
2641
+ editor?.scale = metrics.effectiveScale
2642
+ editor?.mapOrigin = metrics.centerOffset
2643
+ editor?.viewportSize = metrics.syncedViewportSize
2644
+ editor?.screenSize = metrics.worldBounds.size
2645
+ editor?.bboxOrigin = metrics.worldBounds.origin
2604
2646
  controller.applyPendingCanvasNavigationIfNeeded()
2605
2647
  }
2606
2648
 
@@ -2755,15 +2797,13 @@ struct ScreenMapView: View {
2755
2797
  return nil
2756
2798
  }
2757
2799
 
2758
- if let hitId = hoveredWindowId, let editor = controller.editor {
2759
- screenMapClickWindowId = hitId
2800
+ let flippedPt = flippedScreenPoint(event)
2801
+ if let editor = controller.editor,
2802
+ let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor),
2803
+ hoveredWindowId == hit.id {
2804
+ screenMapClickWindowId = hit.id
2760
2805
  screenMapClickPoint = event.locationInWindow
2761
- let flippedPt = flippedScreenPoint(event)
2762
- if let hit = screenMapHitTestWithRect(flippedScreenPt: flippedPt, editor: editor) {
2763
- editor.canvasDragMode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
2764
- } else {
2765
- editor.canvasDragMode = .move
2766
- }
2806
+ editor.canvasDragMode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
2767
2807
  } else {
2768
2808
  screenMapClickWindowId = nil
2769
2809
  }
@@ -2777,8 +2817,6 @@ struct ScreenMapView: View {
2777
2817
  let dy = event.locationInWindow.y - start.y
2778
2818
  editor.activeViewportPreset = nil
2779
2819
  editor.panOffset = CGPoint(x: spaceDragPanStart.x + dx, y: spaceDragPanStart.y - dy)
2780
- editor.objectWillChange.send()
2781
- controller.objectWillChange.send()
2782
2820
  return nil
2783
2821
  }
2784
2822
 
@@ -2849,8 +2887,6 @@ struct ScreenMapView: View {
2849
2887
  }
2850
2888
 
2851
2889
  editor.syncLayoutFrame(at: idx, to: newFrame)
2852
- editor.objectWillChange.send()
2853
- controller.objectWillChange.send()
2854
2890
  return nil
2855
2891
  }
2856
2892
 
@@ -2866,7 +2902,6 @@ struct ScreenMapView: View {
2866
2902
  editor.draggingWindowId = nil
2867
2903
  editor.dragStartFrame = nil
2868
2904
  editor.canvasDragMode = .move
2869
- editor.objectWillChange.send()
2870
2905
  }
2871
2906
  screenMapClickWindowId = nil
2872
2907
  }
@@ -2882,11 +2917,11 @@ struct ScreenMapView: View {
2882
2917
  let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
2883
2918
  guard canvasRect.contains(flippedPt) else { return event }
2884
2919
 
2885
- if let hitId = screenMapHitTest(flippedScreenPt: flippedPt, editor: editor) {
2886
- if !controller.isSelected(hitId) {
2887
- controller.selectSingle(hitId)
2920
+ if let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor) {
2921
+ if !controller.isSelected(hit.id) {
2922
+ controller.selectSingle(hit.id)
2888
2923
  }
2889
- showLayerContextMenu(for: hitId, at: event.locationInWindow, in: eventWindow, editor: editor)
2924
+ showLayerContextMenu(for: hit.id, at: event.locationInWindow, in: eventWindow, editor: editor)
2890
2925
  return nil
2891
2926
  }
2892
2927
  return event
@@ -2938,16 +2973,12 @@ struct ScreenMapView: View {
2938
2973
  editor.activeViewportPreset = nil
2939
2974
  editor.zoomLevel = newZoom
2940
2975
  editor.panOffset = CGPoint(x: newPanX, y: newPanY)
2941
- editor.objectWillChange.send()
2942
- controller.objectWillChange.send()
2943
2976
  } else {
2944
2977
  editor.activeViewportPreset = nil
2945
2978
  editor.panOffset = CGPoint(
2946
2979
  x: editor.panOffset.x + event.scrollingDeltaX,
2947
2980
  y: editor.panOffset.y - event.scrollingDeltaY
2948
2981
  )
2949
- editor.objectWillChange.send()
2950
- controller.objectWillChange.send()
2951
2982
  }
2952
2983
  return nil
2953
2984
  }
@@ -2967,7 +2998,7 @@ struct ScreenMapView: View {
2967
2998
  return event
2968
2999
  }
2969
3000
 
2970
- if let hit = screenMapHitTestWithRect(flippedScreenPt: flippedPt, editor: editor) {
3001
+ if let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor) {
2971
3002
  let mode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
2972
3003
  if mode != editor.currentCursorMode {
2973
3004
  if editor.currentCursorMode != .move { NSCursor.pop() }
@@ -3010,64 +3041,20 @@ struct ScreenMapView: View {
3010
3041
 
3011
3042
  // MARK: - Hit Test / Coordinate Conversion
3012
3043
 
3013
- private func screenMapHitTest(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> UInt32? {
3014
- let effScale = editor.effectiveScale
3015
- let origin = editor.mapOrigin
3016
- let panOffset = editor.panOffset
3017
- guard effScale > 0 else { return nil }
3018
-
3044
+ private func canvasHit(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> CanvasHit? {
3045
+ let projection = CanvasProjection(editor: editor)
3046
+ guard projection.scale > 0 else { return nil }
3019
3047
  let canvasLocal = CGPoint(
3020
3048
  x: flippedScreenPt.x - screenMapCanvasOrigin.x,
3021
3049
  y: flippedScreenPt.y - screenMapCanvasOrigin.y
3022
3050
  )
3023
- let mapPoint = CGPoint(
3024
- x: canvasLocal.x - 8 - origin.x - panOffset.x,
3025
- y: canvasLocal.y - 8 - origin.y - panOffset.y
3026
- )
3027
-
3028
- let bboxOrig = editor.bboxOrigin
3029
- let windowPool = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.windows
3030
- let sorted = windowPool.sorted(by: { $0.zIndex < $1.zIndex })
3051
+ let mapPoint = projection.mapPoint(forCanvasPoint: canvasLocal)
3052
+ let sorted = editor.renderedCanvasWindows.sorted(by: { $0.zIndex < $1.zIndex })
3031
3053
  for win in sorted {
3032
- let f = win.virtualFrame
3033
- let mapRect = CGRect(
3034
- x: (f.origin.x - bboxOrig.x) * effScale,
3035
- y: (f.origin.y - bboxOrig.y) * effScale,
3036
- width: max(f.width * effScale, 4),
3037
- height: max(f.height * effScale, 4)
3038
- )
3039
- if mapRect.contains(mapPoint) { return win.id }
3040
- }
3041
- return nil
3042
- }
3043
-
3044
- private func screenMapHitTestWithRect(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> (id: UInt32, mapRect: CGRect, mapPoint: CGPoint)? {
3045
- let effScale = editor.effectiveScale
3046
- let origin = editor.mapOrigin
3047
- let panOff = editor.panOffset
3048
- guard effScale > 0 else { return nil }
3049
-
3050
- let canvasLocal = CGPoint(
3051
- x: flippedScreenPt.x - screenMapCanvasOrigin.x,
3052
- y: flippedScreenPt.y - screenMapCanvasOrigin.y
3053
- )
3054
- let mapPoint = CGPoint(
3055
- x: canvasLocal.x - 8 - origin.x - panOff.x,
3056
- y: canvasLocal.y - 8 - origin.y - panOff.y
3057
- )
3058
-
3059
- let bboxOrig = editor.bboxOrigin
3060
- let windowPool = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.windows
3061
- let sorted = windowPool.sorted(by: { $0.zIndex < $1.zIndex })
3062
- for win in sorted {
3063
- let f = win.virtualFrame
3064
- let mapRect = CGRect(
3065
- x: (f.origin.x - bboxOrig.x) * effScale,
3066
- y: (f.origin.y - bboxOrig.y) * effScale,
3067
- width: max(f.width * effScale, 4),
3068
- height: max(f.height * effScale, 4)
3069
- )
3070
- if mapRect.contains(mapPoint) { return (win.id, mapRect, mapPoint) }
3054
+ let mapRect = projection.mapRect(for: win.virtualFrame)
3055
+ if mapRect.contains(mapPoint) {
3056
+ return CanvasHit(id: win.id, mapRect: mapRect, mapPoint: mapPoint)
3057
+ }
3071
3058
  }
3072
3059
  return nil
3073
3060
  }
@@ -3181,7 +3168,6 @@ final class ScreenMapMenuTarget: NSObject {
3181
3168
  @objc func performLayerMove(_ sender: NSMenuItem) {
3182
3169
  guard let action = sender.representedObject as? ScreenMapLayerMenuAction else { return }
3183
3170
  action.editor.reassignLayer(windowId: action.windowId, toLayer: action.targetLayer, fitToAvailable: true)
3184
- action.controller.objectWillChange.send()
3185
3171
  }
3186
3172
 
3187
3173
  @objc func performFocus(_ sender: NSMenuItem) {