@lattices/cli 0.3.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 (74) hide show
  1. package/README.md +155 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/AccessibilityTextExtractor.swift +111 -0
  5. package/app/Sources/ActionRow.swift +61 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +242 -0
  8. package/app/Sources/AppShellView.swift +62 -0
  9. package/app/Sources/AppTypeClassifier.swift +70 -0
  10. package/app/Sources/AppWindowShell.swift +63 -0
  11. package/app/Sources/CheatSheetHUD.swift +332 -0
  12. package/app/Sources/CommandModeState.swift +1362 -0
  13. package/app/Sources/CommandModeView.swift +1405 -0
  14. package/app/Sources/CommandModeWindow.swift +192 -0
  15. package/app/Sources/CommandPaletteView.swift +307 -0
  16. package/app/Sources/CommandPaletteWindow.swift +134 -0
  17. package/app/Sources/DaemonProtocol.swift +101 -0
  18. package/app/Sources/DaemonServer.swift +414 -0
  19. package/app/Sources/DesktopModel.swift +149 -0
  20. package/app/Sources/DesktopModelTypes.swift +71 -0
  21. package/app/Sources/DiagnosticLog.swift +271 -0
  22. package/app/Sources/EventBus.swift +30 -0
  23. package/app/Sources/HotkeyManager.swift +254 -0
  24. package/app/Sources/HotkeyStore.swift +338 -0
  25. package/app/Sources/InventoryManager.swift +35 -0
  26. package/app/Sources/InventoryPath.swift +43 -0
  27. package/app/Sources/KeyRecorderView.swift +210 -0
  28. package/app/Sources/LatticesApi.swift +1234 -0
  29. package/app/Sources/LayerBezel.swift +203 -0
  30. package/app/Sources/MainView.swift +479 -0
  31. package/app/Sources/MainWindow.swift +83 -0
  32. package/app/Sources/OcrModel.swift +430 -0
  33. package/app/Sources/OcrStore.swift +329 -0
  34. package/app/Sources/OmniSearchState.swift +283 -0
  35. package/app/Sources/OmniSearchView.swift +288 -0
  36. package/app/Sources/OmniSearchWindow.swift +105 -0
  37. package/app/Sources/OrphanRow.swift +129 -0
  38. package/app/Sources/PaletteCommand.swift +419 -0
  39. package/app/Sources/PermissionChecker.swift +125 -0
  40. package/app/Sources/Preferences.swift +99 -0
  41. package/app/Sources/ProcessModel.swift +199 -0
  42. package/app/Sources/ProcessQuery.swift +151 -0
  43. package/app/Sources/Project.swift +28 -0
  44. package/app/Sources/ProjectRow.swift +368 -0
  45. package/app/Sources/ProjectScanner.swift +128 -0
  46. package/app/Sources/ScreenMapState.swift +2387 -0
  47. package/app/Sources/ScreenMapView.swift +2820 -0
  48. package/app/Sources/ScreenMapWindowController.swift +89 -0
  49. package/app/Sources/SessionManager.swift +72 -0
  50. package/app/Sources/SettingsView.swift +1064 -0
  51. package/app/Sources/SettingsWindow.swift +20 -0
  52. package/app/Sources/TabGroupRow.swift +178 -0
  53. package/app/Sources/Terminal.swift +259 -0
  54. package/app/Sources/TerminalQuery.swift +156 -0
  55. package/app/Sources/TerminalSynthesizer.swift +200 -0
  56. package/app/Sources/Theme.swift +163 -0
  57. package/app/Sources/TilePickerView.swift +209 -0
  58. package/app/Sources/TmuxModel.swift +53 -0
  59. package/app/Sources/TmuxQuery.swift +81 -0
  60. package/app/Sources/WindowTiler.swift +1778 -0
  61. package/app/Sources/WorkspaceManager.swift +575 -0
  62. package/bin/client.js +4 -0
  63. package/bin/daemon-client.js +187 -0
  64. package/bin/lattices-app.js +221 -0
  65. package/bin/lattices.js +1551 -0
  66. package/docs/api.md +924 -0
  67. package/docs/app.md +297 -0
  68. package/docs/concepts.md +135 -0
  69. package/docs/config.md +245 -0
  70. package/docs/layers.md +410 -0
  71. package/docs/ocr.md +185 -0
  72. package/docs/overview.md +94 -0
  73. package/docs/quickstart.md +75 -0
  74. package/package.json +42 -0
@@ -0,0 +1,2820 @@
1
+ import SwiftUI
2
+ import AppKit
3
+
4
+ // MARK: - Screen Map View (Standalone)
5
+
6
+ struct ScreenMapView: View {
7
+ @ObservedObject var controller: ScreenMapController
8
+ var onNavigate: ((AppPage) -> Void)? = nil
9
+ @ObservedObject private var daemon = DaemonServer.shared
10
+ @State private var eventMonitor: Any?
11
+ @State private var mouseDownMonitor: Any?
12
+ @State private var mouseDragMonitor: Any?
13
+ @State private var mouseUpMonitor: Any?
14
+ @State private var rightClickMonitor: Any?
15
+ @State private var scrollWheelMonitor: Any?
16
+ @State private var screenMapCanvasOrigin: CGPoint = .zero
17
+ @State private var screenMapCanvasSize: CGSize = .zero
18
+ @State private var screenMapTitleBarHeight: CGFloat = 0 // reserved for coordinate math
19
+ @State private var screenMapClickWindowId: UInt32? = nil
20
+ @State private var screenMapClickPoint: NSPoint = .zero
21
+ @State private var hoveredWindowId: UInt32?
22
+ @State private var hoveredShelfAction: String?
23
+ @State private var dropTargetLayer: Int?
24
+ @State private var layerRowFrames: [Int: CGRect] = [:]
25
+ @State private var sidebarDragWindowId: UInt32? = nil
26
+ @State private var sidebarDragOffset: CGSize = .zero
27
+ @State private var expandedLayers: Set<Int> = []
28
+ @State private var mouseMovedMonitor: Any?
29
+ @State private var sidebarWidth: CGFloat = 180
30
+ @State private var isDraggingSidebar: Bool = false
31
+ @State private var inspectorWidth: CGFloat = 200
32
+ @State private var isDraggingInspector: Bool = false
33
+ @FocusState private var isSearchFieldFocused: Bool
34
+ @State private var searchHoveredDisplayIndex: Int? = nil
35
+ @State private var canvasTransitionOffset: CGFloat = 0
36
+ @State private var canvasTransitionOpacity: Double = 1.0
37
+ @State private var isSpaceHeld: Bool = false
38
+ @State private var spaceDragStart: NSPoint? = nil
39
+ @State private var spaceDragPanStart: CGPoint = .zero
40
+ @State private var flagsMonitor: Any?
41
+ @State private var searchOverlayFrame: CGRect = .zero
42
+
43
+ var body: some View {
44
+ VStack(spacing: 0) {
45
+ HStack(spacing: 0) {
46
+ if let editor = controller.editor {
47
+ layerSidebar(editor: editor)
48
+ panelResizeHandle(isActive: $isDraggingSidebar, width: $sidebarWidth,
49
+ range: 140...320, edge: .trailing)
50
+ }
51
+ ZStack {
52
+ VStack(spacing: 0) {
53
+ canvasHeaderBezel
54
+ screenMapCanvas(editor: controller.editor)
55
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
56
+ }
57
+ .offset(x: canvasTransitionOffset)
58
+ .opacity(canvasTransitionOpacity)
59
+ .onChange(of: controller.displayTransition) { direction in
60
+ guard direction != .none else { return }
61
+ let slideDistance: CGFloat = direction == .right ? -60 : 60
62
+ // Start from opposite side
63
+ canvasTransitionOffset = -slideDistance
64
+ canvasTransitionOpacity = 0.3
65
+ withAnimation(.easeOut(duration: 0.2)) {
66
+ canvasTransitionOffset = 0
67
+ canvasTransitionOpacity = 1.0
68
+ }
69
+ }
70
+ if controller.isSearchActive, let editor = controller.editor {
71
+ floatingSearchOverlay(editor: editor)
72
+ }
73
+ // Zoom controls — bottom-right corner of canvas
74
+ if let editor = controller.editor {
75
+ VStack {
76
+ Spacer()
77
+ HStack {
78
+ Spacer()
79
+ canvasZoomControls(editor: editor)
80
+ .padding(10)
81
+ }
82
+ }
83
+ }
84
+ }
85
+ if let editor = controller.editor {
86
+ panelResizeHandle(isActive: $isDraggingInspector, width: $inspectorWidth,
87
+ range: 160...360, edge: .leading)
88
+ inspectorPane(editor: editor)
89
+ }
90
+ }
91
+ footerBar
92
+ }
93
+ .background(Palette.bg)
94
+ .overlay(flashOverlay)
95
+ .onAppear {
96
+ installKeyHandler()
97
+ installMouseMonitors()
98
+ }
99
+ .onDisappear {
100
+ removeKeyHandler()
101
+ removeMouseMonitors()
102
+ }
103
+ .onChange(of: controller.editor?.isPreviewing) { isPreviewing in
104
+ handlePreviewChange(isPreviewing: isPreviewing ?? false)
105
+ }
106
+ }
107
+
108
+ // MARK: - Display Toolbar (floating in canvas)
109
+
110
+ private func displayToolbar(editor: ScreenMapEditorState) -> some View {
111
+ HStack(spacing: 4) {
112
+ Button {
113
+ editor.cyclePreviousDisplay()
114
+ controller.flash(editor.focusedDisplay?.label ?? "All displays")
115
+ controller.objectWillChange.send()
116
+ } label: {
117
+ Image(systemName: "chevron.left")
118
+ .font(.system(size: 8, weight: .semibold))
119
+ .foregroundColor(Palette.textDim)
120
+ .frame(width: 18, height: 18)
121
+ .contentShape(Rectangle())
122
+ }
123
+ .buttonStyle(.plain)
124
+
125
+ Button {
126
+ editor.focusDisplay(nil)
127
+ controller.objectWillChange.send()
128
+ } label: {
129
+ displayToolbarPill(name: "All", isActive: editor.focusedDisplayIndex == nil)
130
+ }
131
+ .buttonStyle(.plain)
132
+
133
+ ForEach(Array(editor.spatialDisplayOrder.enumerated()), id: \.element.index) { spatialPos, disp in
134
+ let isActive = editor.focusedDisplayIndex == disp.index
135
+ Button {
136
+ editor.focusDisplay(disp.index)
137
+ controller.objectWillChange.send()
138
+ } label: {
139
+ displayToolbarPill(
140
+ badge: spatialPos + 1,
141
+ name: disp.label,
142
+ isActive: isActive
143
+ )
144
+ }
145
+ .buttonStyle(.plain)
146
+ }
147
+
148
+ Button {
149
+ editor.cycleNextDisplay()
150
+ controller.flash(editor.focusedDisplay?.label ?? "All displays")
151
+ controller.objectWillChange.send()
152
+ } label: {
153
+ Image(systemName: "chevron.right")
154
+ .font(.system(size: 8, weight: .semibold))
155
+ .foregroundColor(Palette.textDim)
156
+ .frame(width: 18, height: 18)
157
+ .contentShape(Rectangle())
158
+ }
159
+ .buttonStyle(.plain)
160
+ }
161
+ .padding(.horizontal, 6)
162
+ .padding(.vertical, 4)
163
+ .background(
164
+ RoundedRectangle(cornerRadius: 8)
165
+ .fill(Color.black.opacity(0.65))
166
+ .overlay(
167
+ RoundedRectangle(cornerRadius: 8)
168
+ .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
169
+ )
170
+ )
171
+ }
172
+
173
+ private func displayToolbarPill(badge: Int? = nil, name: String, isActive: Bool) -> some View {
174
+ HStack(spacing: 4) {
175
+ if let badge = badge {
176
+ ZStack {
177
+ Circle()
178
+ .fill(isActive ? Palette.running.opacity(0.5) : Color.white.opacity(0.25))
179
+ .frame(width: 14, height: 14)
180
+ Text("\(badge)")
181
+ .font(.system(size: 7, weight: .bold, design: .monospaced))
182
+ .foregroundColor(isActive ? .white : .black)
183
+ }
184
+ }
185
+ Text(name)
186
+ .font(Typo.monoBold(8))
187
+ .foregroundColor(isActive ? Palette.text : Palette.textDim)
188
+ .lineLimit(1)
189
+ }
190
+ .padding(.horizontal, 6)
191
+ .padding(.vertical, 3)
192
+ .background(
193
+ RoundedRectangle(cornerRadius: 5)
194
+ .fill(isActive ? Palette.running.opacity(0.15) : Color.white.opacity(0.06))
195
+ )
196
+ .overlay(
197
+ RoundedRectangle(cornerRadius: 5)
198
+ .strokeBorder(isActive ? Palette.running.opacity(0.4) : Color.clear, lineWidth: 0.5)
199
+ )
200
+ }
201
+
202
+ // MARK: - Canvas Header Bezel
203
+
204
+ private var canvasHeaderBezel: some View {
205
+ HStack(spacing: 6) {
206
+ if let editor = controller.editor {
207
+ if let focused = editor.focusedDisplay {
208
+ Circle().fill(Palette.running.opacity(0.4)).frame(width: 6, height: 6)
209
+ Text(focused.label).font(Typo.monoBold(9)).foregroundColor(Palette.textDim).lineLimit(1)
210
+ Text("\(Int(focused.cgRect.width))×\(Int(focused.cgRect.height))").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
211
+ } else {
212
+ Text("All Displays").font(Typo.monoBold(9)).foregroundColor(Palette.textDim)
213
+ Text("\(editor.displays.count) monitors").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
214
+ }
215
+ Spacer()
216
+ Text("\(editor.focusedVisibleWindows.count) windows").font(Typo.mono(8)).foregroundColor(Palette.textMuted)
217
+ } else { Text("Canvas"); Spacer() }
218
+ }
219
+ .padding(.horizontal, 10).padding(.vertical, 5)
220
+ .background(Color(red: 0.08, green: 0.08, blue: 0.09))
221
+ .overlay(alignment: .bottom) { Rectangle().fill(Palette.border).frame(height: 0.5) }
222
+ }
223
+
224
+ // MARK: - Panel Resize Handle
225
+
226
+ enum PanelEdge { case trailing, leading }
227
+
228
+ private func panelResizeHandle(isActive: Binding<Bool>, width: Binding<CGFloat>,
229
+ range: ClosedRange<CGFloat>, edge: PanelEdge) -> some View {
230
+ Rectangle()
231
+ .fill(isActive.wrappedValue ? Palette.running.opacity(0.3) : Palette.border)
232
+ .frame(width: isActive.wrappedValue ? 2 : 0.5)
233
+ .contentShape(Rectangle().inset(by: -3))
234
+ .onHover { h in if h { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } }
235
+ .gesture(
236
+ DragGesture(minimumDistance: 1)
237
+ .onChanged { value in
238
+ isActive.wrappedValue = true
239
+ let delta = edge == .trailing ? value.translation.width : -value.translation.width
240
+ let newWidth = width.wrappedValue + delta
241
+ width.wrappedValue = max(range.lowerBound, min(range.upperBound, newWidth))
242
+ }
243
+ .onEnded { _ in isActive.wrappedValue = false }
244
+ )
245
+ }
246
+
247
+ // MARK: - Inspector Pane
248
+
249
+ private func inspectorPane(editor: ScreenMapEditorState) -> some View {
250
+ let selectedWindows = editor.windows.filter { controller.selectedWindowIds.contains($0.id) }
251
+
252
+ return VStack(spacing: 0) {
253
+ ScrollView(.vertical, showsIndicators: false) {
254
+ VStack(alignment: .leading, spacing: 12) {
255
+ Text("INSPECTOR")
256
+ .font(Typo.monoBold(9))
257
+ .foregroundColor(Palette.textMuted)
258
+
259
+ if selectedWindows.isEmpty {
260
+ VStack(spacing: 8) {
261
+ Text("No Selection")
262
+ .font(Typo.monoBold(10))
263
+ .foregroundColor(Palette.textDim)
264
+ Text("Click a window on the canvas to inspect.")
265
+ .font(Typo.mono(9))
266
+ .foregroundColor(Palette.textMuted)
267
+ .multilineTextAlignment(.center)
268
+ .lineLimit(3)
269
+ }
270
+ .frame(maxWidth: .infinity)
271
+ .padding(.top, 40)
272
+ }
273
+
274
+ ForEach(selectedWindows) { win in
275
+ inspectorWindowCard(win: win, editor: editor)
276
+ }
277
+ }
278
+ .padding(8)
279
+ }
280
+
281
+ // Pinned action tray at bottom
282
+ inspectorActionTray(editor: editor)
283
+ }
284
+ .frame(width: inspectorWidth)
285
+ }
286
+
287
+ // MARK: - Inspector Window Card
288
+
289
+ private func inspectorWindowCard(win: ScreenMapWindowEntry, editor: ScreenMapEditorState) -> some View {
290
+ VStack(alignment: .leading, spacing: 6) {
291
+ HStack(spacing: 5) {
292
+ Circle()
293
+ .fill(Self.layerColor(for: win.layer))
294
+ .frame(width: 6, height: 6)
295
+ Text(win.app)
296
+ .font(Typo.monoBold(10))
297
+ .foregroundColor(Palette.text)
298
+ .lineLimit(1)
299
+ }
300
+ if !win.title.isEmpty {
301
+ Text(win.title)
302
+ .font(Typo.mono(9))
303
+ .foregroundColor(Palette.textDim)
304
+ .lineLimit(3)
305
+ }
306
+ VStack(alignment: .leading, spacing: 3) {
307
+ inspectorRow(label: "Layer", value: editor.layerDisplayName(for: win.layer))
308
+ inspectorRow(label: "Display", value: {
309
+ if let disp = editor.displays.first(where: { $0.index == win.displayIndex }) {
310
+ return "\(editor.spatialNumber(for: disp.index)). \(disp.label)"
311
+ }
312
+ return "Display \(win.displayIndex)"
313
+ }())
314
+ inspectorRow(label: "Size",
315
+ value: "\(Int(win.editedFrame.width))×\(Int(win.editedFrame.height))")
316
+ inspectorRow(label: "Position",
317
+ value: "(\(Int(win.editedFrame.origin.x)), \(Int(win.editedFrame.origin.y)))")
318
+ inspectorRow(label: "Z-Index", value: "\(win.zIndex)")
319
+ if win.hasEdits {
320
+ inspectorRow(label: "Original",
321
+ value: "\(Int(win.originalFrame.width))×\(Int(win.originalFrame.height))")
322
+ }
323
+ }
324
+ if win.hasEdits {
325
+ HStack(spacing: 4) {
326
+ Circle()
327
+ .fill(Color.orange)
328
+ .frame(width: 5, height: 5)
329
+ Text("Modified")
330
+ .font(Typo.monoBold(8))
331
+ .foregroundColor(Color.orange)
332
+ }
333
+ }
334
+ }
335
+ .padding(8)
336
+ .background(
337
+ RoundedRectangle(cornerRadius: 6)
338
+ .fill(Palette.surface)
339
+ .overlay(
340
+ RoundedRectangle(cornerRadius: 6)
341
+ .strokeBorder(Palette.border, lineWidth: 0.5)
342
+ )
343
+ )
344
+ }
345
+
346
+ // MARK: - Floating Search Overlay
347
+
348
+ private func floatingSearchOverlay(editor: ScreenMapEditorState) -> some View {
349
+ let results = editor.searchFilteredWindows
350
+ let groups = editor.searchResultsByDisplay
351
+ let highlightIdx = max(0, min(controller.searchHighlightIndex, results.count - 1))
352
+ let terms = editor.searchTerms
353
+
354
+ return VStack(spacing: 0) {
355
+ Spacer().frame(height: 60)
356
+
357
+ VStack(spacing: 0) {
358
+ // Search field
359
+ HStack(spacing: 10) {
360
+ Image(systemName: "magnifyingglass")
361
+ .font(.system(size: 14, weight: .medium))
362
+ .foregroundColor(Self.shelfGreen)
363
+ TextField("Search windows…", text: Binding(
364
+ get: { editor.windowSearchQuery },
365
+ set: { newValue in
366
+ editor.windowSearchQuery = newValue
367
+ controller.searchHighlightIndex = 0
368
+ }
369
+ ))
370
+ .textFieldStyle(.plain)
371
+ .font(Typo.mono(14))
372
+ .foregroundColor(Palette.text)
373
+ .focused($isSearchFieldFocused)
374
+ if !editor.windowSearchQuery.isEmpty {
375
+ Text("\(results.count)")
376
+ .font(Typo.monoBold(10))
377
+ .foregroundColor(Palette.textMuted)
378
+ Button {
379
+ editor.windowSearchQuery = ""
380
+ } label: {
381
+ Image(systemName: "xmark.circle.fill")
382
+ .font(.system(size: 12))
383
+ .foregroundColor(Palette.textMuted)
384
+ }
385
+ .buttonStyle(.plain)
386
+ }
387
+ }
388
+ .padding(.horizontal, 14)
389
+ .padding(.vertical, 10)
390
+
391
+ // Results: side-by-side columns per display
392
+ if !groups.isEmpty {
393
+ Rectangle().fill(Palette.border).frame(height: 0.5)
394
+ HStack(alignment: .top, spacing: 0) {
395
+ ForEach(groups.indices, id: \.self) { groupIdx in
396
+ let group = groups[groupIdx]
397
+ if groupIdx > 0 {
398
+ Rectangle().fill(Palette.border).frame(width: 0.5)
399
+ }
400
+ VStack(spacing: 0) {
401
+ // Display header with hover → mini-map highlight
402
+ searchDisplayHeader(
403
+ spatialNumber: group.spatialNumber,
404
+ label: group.label,
405
+ matchCount: group.windows.count,
406
+ isHovered: searchHoveredDisplayIndex == group.displayIndex
407
+ )
408
+ .onHover { hovering in
409
+ searchHoveredDisplayIndex = hovering ? group.displayIndex : nil
410
+ }
411
+
412
+ // Window list for this display
413
+ ScrollView(.vertical, showsIndicators: false) {
414
+ VStack(spacing: 2) {
415
+ ForEach(Array(group.windows.enumerated()), id: \.element.id) { _, win in
416
+ let flatIdx = flatIndex(for: win, in: groups)
417
+ let isHighlighted = flatIdx == highlightIdx
418
+ searchResultRow(win: win, editor: editor, terms: terms, isHighlighted: isHighlighted)
419
+ .onTapGesture {
420
+ controller.selectSingle(win.id)
421
+ if editor.searchHasDirectHit {
422
+ controller.closeSearch()
423
+ }
424
+ }
425
+ }
426
+ }
427
+ .padding(4)
428
+ }
429
+ }
430
+ .frame(maxWidth: .infinity)
431
+ }
432
+ }
433
+ .frame(maxHeight: 280)
434
+ } else if !editor.windowSearchQuery.isEmpty {
435
+ Rectangle().fill(Palette.border).frame(height: 0.5)
436
+ Text("No matches")
437
+ .font(Typo.mono(11))
438
+ .foregroundColor(Palette.textMuted)
439
+ .padding(.vertical, 12)
440
+ }
441
+
442
+ // Keyboard hints
443
+ Rectangle().fill(Palette.border).frame(height: 0.5)
444
+ HStack(spacing: 8) {
445
+ searchHint("↑↓", label: "nav")
446
+ searchHint("↩", label: "select")
447
+ searchHint("⌘↩", label: "show")
448
+ searchHint("esc", label: "close")
449
+ if terms.count > 1 {
450
+ Spacer()
451
+ Text("\(terms.count) terms")
452
+ .font(Typo.mono(7))
453
+ .foregroundColor(Palette.textMuted)
454
+ }
455
+ }
456
+ .padding(.horizontal, 10)
457
+ .padding(.vertical, 5)
458
+ }
459
+ .background(
460
+ RoundedRectangle(cornerRadius: 10)
461
+ .fill(Color(red: 0.1, green: 0.1, blue: 0.11))
462
+ .overlay(
463
+ RoundedRectangle(cornerRadius: 10)
464
+ .strokeBorder(Self.shelfGreen.opacity(0.3), lineWidth: 1)
465
+ )
466
+ .shadow(color: Self.shelfGreen.opacity(0.15), radius: 20)
467
+ .shadow(color: Color.black.opacity(0.5), radius: 30)
468
+ )
469
+ .clipShape(RoundedRectangle(cornerRadius: 10))
470
+ .frame(width: groups.count > 1 ? 600 : 500)
471
+ .background(
472
+ GeometryReader { geo in
473
+ Color.clear.preference(key: SearchOverlayFrameKey.self,
474
+ value: geo.frame(in: .global))
475
+ }
476
+ )
477
+ .onPreferenceChange(SearchOverlayFrameKey.self) { frame in
478
+ searchOverlayFrame = frame
479
+ }
480
+ .onAppear {
481
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
482
+ isSearchFieldFocused = true
483
+ }
484
+ }
485
+
486
+ Spacer()
487
+ }
488
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
489
+ .background(Color.black.opacity(0.3))
490
+ .contentShape(Rectangle())
491
+ .onTapGesture {
492
+ controller.closeSearch()
493
+ }
494
+ }
495
+
496
+ /// Compute flat index of a window within the grouped results (for highlight tracking)
497
+ private func flatIndex(
498
+ for win: ScreenMapWindowEntry,
499
+ in groups: [(displayIndex: Int, spatialNumber: Int, label: String, windows: [ScreenMapWindowEntry])]
500
+ ) -> Int {
501
+ var idx = 0
502
+ for group in groups {
503
+ for w in group.windows {
504
+ if w.id == win.id { return idx }
505
+ idx += 1
506
+ }
507
+ }
508
+ return 0
509
+ }
510
+
511
+ /// Display section header within search results
512
+ private func searchDisplayHeader(spatialNumber: Int, label: String, matchCount: Int, isHovered: Bool = false) -> some View {
513
+ HStack(spacing: 6) {
514
+ Text("\(spatialNumber)")
515
+ .font(Typo.monoBold(8))
516
+ .foregroundColor(isHovered ? Palette.bg : Palette.bg)
517
+ .frame(width: 14, height: 14)
518
+ .background(
519
+ RoundedRectangle(cornerRadius: 3)
520
+ .fill(isHovered ? Self.shelfGreen : Palette.textMuted)
521
+ )
522
+ Text(label)
523
+ .font(Typo.mono(9))
524
+ .foregroundColor(isHovered ? Palette.text : Palette.textMuted)
525
+ .lineLimit(1)
526
+ Spacer()
527
+ Text("\(matchCount)")
528
+ .font(Typo.monoBold(8))
529
+ .foregroundColor(isHovered ? Self.shelfGreen : Palette.textMuted)
530
+ }
531
+ .padding(.horizontal, 8)
532
+ .padding(.top, 6)
533
+ .padding(.bottom, 4)
534
+ .background(isHovered ? Self.shelfGreen.opacity(0.06) : Color.clear)
535
+ .contentShape(Rectangle())
536
+ .animation(.easeInOut(duration: 0.15), value: isHovered)
537
+ }
538
+
539
+ private func searchResultRow(win: ScreenMapWindowEntry, editor: ScreenMapEditorState, terms: [String], isHighlighted: Bool) -> some View {
540
+ HStack(spacing: 6) {
541
+ Circle()
542
+ .fill(Self.layerColor(for: win.layer))
543
+ .frame(width: 5, height: 5)
544
+ VStack(alignment: .leading, spacing: 1) {
545
+ highlightedText(win.app, terms: terms, baseFont: Typo.monoBold(9),
546
+ baseColor: isHighlighted ? Palette.text : Palette.textDim)
547
+ .lineLimit(1)
548
+ if !win.title.isEmpty {
549
+ highlightedText(win.title, terms: terms, baseFont: Typo.mono(8),
550
+ baseColor: Palette.textMuted)
551
+ .lineLimit(1)
552
+ }
553
+ }
554
+ Spacer()
555
+ if isHighlighted {
556
+ Button {
557
+ controller.focusWindowOnScreen(win.id)
558
+ } label: {
559
+ Image(systemName: "macwindow.and.cursorarrow")
560
+ .font(.system(size: 8))
561
+ .foregroundColor(Self.shelfGreen)
562
+ .padding(3)
563
+ .background(
564
+ RoundedRectangle(cornerRadius: 3)
565
+ .fill(Self.shelfGreen.opacity(0.1))
566
+ )
567
+ }
568
+ .buttonStyle(.plain)
569
+ .help("Show on screen (⌘↩)")
570
+ }
571
+ Text(editor.layerDisplayName(for: win.layer))
572
+ .font(Typo.mono(7))
573
+ .foregroundColor(Palette.textMuted)
574
+ .padding(.horizontal, 4)
575
+ .padding(.vertical, 1)
576
+ .background(
577
+ RoundedRectangle(cornerRadius: 3)
578
+ .fill(Self.layerColor(for: win.layer).opacity(0.15))
579
+ )
580
+ }
581
+ .padding(.horizontal, 6)
582
+ .padding(.vertical, 4)
583
+ .background(
584
+ RoundedRectangle(cornerRadius: 4)
585
+ .fill(isHighlighted ? Self.shelfGreen.opacity(0.12) : Color.clear)
586
+ .overlay(
587
+ RoundedRectangle(cornerRadius: 4)
588
+ .strokeBorder(isHighlighted ? Self.shelfGreen.opacity(0.3) : Color.clear, lineWidth: 0.5)
589
+ )
590
+ )
591
+ .contentShape(Rectangle())
592
+ .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
593
+ }
594
+
595
+ /// Highlight matching search terms within text
596
+ private func highlightedText(_ text: String, terms: [String], baseFont: Font, baseColor: Color) -> Text {
597
+ guard !terms.isEmpty else {
598
+ return Text(text).font(baseFont).foregroundColor(baseColor)
599
+ }
600
+ let lower = text.lowercased()
601
+ // Build set of character offsets that match any term
602
+ var matchSet = IndexSet()
603
+ for term in terms {
604
+ var searchStart = lower.startIndex
605
+ while searchStart < lower.endIndex,
606
+ let range = lower.range(of: term, range: searchStart..<lower.endIndex) {
607
+ let startOffset = lower.distance(from: lower.startIndex, to: range.lowerBound)
608
+ let length = lower.distance(from: range.lowerBound, to: range.upperBound)
609
+ matchSet.insert(integersIn: startOffset..<(startOffset + length))
610
+ searchStart = range.upperBound
611
+ }
612
+ }
613
+ // Convert to segments
614
+ var result = Text("")
615
+ var i = 0
616
+ let chars = Array(text)
617
+ while i < chars.count {
618
+ let isMatch = matchSet.contains(i)
619
+ var j = i + 1
620
+ while j < chars.count && matchSet.contains(j) == isMatch { j += 1 }
621
+ let segment = String(chars[i..<j])
622
+ if isMatch {
623
+ result = result + Text(segment).font(baseFont).foregroundColor(Self.shelfGreen)
624
+ } else {
625
+ result = result + Text(segment).font(baseFont).foregroundColor(baseColor)
626
+ }
627
+ i = j
628
+ }
629
+ return result
630
+ }
631
+
632
+ private func footerHint(_ key: String, label: String) -> some View {
633
+ HStack(spacing: 2) {
634
+ Text(key)
635
+ .font(Typo.monoBold(8))
636
+ .foregroundColor(Palette.textDim)
637
+ .padding(.horizontal, 3)
638
+ .padding(.vertical, 1)
639
+ .background(
640
+ RoundedRectangle(cornerRadius: 2)
641
+ .strokeBorder(Palette.border, lineWidth: 0.5)
642
+ )
643
+ Text(label)
644
+ .font(Typo.mono(8))
645
+ .foregroundColor(Palette.textMuted)
646
+ }
647
+ }
648
+
649
+ private func searchHint(_ key: String, label: String) -> some View {
650
+ HStack(spacing: 3) {
651
+ Text(key)
652
+ .font(Typo.monoBold(7))
653
+ .foregroundColor(Palette.textDim)
654
+ .padding(.horizontal, 3)
655
+ .padding(.vertical, 1)
656
+ .background(
657
+ RoundedRectangle(cornerRadius: 2)
658
+ .strokeBorder(Palette.border, lineWidth: 0.5)
659
+ )
660
+ Text(label)
661
+ .font(Typo.mono(7))
662
+ .foregroundColor(Palette.textMuted)
663
+ }
664
+ }
665
+
666
+ // MARK: - Inspector Action Tray
667
+
668
+ private func inspectorActionTray(editor: ScreenMapEditorState) -> some View {
669
+ let actions: [(key: String, label: String, action: () -> Void)] = [
670
+ ("s", "spread", { [controller] in controller.smartSpreadLayer() }),
671
+ ("e", "expose", { [controller] in controller.exposeLayer() }),
672
+ ("t", "tile", { [controller] in controller.tileLayer() }),
673
+ ("d", "distrib", { [controller] in controller.distributeVisible() }),
674
+ ("g", "grow", { [controller] in controller.fitAvailableSpace() }),
675
+ ("c", "merge", { [controller] in controller.consolidateLayers() }),
676
+ ("f", "flatten", { [controller] in controller.flattenLayers() }),
677
+ ("v", "preview", { [controller] in controller.previewLayer() }),
678
+ ]
679
+
680
+ let columns = [GridItem(.flexible()), GridItem(.flexible())]
681
+ let editCount = editor.pendingEditCount
682
+ let isZoomed = editor.zoomLevel != 1.0 || editor.panOffset != .zero
683
+
684
+ return VStack(spacing: 0) {
685
+ // Contextual commands area (fixed slot, always reserved)
686
+ Rectangle().fill(Palette.border).frame(height: 0.5)
687
+ VStack(spacing: 0) {
688
+ if editor.isTilingMode {
689
+ VStack(spacing: 4) {
690
+ HStack(spacing: 4) {
691
+ Text("TILE")
692
+ .font(Typo.monoBold(9))
693
+ .foregroundColor(.white)
694
+ .padding(.horizontal, 5)
695
+ .padding(.vertical, 2)
696
+ .background(RoundedRectangle(cornerRadius: 3).fill(Self.shelfGreen))
697
+ Spacer()
698
+ Text("esc cancel")
699
+ .font(Typo.mono(7))
700
+ .foregroundColor(Palette.textMuted)
701
+ }
702
+ HStack(spacing: 3) {
703
+ ForEach(["←", "→", "↑", "↓"], id: \.self) { key in
704
+ Text(key)
705
+ .font(Typo.monoBold(8))
706
+ .foregroundColor(Palette.textDim)
707
+ .padding(.horizontal, 3)
708
+ .padding(.vertical, 1)
709
+ .background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
710
+ .overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
711
+ }
712
+ Text("1-7")
713
+ .font(Typo.monoBold(8))
714
+ .foregroundColor(Palette.textDim)
715
+ .padding(.horizontal, 3)
716
+ .padding(.vertical, 1)
717
+ .background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
718
+ .overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
719
+ Text("c")
720
+ .font(Typo.monoBold(8))
721
+ .foregroundColor(Palette.textDim)
722
+ .padding(.horizontal, 3)
723
+ .padding(.vertical, 1)
724
+ .background(RoundedRectangle(cornerRadius: 2).fill(Palette.surface))
725
+ .overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border, lineWidth: 0.5))
726
+ Spacer()
727
+ }
728
+ }
729
+ .padding(.horizontal, 8)
730
+ .padding(.vertical, 5)
731
+ }
732
+ if editCount > 0 {
733
+ Button {
734
+ controller.applyEditsFromButton()
735
+ } label: {
736
+ HStack(spacing: 6) {
737
+ Text("↩")
738
+ .font(Typo.monoBold(10))
739
+ .foregroundColor(Self.shelfGreen)
740
+ Text("Apply \(editCount) \(editCount == 1 ? "edit" : "edits")")
741
+ .font(Typo.monoBold(9))
742
+ .foregroundColor(Self.shelfGreen)
743
+ Spacer()
744
+ }
745
+ .padding(.horizontal, 8)
746
+ .padding(.vertical, 5)
747
+ .contentShape(Rectangle())
748
+ }
749
+ .buttonStyle(.plain)
750
+ .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
751
+ }
752
+ if isZoomed {
753
+ Button {
754
+ editor.resetZoomPan()
755
+ controller.flash("Fit all")
756
+ } label: {
757
+ HStack(spacing: 4) {
758
+ Text("r")
759
+ .font(Typo.monoBold(8))
760
+ .foregroundColor(Self.shelfGreen)
761
+ .padding(.horizontal, 4)
762
+ .padding(.vertical, 1)
763
+ .background(RoundedRectangle(cornerRadius: 2).fill(Self.shelfGreen.opacity(0.15)))
764
+ Text("fit all")
765
+ .font(Typo.mono(8))
766
+ .foregroundColor(Palette.textDim)
767
+ Spacer()
768
+ Text("\(Int(editor.zoomLevel * 100))%")
769
+ .font(Typo.mono(8))
770
+ .foregroundColor(Palette.textMuted)
771
+ }
772
+ .padding(.horizontal, 8)
773
+ .padding(.vertical, 4)
774
+ .contentShape(Rectangle())
775
+ }
776
+ .buttonStyle(.plain)
777
+ .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
778
+ }
779
+ if let ref = editor.lastActionRef {
780
+ Button {
781
+ if let json = editor.actionLog.lastEntryJSON() {
782
+ NSPasteboard.general.clearContents()
783
+ NSPasteboard.general.setString(json, forType: .string)
784
+ controller.flash("Copied \(ref)")
785
+ }
786
+ } label: {
787
+ HStack(spacing: 4) {
788
+ Text(ref)
789
+ .font(Typo.monoBold(8))
790
+ .foregroundColor(Self.shelfGreen.opacity(0.6))
791
+ Spacer()
792
+ Text("copy")
793
+ .font(Typo.mono(7))
794
+ .foregroundColor(Palette.textMuted)
795
+ }
796
+ .padding(.horizontal, 8)
797
+ .padding(.vertical, 4)
798
+ .contentShape(Rectangle())
799
+ }
800
+ .buttonStyle(.plain)
801
+ .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
802
+ }
803
+ }
804
+ .frame(maxWidth: .infinity)
805
+ .background(Color(red: 0.05, green: 0.05, blue: 0.06))
806
+
807
+ // Actions grid (always pinned at bottom)
808
+ Rectangle().fill(Palette.border).frame(height: 0.5)
809
+
810
+ Text("ACTIONS")
811
+ .font(Typo.monoBold(8))
812
+ .foregroundColor(Palette.textMuted)
813
+ .frame(maxWidth: .infinity, alignment: .leading)
814
+ .padding(.horizontal, 8)
815
+ .padding(.top, 6)
816
+ .padding(.bottom, 4)
817
+
818
+ LazyVGrid(columns: columns, spacing: 4) {
819
+ ForEach(Array(actions.enumerated()), id: \.offset) { _, item in
820
+ let isHovered = hoveredShelfAction == item.key
821
+ Button(action: item.action) {
822
+ HStack(spacing: 4) {
823
+ Text(item.key)
824
+ .font(Typo.monoBold(8))
825
+ .foregroundColor(Self.shelfGreen)
826
+ .frame(width: 14)
827
+ Text(item.label)
828
+ .font(Typo.mono(8))
829
+ .foregroundColor(isHovered ? Palette.text : Palette.textDim)
830
+ .lineLimit(1)
831
+ Spacer()
832
+ }
833
+ .padding(.horizontal, 6)
834
+ .padding(.vertical, 4)
835
+ .background(
836
+ RoundedRectangle(cornerRadius: 4)
837
+ .fill(isHovered ? Palette.surfaceHov : Palette.surface)
838
+ .overlay(
839
+ RoundedRectangle(cornerRadius: 4)
840
+ .strokeBorder(isHovered ? Palette.borderLit : Palette.border, lineWidth: 0.5)
841
+ )
842
+ )
843
+ .contentShape(Rectangle())
844
+ }
845
+ .buttonStyle(.plain)
846
+ .onHover { h in
847
+ hoveredShelfAction = h ? item.key : (hoveredShelfAction == item.key ? nil : hoveredShelfAction)
848
+ }
849
+ }
850
+ }
851
+ .padding(.horizontal, 6)
852
+ .padding(.bottom, 4)
853
+ }
854
+ .background(Color(red: 0.06, green: 0.06, blue: 0.07))
855
+ }
856
+
857
+ private func inspectorRow(label: String, value: String) -> some View {
858
+ HStack(alignment: .top, spacing: 0) {
859
+ Text(label)
860
+ .font(Typo.mono(8))
861
+ .foregroundColor(Palette.textMuted)
862
+ .frame(width: 52, alignment: .leading)
863
+ Text(value)
864
+ .font(Typo.mono(8))
865
+ .foregroundColor(Palette.textDim)
866
+ .lineLimit(2)
867
+ }
868
+ }
869
+
870
+ // MARK: - Canvas Context Badge
871
+
872
+ private var canvasContextBadge: some View {
873
+ HStack(spacing: 6) {
874
+ if let editor = controller.editor {
875
+ let layerColor = editor.activeLayer != nil
876
+ ? Self.layerColor(for: editor.activeLayer!)
877
+ : Palette.running
878
+
879
+ Circle()
880
+ .fill(layerColor)
881
+ .frame(width: 6, height: 6)
882
+
883
+ Text(editor.layerLabel)
884
+ .font(Typo.monoBold(9))
885
+ .foregroundColor(layerColor)
886
+
887
+ Text("·")
888
+ .foregroundColor(Palette.textMuted)
889
+
890
+ Text("\(editor.focusedVisibleWindows.count) windows")
891
+ .font(Typo.mono(9))
892
+ .foregroundColor(Palette.textDim)
893
+
894
+ if let focused = editor.focusedDisplay {
895
+ Text("·")
896
+ .foregroundColor(Palette.textMuted)
897
+ Text(focused.label)
898
+ .font(Typo.mono(8))
899
+ .foregroundColor(Palette.textMuted)
900
+ .lineLimit(1)
901
+ }
902
+
903
+ let editCount = editor.windows.filter { $0.hasEdits }.count
904
+ if editCount > 0 {
905
+ Text("·")
906
+ .foregroundColor(Palette.textMuted)
907
+ Text("\(editCount) pending")
908
+ .font(Typo.mono(8))
909
+ .foregroundColor(Color.orange.opacity(0.8))
910
+ .onTapGesture { controller.applyEditsFromButton() }
911
+ .onHover { hovering in
912
+ if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
913
+ }
914
+ }
915
+
916
+ if let ref = editor.lastActionRef {
917
+ Text("·")
918
+ .foregroundColor(Palette.textMuted)
919
+ Text(ref)
920
+ .font(Typo.monoBold(8))
921
+ .foregroundColor(Self.shelfGreen.opacity(0.7))
922
+ }
923
+ }
924
+ }
925
+ .padding(.horizontal, 8)
926
+ .padding(.vertical, 4)
927
+ .background(
928
+ RoundedRectangle(cornerRadius: 6)
929
+ .fill(Color.black.opacity(0.55))
930
+ .overlay(
931
+ RoundedRectangle(cornerRadius: 6)
932
+ .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
933
+ )
934
+ )
935
+ .padding(10)
936
+ }
937
+
938
+ // MARK: - Layer Sidebar
939
+
940
+ private func layerSidebar(editor: ScreenMapEditorState) -> some View {
941
+ let layers = editor.effectiveLayers
942
+
943
+ return VStack(spacing: 0) {
944
+ // Header
945
+ HStack {
946
+ Text("LAYERS")
947
+ .font(Typo.monoBold(9))
948
+ .foregroundColor(Palette.textMuted)
949
+ Spacer()
950
+ if editor.effectiveLayerCount > 1 {
951
+ Button(action: { controller.consolidateLayers() }) {
952
+ Image(systemName: "arrow.triangle.merge")
953
+ .font(.system(size: 8, weight: .semibold))
954
+ .foregroundColor(Palette.textDim)
955
+ }
956
+ .buttonStyle(.plain)
957
+ .help("Defrag layers (c)")
958
+ }
959
+ }
960
+ .padding(.bottom, 8)
961
+
962
+ // "All" row
963
+ ScrollView(.vertical, showsIndicators: false) {
964
+ VStack(spacing: 2) {
965
+ layerTreeHeader(
966
+ label: "All",
967
+ count: editor.focusedDisplayIndex != nil
968
+ ? editor.windows.filter { $0.displayIndex == editor.focusedDisplayIndex! }.count
969
+ : editor.windows.count,
970
+ isActive: editor.isShowingAll,
971
+ color: Palette.running
972
+ ) {
973
+ editor.selectLayer(nil)
974
+ controller.objectWillChange.send()
975
+ }
976
+
977
+ // Per-layer tree nodes
978
+ ForEach(layers, id: \.self) { layer in
979
+ let displayName = editor.layerDisplayName(for: layer)
980
+ let fullName = editor.layerNames[layer]
981
+ let color = Self.layerColor(for: layer)
982
+ let isActive = editor.isLayerSelected(layer)
983
+ let isDropTarget = dropTargetLayer == layer
984
+ let layerWindows = layerWindowsForTree(editor: editor, layer: layer)
985
+
986
+ VStack(spacing: 0) {
987
+ layerTreeHeader(label: fullName ?? displayName,
988
+ count: layerWindows.count,
989
+ isActive: isActive,
990
+ color: color,
991
+ isExpandable: true,
992
+ isExpanded: expandedLayers.contains(layer),
993
+ onToggleExpand: {
994
+ if expandedLayers.contains(layer) {
995
+ expandedLayers.remove(layer)
996
+ } else {
997
+ expandedLayers.insert(layer)
998
+ }
999
+ }) {
1000
+ if NSEvent.modifierFlags.contains(.command) {
1001
+ editor.toggleLayerSelection(layer)
1002
+ } else {
1003
+ editor.selectLayer(layer)
1004
+ }
1005
+ // Auto-expand on selection
1006
+ expandedLayers.insert(layer)
1007
+ controller.objectWillChange.send()
1008
+ }
1009
+
1010
+ // Window children (shown when layer is expanded)
1011
+ if expandedLayers.contains(layer) {
1012
+ VStack(spacing: 0) {
1013
+ ForEach(layerWindows) { win in
1014
+ let isSelected = controller.selectedWindowIds.contains(win.id)
1015
+ let isDragging = sidebarDragWindowId == win.id
1016
+ HStack(spacing: 4) {
1017
+ Rectangle()
1018
+ .fill(color.opacity(0.4))
1019
+ .frame(width: 1, height: 12)
1020
+ .padding(.leading, 8)
1021
+ Text(win.app)
1022
+ .font(Typo.mono(8))
1023
+ .foregroundColor(isSelected ? Palette.running : Palette.textDim)
1024
+ .lineLimit(1)
1025
+ Spacer()
1026
+ if win.hasEdits {
1027
+ Circle()
1028
+ .fill(Color.orange)
1029
+ .frame(width: 4, height: 4)
1030
+ }
1031
+ }
1032
+ .padding(.vertical, 2)
1033
+ .padding(.horizontal, 4)
1034
+ .background(
1035
+ RoundedRectangle(cornerRadius: 3)
1036
+ .fill(isSelected ? Palette.running.opacity(0.08) : Color.clear)
1037
+ )
1038
+ .contentShape(Rectangle())
1039
+ .opacity(isDragging ? 0.4 : 1.0)
1040
+ .offset(isDragging ? sidebarDragOffset : .zero)
1041
+ .zIndex(isDragging ? 10 : 0)
1042
+ .gesture(
1043
+ DragGesture(minimumDistance: 4, coordinateSpace: .named("layerSidebar"))
1044
+ .onChanged { value in
1045
+ sidebarDragWindowId = win.id
1046
+ sidebarDragOffset = value.translation
1047
+ controller.selectSingle(win.id)
1048
+ // Hit-test layer rows
1049
+ let pt = value.location
1050
+ var hit: Int? = nil
1051
+ for (l, frame) in layerRowFrames {
1052
+ if l != layer && frame.contains(pt) {
1053
+ hit = l
1054
+ break
1055
+ }
1056
+ }
1057
+ dropTargetLayer = hit
1058
+ }
1059
+ .onEnded { _ in
1060
+ if let targetLayer = dropTargetLayer {
1061
+ editor.reassignLayer(windowId: win.id, toLayer: targetLayer, fitToAvailable: true)
1062
+ controller.flash("Moved to L\(targetLayer)")
1063
+ controller.objectWillChange.send()
1064
+ }
1065
+ sidebarDragWindowId = nil
1066
+ sidebarDragOffset = .zero
1067
+ dropTargetLayer = nil
1068
+ }
1069
+ )
1070
+ .onTapGesture {
1071
+ if NSEvent.modifierFlags.contains(.command) {
1072
+ controller.toggleSelection(win.id)
1073
+ } else {
1074
+ controller.selectSingle(win.id)
1075
+ }
1076
+ }
1077
+ }
1078
+ }
1079
+ .padding(.leading, 4)
1080
+ .padding(.top, 2)
1081
+ }
1082
+ }
1083
+ .overlay(
1084
+ RoundedRectangle(cornerRadius: 4)
1085
+ .strokeBorder(isDropTarget ? Palette.running : Color.clear, lineWidth: 1.5)
1086
+ )
1087
+ .background(
1088
+ GeometryReader { geo in
1089
+ Color.clear.preference(key: LayerRowFrameKey.self,
1090
+ value: [layer: geo.frame(in: .named("layerSidebar"))])
1091
+ }
1092
+ )
1093
+ }
1094
+ }
1095
+ }
1096
+ .coordinateSpace(name: "layerSidebar")
1097
+
1098
+ Spacer(minLength: 8)
1099
+ sidebarMiniMap(editor: editor)
1100
+ }
1101
+ .padding(.horizontal, 8)
1102
+ .padding(.vertical, 8)
1103
+ .frame(width: sidebarWidth)
1104
+ .onPreferenceChange(LayerRowFrameKey.self) { layerRowFrames = $0 }
1105
+ }
1106
+
1107
+ private func layerWindowsForTree(editor: ScreenMapEditorState, layer: Int) -> [ScreenMapWindowEntry] {
1108
+ var wins = editor.windows.filter { $0.layer == layer }
1109
+ if let dIdx = editor.focusedDisplayIndex {
1110
+ wins = wins.filter { $0.displayIndex == dIdx }
1111
+ }
1112
+ return wins.sorted { $0.zIndex < $1.zIndex }
1113
+ }
1114
+
1115
+ private func layerTreeHeader(label: String, count: Int, isActive: Bool, color: Color,
1116
+ isExpandable: Bool = false, isExpanded: Bool = false,
1117
+ onToggleExpand: (() -> Void)? = nil,
1118
+ action: @escaping () -> Void) -> some View {
1119
+ HStack(spacing: 0) {
1120
+ if isExpandable {
1121
+ Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
1122
+ .font(.system(size: 7, weight: .bold))
1123
+ .foregroundColor(Palette.textMuted)
1124
+ .frame(width: 16, height: 16)
1125
+ .onTapGesture { onToggleExpand?() }
1126
+ }
1127
+ HStack(spacing: 5) {
1128
+ Circle()
1129
+ .fill(color)
1130
+ .frame(width: 6, height: 6)
1131
+ Text(label)
1132
+ .font(Typo.monoBold(9))
1133
+ .lineLimit(1)
1134
+ Spacer()
1135
+ Text("\(count)")
1136
+ .font(Typo.mono(8))
1137
+ .foregroundColor(isActive ? Palette.text.opacity(0.7) : Palette.textMuted)
1138
+ }
1139
+ .foregroundColor(isActive ? Palette.text : Palette.textDim)
1140
+ }
1141
+ .padding(.leading, isExpandable ? 0 : 16)
1142
+ .padding(.trailing, 8)
1143
+ .padding(.vertical, 5)
1144
+ .frame(maxWidth: .infinity, alignment: .leading)
1145
+ .background(
1146
+ RoundedRectangle(cornerRadius: 6)
1147
+ .fill(isActive ? color.opacity(0.12) : Color.clear)
1148
+ )
1149
+ .contentShape(Rectangle())
1150
+ .onTapGesture { action() }
1151
+ }
1152
+
1153
+ // MARK: - Canvas
1154
+
1155
+ private func screenMapCanvas(editor: ScreenMapEditorState?) -> some View {
1156
+ let isFocused = editor?.focusedDisplayIndex != nil
1157
+ let allWindows = isFocused ? (editor?.focusedVisibleWindows ?? []) : (editor?.visibleWindows ?? [])
1158
+ let displays = editor?.displays ?? []
1159
+ let zoomLevel = editor?.zoomLevel ?? 1.0
1160
+ let panOffset = editor?.panOffset ?? .zero
1161
+
1162
+ return GeometryReader { geo in
1163
+ let availW = geo.size.width - 24
1164
+ let availH = geo.size.height - 16
1165
+
1166
+ let bboxPad: CGFloat = (!isFocused && displays.count > 1) ? 40 : 0
1167
+ let bbox: CGRect = {
1168
+ if let focused = editor?.focusedDisplay {
1169
+ return focused.cgRect
1170
+ }
1171
+ guard !displays.isEmpty else {
1172
+ let s = NSScreen.main?.frame ?? CGRect(x: 0, y: 0, width: 1920, height: 1080)
1173
+ return CGRect(origin: .zero, size: s.size)
1174
+ }
1175
+ var union = displays[0].cgRect
1176
+ for d in displays.dropFirst() { union = union.union(d.cgRect) }
1177
+ return union.insetBy(dx: -bboxPad, dy: -bboxPad)
1178
+ }()
1179
+ let bboxOriginPt = bbox.origin
1180
+ let screenW = bbox.width
1181
+ let screenH = bbox.height
1182
+
1183
+ let fitScale = min(availW / screenW, availH / screenH)
1184
+ let effScale = fitScale * zoomLevel
1185
+ let mapW = screenW * effScale
1186
+ let mapH = screenH * effScale
1187
+ let centerX = (geo.size.width - mapW) / 2
1188
+ let centerY = (geo.size.height - mapH) / 2
1189
+
1190
+ ZStack(alignment: .topLeading) {
1191
+ // Per-display background rectangles
1192
+ if isFocused, let focused = editor?.focusedDisplay, let editor = editor {
1193
+ focusedDisplayBackground(focused: focused, editor: editor, mapW: mapW, mapH: mapH)
1194
+ } else if displays.count > 1 {
1195
+ multiDisplayBackgrounds(displays: displays, editor: editor, effScale: effScale, bboxOrigin: bboxOriginPt)
1196
+ } else {
1197
+ singleDisplayBackground(displays: displays, mapW: mapW, mapH: mapH)
1198
+ }
1199
+
1200
+ // Ghost outlines for edited windows
1201
+ ForEach(allWindows.filter(\.hasEdits)) { win in
1202
+ let f = win.originalFrame
1203
+ let x = (f.origin.x - bboxOriginPt.x) * effScale
1204
+ let y = (f.origin.y - bboxOriginPt.y) * effScale
1205
+ let w = max(f.width * effScale, 4)
1206
+ let h = max(f.height * effScale, 4)
1207
+
1208
+ RoundedRectangle(cornerRadius: 2)
1209
+ .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
1210
+ .foregroundColor(Palette.textMuted.opacity(0.4))
1211
+ .frame(width: w, height: h)
1212
+ .offset(x: x, y: y)
1213
+ }
1214
+
1215
+ // Live windows back-to-front
1216
+ ForEach(Array(allWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
1217
+ windowTile(win: win, editor: editor, scale: effScale, bboxOrigin: bboxOriginPt)
1218
+ }
1219
+ }
1220
+ .frame(width: mapW, height: mapH)
1221
+ .offset(x: centerX + panOffset.x, y: centerY + panOffset.y)
1222
+ .onAppear {
1223
+ cacheGeometry(editor: editor, fitScale: fitScale, scale: effScale,
1224
+ offsetX: centerX, offsetY: centerY,
1225
+ screenSize: CGSize(width: screenW, height: screenH),
1226
+ bboxOrigin: bboxOriginPt)
1227
+ }
1228
+ .onChange(of: geo.size) { _ in
1229
+ let newFitScale = min((geo.size.width - 24) / screenW, (geo.size.height - 16) / screenH)
1230
+ let newEffScale = newFitScale * zoomLevel
1231
+ let newMapW = screenW * newEffScale
1232
+ let newMapH = screenH * newEffScale
1233
+ let newCX = (geo.size.width - newMapW) / 2
1234
+ let newCY = (geo.size.height - newMapH) / 2
1235
+ cacheGeometry(editor: editor, fitScale: newFitScale, scale: newEffScale,
1236
+ offsetX: newCX, offsetY: newCY,
1237
+ screenSize: CGSize(width: screenW, height: screenH),
1238
+ bboxOrigin: bboxOriginPt)
1239
+ }
1240
+ }
1241
+ .padding(8)
1242
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
1243
+ .clipped()
1244
+ .background(
1245
+ ZStack {
1246
+ RoundedRectangle(cornerRadius: 6)
1247
+ .fill(Color.black.opacity(0.25))
1248
+ RoundedRectangle(cornerRadius: 6)
1249
+ .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
1250
+ Canvas { context, size in
1251
+ let spacing: CGFloat = 20
1252
+ let dotColor = Color.white.opacity(0.04)
1253
+ for x in stride(from: spacing, to: size.width, by: spacing) {
1254
+ for y in stride(from: spacing, to: size.height, by: spacing) {
1255
+ context.fill(
1256
+ Path(ellipseIn: CGRect(x: x - 0.5, y: y - 0.5, width: 1, height: 1)),
1257
+ with: .color(dotColor)
1258
+ )
1259
+ }
1260
+ }
1261
+ }
1262
+ }
1263
+ )
1264
+ .overlay(alignment: .top) {
1265
+ if let editor = controller.editor, editor.displays.count > 1 {
1266
+ displayToolbar(editor: editor)
1267
+ .padding(.top, 8)
1268
+ }
1269
+ }
1270
+ .overlay(alignment: .bottomLeading) {
1271
+ canvasContextBadge
1272
+ }
1273
+ .overlay(
1274
+ GeometryReader { geo in
1275
+ Color.clear.onAppear {
1276
+ let frame = geo.frame(in: .global)
1277
+ screenMapCanvasOrigin = frame.origin
1278
+ screenMapCanvasSize = frame.size
1279
+ }
1280
+ .onChange(of: geo.frame(in: .global)) { newFrame in
1281
+ screenMapCanvasOrigin = newFrame.origin
1282
+ screenMapCanvasSize = newFrame.size
1283
+ }
1284
+ }
1285
+ )
1286
+ }
1287
+
1288
+ // MARK: - Display Backgrounds
1289
+
1290
+ private func focusedDisplayBackground(focused: DisplayGeometry, editor: ScreenMapEditorState, mapW: CGFloat, mapH: CGFloat) -> some View {
1291
+ ZStack(alignment: .topLeading) {
1292
+ RoundedRectangle(cornerRadius: 6)
1293
+ .fill(Palette.bg.opacity(0.5))
1294
+ .overlay(
1295
+ RoundedRectangle(cornerRadius: 6)
1296
+ .strokeBorder(Palette.running.opacity(0.3), lineWidth: 1)
1297
+ )
1298
+ .contentShape(Rectangle())
1299
+ .onTapGesture { controller.clearSelection() }
1300
+ }
1301
+ .frame(width: mapW, height: mapH)
1302
+ }
1303
+
1304
+ private func multiDisplayBackgrounds(displays: [DisplayGeometry], editor: ScreenMapEditorState?, effScale: CGFloat, bboxOrigin: CGPoint) -> some View {
1305
+ ForEach(displays, id: \.index) { disp in
1306
+ let dx = (disp.cgRect.origin.x - bboxOrigin.x) * effScale
1307
+ let dy = (disp.cgRect.origin.y - bboxOrigin.y) * effScale
1308
+ let dw = disp.cgRect.width * effScale
1309
+ let dh = disp.cgRect.height * effScale
1310
+ let bezel: CGFloat = 3
1311
+
1312
+ ZStack {
1313
+ RoundedRectangle(cornerRadius: 8)
1314
+ .fill(Color.white.opacity(0.07))
1315
+ .overlay(
1316
+ RoundedRectangle(cornerRadius: 8)
1317
+ .strokeBorder(Color.white.opacity(0.18), lineWidth: 1.5)
1318
+ )
1319
+ RoundedRectangle(cornerRadius: 5)
1320
+ .fill(Palette.bg.opacity(0.55))
1321
+ .overlay(
1322
+ RoundedRectangle(cornerRadius: 5)
1323
+ .strokeBorder(Color.black.opacity(0.4), lineWidth: 0.5)
1324
+ )
1325
+ .padding(bezel)
1326
+
1327
+ // Display number badge (top-left corner)
1328
+ VStack {
1329
+ HStack {
1330
+ ZStack {
1331
+ Circle()
1332
+ .fill(Color.white.opacity(0.3))
1333
+ .frame(width: 16, height: 16)
1334
+ Text("\(editor?.spatialNumber(for: disp.index) ?? (disp.index + 1))")
1335
+ .font(.system(size: 8, weight: .bold, design: .monospaced))
1336
+ .foregroundColor(.black)
1337
+ }
1338
+ .padding(.top, bezel + 4)
1339
+ .padding(.leading, bezel + 4)
1340
+ Spacer()
1341
+ }
1342
+ Spacer()
1343
+ }
1344
+ }
1345
+ .contentShape(Rectangle())
1346
+ .onTapGesture {
1347
+ editor?.focusDisplay(disp.index)
1348
+ controller.objectWillChange.send()
1349
+ }
1350
+ .frame(width: dw, height: dh)
1351
+ .offset(x: dx, y: dy)
1352
+ }
1353
+ }
1354
+
1355
+ private func singleDisplayBackground(displays: [DisplayGeometry], mapW: CGFloat, mapH: CGFloat) -> some View {
1356
+ ZStack(alignment: .topLeading) {
1357
+ RoundedRectangle(cornerRadius: 6)
1358
+ .fill(Palette.bg.opacity(0.5))
1359
+ .overlay(
1360
+ RoundedRectangle(cornerRadius: 6)
1361
+ .strokeBorder(Palette.border, lineWidth: 0.5)
1362
+ )
1363
+ .contentShape(Rectangle())
1364
+ .onTapGesture { controller.clearSelection() }
1365
+
1366
+ }
1367
+ .frame(width: mapW, height: mapH)
1368
+ }
1369
+
1370
+ // MARK: - Window Tile
1371
+
1372
+ @ViewBuilder
1373
+ private func windowTile(win: ScreenMapWindowEntry, editor: ScreenMapEditorState?, scale: CGFloat, bboxOrigin: CGPoint = .zero) -> some View {
1374
+ let f = win.editedFrame
1375
+ let x = (f.origin.x - bboxOrigin.x) * scale
1376
+ let y = (f.origin.y - bboxOrigin.y) * scale
1377
+ let w = max(f.width * scale, 4)
1378
+ let h = max(f.height * scale, 4)
1379
+ let isSelected = controller.selectedWindowIds.contains(win.id)
1380
+ let isDragging = editor?.draggingWindowId == win.id
1381
+ let isInActiveLayer = editor?.isLayerSelected(win.layer) ?? true
1382
+ let winLayerColor = Self.layerColor(for: win.layer)
1383
+ let isSearchHighlighted = controller.searchHighlightedWindowId == win.id
1384
+
1385
+ let fillColor = isSearchHighlighted
1386
+ ? Self.shelfGreen.opacity(0.2)
1387
+ : isSelected
1388
+ ? Palette.running.opacity(0.18)
1389
+ : win.hasEdits ? Color.orange.opacity(0.12) : Palette.surface.opacity(0.7)
1390
+ let borderColor = isSearchHighlighted
1391
+ ? Self.shelfGreen.opacity(0.8)
1392
+ : isSelected
1393
+ ? Palette.running.opacity(0.8)
1394
+ : win.hasEdits ? Color.orange.opacity(0.6) : Palette.border.opacity(0.6)
1395
+
1396
+ Button {
1397
+ if NSEvent.modifierFlags.contains(.command) {
1398
+ controller.toggleSelection(win.id)
1399
+ } else {
1400
+ controller.selectSingle(win.id)
1401
+ }
1402
+ } label: {
1403
+ RoundedRectangle(cornerRadius: 2)
1404
+ .fill(fillColor)
1405
+ .overlay(
1406
+ RoundedRectangle(cornerRadius: 2)
1407
+ .strokeBorder(borderColor, lineWidth: isSearchHighlighted ? 2 : isSelected ? 1.5 : 0.5)
1408
+ )
1409
+ .overlay(alignment: .leading) {
1410
+ Rectangle()
1411
+ .fill(winLayerColor)
1412
+ .frame(width: 2)
1413
+ }
1414
+ .clipShape(RoundedRectangle(cornerRadius: 2))
1415
+ .overlay {
1416
+ ZStack {
1417
+ VStack(spacing: 1) {
1418
+ Text(win.app)
1419
+ .font(Typo.monoBold(max(7, min(10, h * 0.15))))
1420
+ .foregroundColor(isSelected ? Palette.running : Palette.text)
1421
+ .lineLimit(1)
1422
+ if h > 30 {
1423
+ Text(win.title)
1424
+ .font(Typo.mono(max(6, min(8, h * 0.1))))
1425
+ .foregroundColor(Palette.textDim)
1426
+ .lineLimit(1)
1427
+ }
1428
+ if h > 50 {
1429
+ Text("\(Int(win.originalFrame.width))x\(Int(win.originalFrame.height))")
1430
+ .font(Typo.mono(6))
1431
+ .foregroundColor(Palette.textMuted)
1432
+ }
1433
+ }
1434
+ .padding(.leading, 4)
1435
+ .padding(2)
1436
+
1437
+ if h > 40, let tileIcon = Self.inferTileIcon(for: win, displays: editor?.displays ?? []) {
1438
+ VStack {
1439
+ HStack {
1440
+ Spacer()
1441
+ Image(systemName: tileIcon)
1442
+ .font(.system(size: 6))
1443
+ .foregroundColor(Color.white.opacity(0.3))
1444
+ .padding(2)
1445
+ }
1446
+ Spacer()
1447
+ }
1448
+ }
1449
+
1450
+ if h > 50, let session = Self.extractLatticesSession(from: win.title) {
1451
+ VStack {
1452
+ Spacer()
1453
+ HStack {
1454
+ Text("[\(session)]")
1455
+ .font(Typo.mono(6))
1456
+ .foregroundColor(Palette.running.opacity(0.7))
1457
+ .lineLimit(1)
1458
+ .padding(.leading, 4)
1459
+ .padding(.bottom, 2)
1460
+ Spacer()
1461
+ }
1462
+ }
1463
+ }
1464
+ }
1465
+ }
1466
+ }
1467
+ .buttonStyle(.plain)
1468
+ .frame(width: w, height: h)
1469
+ .overlay {
1470
+ if isSelected && w > 30 && h > 20 {
1471
+ resizeHandles(width: w, height: h)
1472
+ }
1473
+ }
1474
+ .onHover { isHovering in
1475
+ hoveredWindowId = isHovering ? win.id : (hoveredWindowId == win.id ? nil : hoveredWindowId)
1476
+ }
1477
+ .overlay {
1478
+ if isSearchHighlighted {
1479
+ RoundedRectangle(cornerRadius: 2)
1480
+ .strokeBorder(Self.shelfGreen.opacity(0.6), lineWidth: 2)
1481
+ .shadow(color: Self.shelfGreen.opacity(0.5), radius: 6)
1482
+ }
1483
+ }
1484
+ .offset(x: x, y: y)
1485
+ .opacity(isInActiveLayer ? 1.0 : 0.3)
1486
+ .shadow(color: isDragging ? Palette.running.opacity(0.4) : .clear,
1487
+ radius: isDragging ? 6 : 0)
1488
+ }
1489
+
1490
+ @ViewBuilder
1491
+ private func resizeHandles(width w: CGFloat, height h: CGFloat) -> some View {
1492
+ let dotSize: CGFloat = 5
1493
+ let barW: CGFloat = 8
1494
+ let barH: CGFloat = 3
1495
+ let handleColor = Palette.running.opacity(0.7)
1496
+ let halfDot = dotSize / 2
1497
+
1498
+ ZStack {
1499
+ // Corner dots
1500
+ Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
1501
+ .position(x: halfDot, y: halfDot)
1502
+ Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
1503
+ .position(x: w - halfDot, y: halfDot)
1504
+ Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
1505
+ .position(x: halfDot, y: h - halfDot)
1506
+ Circle().fill(handleColor).frame(width: dotSize, height: dotSize)
1507
+ .position(x: w - halfDot, y: h - halfDot)
1508
+
1509
+ // Edge midpoint bars
1510
+ if w > 50 {
1511
+ RoundedRectangle(cornerRadius: 1).fill(handleColor)
1512
+ .frame(width: barW, height: barH)
1513
+ .position(x: w / 2, y: 1.5)
1514
+ RoundedRectangle(cornerRadius: 1).fill(handleColor)
1515
+ .frame(width: barW, height: barH)
1516
+ .position(x: w / 2, y: h - 1.5)
1517
+ }
1518
+ if h > 40 {
1519
+ RoundedRectangle(cornerRadius: 1).fill(handleColor)
1520
+ .frame(width: barH, height: barW)
1521
+ .position(x: 1.5, y: h / 2)
1522
+ RoundedRectangle(cornerRadius: 1).fill(handleColor)
1523
+ .frame(width: barH, height: barW)
1524
+ .position(x: w - 1.5, y: h / 2)
1525
+ }
1526
+ }
1527
+ .allowsHitTesting(false)
1528
+ }
1529
+
1530
+ // MARK: - Canvas Zoom Controls
1531
+
1532
+ private func canvasZoomControls(editor: ScreenMapEditorState) -> some View {
1533
+ let pct = Int(editor.zoomLevel * 100)
1534
+ return HStack(spacing: 0) {
1535
+ Button {
1536
+ let newZoom = max(ScreenMapEditorState.minZoom, editor.zoomLevel - 0.25)
1537
+ editor.zoomLevel = newZoom
1538
+ editor.objectWillChange.send()
1539
+ controller.objectWillChange.send()
1540
+ } label: {
1541
+ Image(systemName: "minus")
1542
+ .font(.system(size: 9, weight: .medium))
1543
+ .frame(width: 22, height: 20)
1544
+ .contentShape(Rectangle())
1545
+ }
1546
+ .buttonStyle(.plain)
1547
+
1548
+ Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
1549
+
1550
+ Button {
1551
+ editor.resetZoomPan()
1552
+ controller.flash("Fit all")
1553
+ controller.objectWillChange.send()
1554
+ } label: {
1555
+ Text("\(pct)%")
1556
+ .font(Typo.mono(9))
1557
+ .frame(width: 40, height: 20)
1558
+ .contentShape(Rectangle())
1559
+ }
1560
+ .buttonStyle(.plain)
1561
+
1562
+ Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
1563
+
1564
+ Button {
1565
+ let newZoom = min(ScreenMapEditorState.maxZoom, editor.zoomLevel + 0.25)
1566
+ editor.zoomLevel = newZoom
1567
+ editor.objectWillChange.send()
1568
+ controller.objectWillChange.send()
1569
+ } label: {
1570
+ Image(systemName: "plus")
1571
+ .font(.system(size: 9, weight: .medium))
1572
+ .frame(width: 22, height: 20)
1573
+ .contentShape(Rectangle())
1574
+ }
1575
+ .buttonStyle(.plain)
1576
+ }
1577
+ .foregroundColor(Palette.textMuted)
1578
+ .background(
1579
+ RoundedRectangle(cornerRadius: 5)
1580
+ .fill(Color(red: 0.1, green: 0.1, blue: 0.11).opacity(0.85))
1581
+ .overlay(
1582
+ RoundedRectangle(cornerRadius: 5)
1583
+ .strokeBorder(Palette.border, lineWidth: 0.5)
1584
+ )
1585
+ )
1586
+ }
1587
+
1588
+ private static let shelfGreen = Color(red: 0.18, green: 0.82, blue: 0.48)
1589
+
1590
+ // MARK: - Canvas Status Bar
1591
+
1592
+ private var canvasStatusBar: some View {
1593
+ VStack(spacing: 0) {
1594
+ Rectangle().fill(Color.white.opacity(0.04)).frame(height: 0.5)
1595
+ HStack(spacing: 6) {
1596
+ if let editor = controller.editor {
1597
+ let layerColor = editor.activeLayer != nil
1598
+ ? Self.layerColor(for: editor.activeLayer!)
1599
+ : Palette.running
1600
+ Circle().fill(layerColor).frame(width: 5, height: 5)
1601
+ Text(editor.layerLabel)
1602
+ .font(Typo.monoBold(8))
1603
+ .foregroundColor(layerColor)
1604
+ Text("·").foregroundColor(Palette.textMuted).font(Typo.mono(7))
1605
+ Text("\(editor.focusedVisibleWindows.count) windows")
1606
+ .font(Typo.mono(8))
1607
+ .foregroundColor(Palette.textDim)
1608
+ if let focused = editor.focusedDisplay {
1609
+ Text("·").foregroundColor(Palette.textMuted).font(Typo.mono(7))
1610
+ Text(focused.label)
1611
+ .font(Typo.mono(8))
1612
+ .foregroundColor(Palette.textMuted)
1613
+ .lineLimit(1)
1614
+ }
1615
+ Spacer()
1616
+ let editCount = editor.windows.filter { $0.hasEdits }.count
1617
+ if editCount > 0 {
1618
+ Text("\(editCount) pending")
1619
+ .font(Typo.mono(7))
1620
+ .foregroundColor(Color.orange.opacity(0.7))
1621
+ }
1622
+ if let ref = editor.lastActionRef {
1623
+ Text(ref)
1624
+ .font(Typo.monoBold(8))
1625
+ .foregroundColor(Self.shelfGreen.opacity(0.6))
1626
+ }
1627
+ }
1628
+ }
1629
+ .padding(.horizontal, 10)
1630
+ .padding(.vertical, 4)
1631
+ }
1632
+ .background(Color(red: 0.08, green: 0.08, blue: 0.09))
1633
+ }
1634
+
1635
+ // MARK: - Footer Bar
1636
+
1637
+ // MARK: - Status Bar
1638
+
1639
+ private var footerBar: some View {
1640
+ VStack(spacing: 0) {
1641
+ Rectangle().fill(Palette.borderLit).frame(height: 0.5)
1642
+ HStack(spacing: 0) {
1643
+ // Left: server health + settings
1644
+ HStack(spacing: 6) {
1645
+ Circle()
1646
+ .fill(daemon.isListening ? Palette.running : Palette.kill)
1647
+ .frame(width: 6, height: 6)
1648
+ if daemon.isListening {
1649
+ Text("Serving")
1650
+ .font(Typo.monoBold(9))
1651
+ .foregroundColor(Palette.running.opacity(0.8))
1652
+ Text(":9399")
1653
+ .font(Typo.mono(9))
1654
+ .foregroundColor(Palette.textMuted)
1655
+ if daemon.clientCount > 0 {
1656
+ Text("·")
1657
+ .foregroundColor(Palette.textMuted)
1658
+ Text("\(daemon.clientCount) client\(daemon.clientCount == 1 ? "" : "s")")
1659
+ .font(Typo.mono(9))
1660
+ .foregroundColor(Palette.textDim)
1661
+ }
1662
+ } else {
1663
+ Text("Offline")
1664
+ .font(Typo.monoBold(9))
1665
+ .foregroundColor(Palette.kill.opacity(0.7))
1666
+ }
1667
+
1668
+ Text("·").foregroundColor(Palette.textMuted)
1669
+
1670
+ statusBarButton(icon: "gearshape", label: "Settings") {
1671
+ onNavigate?(.settings)
1672
+ }
1673
+ }
1674
+
1675
+ Spacer()
1676
+ if let editor = controller.editor {
1677
+ if editor.pendingEditCount > 0 {
1678
+ Button {
1679
+ controller.applyEditsFromButton()
1680
+ } label: {
1681
+ HStack(spacing: 4) {
1682
+ Text("↩")
1683
+ .font(Typo.monoBold(9))
1684
+ .foregroundColor(Self.shelfGreen)
1685
+ Text("\(editor.pendingEditCount) pending")
1686
+ .font(Typo.monoBold(9))
1687
+ .foregroundColor(Color.orange.opacity(0.8))
1688
+ }
1689
+ }
1690
+ .buttonStyle(.plain)
1691
+ .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
1692
+ }
1693
+ if let ref = editor.lastActionRef {
1694
+ Text(ref)
1695
+ .font(Typo.monoBold(8))
1696
+ .foregroundColor(Self.shelfGreen.opacity(0.6))
1697
+ }
1698
+ }
1699
+ Spacer()
1700
+
1701
+ // Quick keyboard hints
1702
+ HStack(spacing: 6) {
1703
+ if !controller.selectedWindowIds.isEmpty {
1704
+ footerHint("⌘↩", label: "show")
1705
+ }
1706
+ footerHint("/", label: "search")
1707
+ footerHint("q", label: "quit")
1708
+ }
1709
+ .padding(.trailing, 8)
1710
+
1711
+ // Right: docs + logs
1712
+ HStack(spacing: 10) {
1713
+ statusBarButton(icon: "book", label: "Docs") {
1714
+ onNavigate?(.docs)
1715
+ }
1716
+ statusBarButton(icon: "text.alignleft", label: "Logs") {
1717
+ DiagnosticWindow.shared.toggle()
1718
+ }
1719
+ }
1720
+ }
1721
+ .padding(.horizontal, 10)
1722
+ .padding(.vertical, 4)
1723
+ }
1724
+ .background(Color(red: 0.08, green: 0.08, blue: 0.09))
1725
+ }
1726
+
1727
+ private func statusBarButton(icon: String, label: String, action: @escaping () -> Void) -> some View {
1728
+ Button(action: action) {
1729
+ HStack(spacing: 4) {
1730
+ Image(systemName: icon)
1731
+ .font(.system(size: 9))
1732
+ Text(label)
1733
+ .font(Typo.mono(9))
1734
+ }
1735
+ .foregroundColor(Palette.textMuted)
1736
+ .contentShape(Rectangle())
1737
+ }
1738
+ .buttonStyle(.plain)
1739
+ .onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
1740
+ }
1741
+
1742
+ private func chordHint(key: String, label: String) -> some View {
1743
+ HStack(spacing: 4) {
1744
+ Text(key)
1745
+ .font(Typo.mono(9))
1746
+ .foregroundColor(Palette.text)
1747
+ .padding(.horizontal, 4)
1748
+ .padding(.vertical, 2)
1749
+ .background(
1750
+ RoundedRectangle(cornerRadius: 3)
1751
+ .fill(Palette.surface)
1752
+ .overlay(
1753
+ RoundedRectangle(cornerRadius: 3)
1754
+ .strokeBorder(Palette.border, lineWidth: 0.5)
1755
+ )
1756
+ )
1757
+ Text(label)
1758
+ .font(Typo.mono(9))
1759
+ .foregroundColor(Palette.textMuted)
1760
+ }
1761
+ }
1762
+
1763
+ // MARK: - Sidebar Mini-Map
1764
+
1765
+ @ViewBuilder
1766
+ private func sidebarMiniMap(editor: ScreenMapEditorState) -> some View {
1767
+ let displays = editor.displays
1768
+
1769
+ if displays.count > 1 {
1770
+ let union: CGRect = {
1771
+ var u = displays[0].cgRect
1772
+ for d in displays.dropFirst() { u = u.union(d.cgRect) }
1773
+ return u
1774
+ }()
1775
+ let miniW: CGFloat = sidebarWidth - 28
1776
+ let scaleW = miniW / max(union.width, 1)
1777
+ let scaleH: CGFloat = 80 / max(union.height, 1)
1778
+ let scale = min(scaleW, scaleH)
1779
+ let contentW = union.width * scale
1780
+ let contentH = union.height * scale
1781
+
1782
+ VStack(spacing: 4) {
1783
+ ZStack {
1784
+ ZStack(alignment: .topLeading) {
1785
+ ForEach(displays, id: \.index) { disp in
1786
+ let isFocused = editor.focusedDisplayIndex == disp.index
1787
+ let dx = (disp.cgRect.origin.x - union.origin.x) * scale
1788
+ let dy = (disp.cgRect.origin.y - union.origin.y) * scale
1789
+ let dw = disp.cgRect.width * scale
1790
+ let dh = disp.cgRect.height * scale
1791
+ let inset: CGFloat = 1.5
1792
+ let fontSize: CGFloat = min(dw, dh) > 28 ? 11 : (min(dw, dh) > 16 ? 9 : 7)
1793
+
1794
+ Button {
1795
+ editor.focusDisplay(disp.index)
1796
+ controller.objectWillChange.send()
1797
+ } label: {
1798
+ ZStack {
1799
+ RoundedRectangle(cornerRadius: 3)
1800
+ .fill(isFocused ? Palette.running.opacity(0.15) : Color.white.opacity(0.06))
1801
+ RoundedRectangle(cornerRadius: 3)
1802
+ .strokeBorder(isFocused ? Palette.running.opacity(0.7) : Color.white.opacity(0.15), lineWidth: isFocused ? 1.5 : 0.5)
1803
+ Text("\(editor.spatialNumber(for: disp.index))")
1804
+ .font(.system(size: fontSize, weight: .bold, design: .monospaced))
1805
+ .foregroundColor(isFocused ? Palette.running : Color.white.opacity(0.35))
1806
+ }
1807
+ .frame(width: max(dw - inset * 2, 12), height: max(dh - inset * 2, 12))
1808
+ }
1809
+ .buttonStyle(.plain)
1810
+ .offset(x: dx + inset, y: dy + inset)
1811
+ }
1812
+ }
1813
+ .frame(width: contentW, height: contentH)
1814
+ }
1815
+ .frame(width: miniW, height: max(contentH, 48), alignment: .topLeading)
1816
+ .clipped()
1817
+
1818
+ Button {
1819
+ editor.focusDisplay(nil)
1820
+ controller.objectWillChange.send()
1821
+ } label: {
1822
+ Text("ALL")
1823
+ .font(Typo.monoBold(7))
1824
+ .foregroundColor(editor.focusedDisplayIndex == nil ? Palette.running : Palette.textDim)
1825
+ .frame(maxWidth: .infinity)
1826
+ .padding(.vertical, 2)
1827
+ }
1828
+ .buttonStyle(.plain)
1829
+ }
1830
+ .padding(6)
1831
+ .background(
1832
+ RoundedRectangle(cornerRadius: 6)
1833
+ .fill(Color.black.opacity(0.4))
1834
+ .overlay(
1835
+ RoundedRectangle(cornerRadius: 6)
1836
+ .strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
1837
+ )
1838
+ )
1839
+ }
1840
+ }
1841
+
1842
+ // MARK: - Flash Overlay
1843
+
1844
+ @ViewBuilder
1845
+ private var flashOverlay: some View {
1846
+ if let msg = controller.flashMessage {
1847
+ VStack {
1848
+ Spacer()
1849
+ HStack(spacing: 6) {
1850
+ Image(systemName: "rectangle.3.group")
1851
+ .font(.system(size: 11))
1852
+ Text(msg)
1853
+ .font(Typo.monoBold(11))
1854
+ }
1855
+ .foregroundColor(Palette.text)
1856
+ .padding(.horizontal, 14)
1857
+ .padding(.vertical, 8)
1858
+ .background(
1859
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
1860
+ .fill(Palette.surface)
1861
+ .overlay(
1862
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
1863
+ .strokeBorder(Palette.running.opacity(0.3), lineWidth: 0.5)
1864
+ )
1865
+ .shadow(color: .black.opacity(0.2), radius: 8, y: 2)
1866
+ )
1867
+ .padding(.bottom, 60)
1868
+ }
1869
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
1870
+ .animation(.easeOut(duration: 0.2), value: controller.flashMessage)
1871
+ .allowsHitTesting(false)
1872
+ }
1873
+ }
1874
+
1875
+ private var divider: some View {
1876
+ Rectangle()
1877
+ .fill(Palette.border)
1878
+ .frame(height: 0.5)
1879
+ }
1880
+
1881
+ // MARK: - Helpers
1882
+
1883
+ private func cacheGeometry(editor: ScreenMapEditorState?, fitScale: CGFloat? = nil, scale: CGFloat,
1884
+ offsetX: CGFloat, offsetY: CGFloat,
1885
+ screenSize: CGSize, bboxOrigin: CGPoint = .zero) {
1886
+ if let fs = fitScale { editor?.fitScale = fs }
1887
+ editor?.scale = scale
1888
+ editor?.mapOrigin = CGPoint(x: offsetX, y: offsetY)
1889
+ editor?.screenSize = screenSize
1890
+ editor?.bboxOrigin = bboxOrigin
1891
+ }
1892
+
1893
+ // MARK: - Layer Colors
1894
+
1895
+ private static let layerColors: [Color] = [
1896
+ .green, .cyan, .orange, .purple, .pink, .yellow, .mint, .indigo
1897
+ ]
1898
+
1899
+ private static func layerColor(for layer: Int) -> Color {
1900
+ layerColors[layer % layerColors.count]
1901
+ }
1902
+
1903
+ private static func inferTileIcon(for win: ScreenMapWindowEntry, displays: [DisplayGeometry]) -> String? {
1904
+ guard let disp = displays.first(where: { $0.index == win.displayIndex }) else { return nil }
1905
+ let screenW = disp.cgRect.width
1906
+ let screenH = disp.cgRect.height
1907
+ let relX = win.originalFrame.origin.x - disp.cgRect.origin.x
1908
+ let relY = win.originalFrame.origin.y - disp.cgRect.origin.y
1909
+ let winW = win.originalFrame.width
1910
+ let winH = win.originalFrame.height
1911
+ let tolerance: CGFloat = 30
1912
+
1913
+ for pos in TilePosition.allCases {
1914
+ let (fx, fy, fw, fh) = pos.rect
1915
+ let expectedX = fx * screenW
1916
+ let expectedY = fy * screenH
1917
+ let expectedW = fw * screenW
1918
+ let expectedH = fh * screenH
1919
+ if abs(relX - expectedX) < tolerance && abs(relY - expectedY) < tolerance
1920
+ && abs(winW - expectedW) < tolerance && abs(winH - expectedH) < tolerance {
1921
+ return pos.icon
1922
+ }
1923
+ }
1924
+ return nil
1925
+ }
1926
+
1927
+ private static func extractLatticesSession(from title: String) -> String? {
1928
+ guard let range = title.range(of: #"\[lattices:([^\]]+)\]"#, options: .regularExpression) else { return nil }
1929
+ let match = String(title[range])
1930
+ return String(match.dropFirst(9).dropLast(1))
1931
+ }
1932
+
1933
+ // MARK: - Layer Preview
1934
+
1935
+ private func handlePreviewChange(isPreviewing: Bool) {
1936
+ guard isPreviewing, let editor = controller.editor else { return }
1937
+ let screens = NSScreen.screens
1938
+ guard !screens.isEmpty else { return }
1939
+
1940
+ let primaryHeight = screens.first?.frame.height ?? 0
1941
+
1942
+ // Scope preview to the focused display's screen, or union of all
1943
+ let targetFrame: NSRect
1944
+ let cgOrigin: CGPoint
1945
+ if let focusedIdx = editor.focusedDisplayIndex, focusedIdx < screens.count {
1946
+ let screen = screens[focusedIdx]
1947
+ targetFrame = screen.frame
1948
+ cgOrigin = CGPoint(x: screen.frame.origin.x,
1949
+ y: primaryHeight - screen.frame.maxY)
1950
+ } else {
1951
+ var union = screens[0].frame
1952
+ for screen in screens.dropFirst() { union = union.union(screen.frame) }
1953
+ targetFrame = union
1954
+ cgOrigin = CGPoint(x: union.origin.x,
1955
+ y: primaryHeight - (union.origin.y + union.height))
1956
+ }
1957
+
1958
+ let visible = editor.focusedVisibleWindows
1959
+ let label = editor.layerLabel
1960
+ let captures = controller.previewCaptures
1961
+
1962
+ let overlay = ScreenMapPreviewOverlay(
1963
+ windows: visible, layerLabel: label, captures: captures,
1964
+ screenFrame: targetFrame,
1965
+ screenCGOrigin: cgOrigin
1966
+ )
1967
+ let hostingView = NSHostingView(rootView: overlay)
1968
+ controller.showPreviewWindow(contentView: hostingView, frame: targetFrame)
1969
+ }
1970
+
1971
+ // MARK: - Key Handler
1972
+
1973
+ private func installKeyHandler() {
1974
+ eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { event in
1975
+ // Only handle keys when our window is the key window
1976
+ guard let win = ScreenMapWindowController.shared.nsWindow,
1977
+ win.isKeyWindow else { return event }
1978
+ // Track space key for canvas drag-to-pan
1979
+ if event.keyCode == 49 && !controller.isSearchActive {
1980
+ if event.type == .keyDown && !event.isARepeat {
1981
+ isSpaceHeld = true
1982
+ NSCursor.openHand.push()
1983
+ return nil
1984
+ } else if event.type == .keyUp {
1985
+ isSpaceHeld = false
1986
+ spaceDragStart = nil
1987
+ NSCursor.pop()
1988
+ return nil
1989
+ }
1990
+ }
1991
+ guard event.type == .keyDown else { return event }
1992
+ let consumed = controller.handleKey(event.keyCode, modifiers: event.modifierFlags)
1993
+ return consumed ? nil : event
1994
+ }
1995
+ }
1996
+
1997
+ private func removeKeyHandler() {
1998
+ if let monitor = eventMonitor {
1999
+ NSEvent.removeMonitor(monitor)
2000
+ eventMonitor = nil
2001
+ }
2002
+ }
2003
+
2004
+ // MARK: - Mouse Monitors
2005
+
2006
+ private func installMouseMonitors() {
2007
+ let dragThreshold: CGFloat = 4
2008
+
2009
+ mouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { event in
2010
+ guard let eventWindow = event.window,
2011
+ eventWindow === ScreenMapWindowController.shared.nsWindow else { return event }
2012
+
2013
+ // Space+click → begin canvas pan
2014
+ if isSpaceHeld, let editor = controller.editor {
2015
+ spaceDragStart = event.locationInWindow
2016
+ spaceDragPanStart = editor.panOffset
2017
+ NSCursor.closedHand.push()
2018
+ return nil
2019
+ }
2020
+
2021
+ if let hitId = hoveredWindowId, let editor = controller.editor {
2022
+ screenMapClickWindowId = hitId
2023
+ screenMapClickPoint = event.locationInWindow
2024
+ let flippedPt = flippedScreenPoint(event)
2025
+ if let hit = screenMapHitTestWithRect(flippedScreenPt: flippedPt, editor: editor) {
2026
+ editor.canvasDragMode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
2027
+ } else {
2028
+ editor.canvasDragMode = .move
2029
+ }
2030
+ } else {
2031
+ screenMapClickWindowId = nil
2032
+ }
2033
+ return event
2034
+ }
2035
+
2036
+ mouseDragMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
2037
+ // Space+drag → pan canvas
2038
+ if isSpaceHeld, let start = spaceDragStart, let editor = controller.editor {
2039
+ let dx = event.locationInWindow.x - start.x
2040
+ let dy = event.locationInWindow.y - start.y
2041
+ editor.panOffset = CGPoint(x: spaceDragPanStart.x + dx, y: spaceDragPanStart.y - dy)
2042
+ editor.objectWillChange.send()
2043
+ controller.objectWillChange.send()
2044
+ return nil
2045
+ }
2046
+
2047
+ guard let hitId = screenMapClickWindowId,
2048
+ let editor = controller.editor else { return event }
2049
+ let dx = event.locationInWindow.x - screenMapClickPoint.x
2050
+ let dy = event.locationInWindow.y - screenMapClickPoint.y
2051
+ guard sqrt(dx * dx + dy * dy) >= dragThreshold else { return event }
2052
+
2053
+ if editor.draggingWindowId != hitId {
2054
+ editor.draggingWindowId = hitId
2055
+ if let idx = editor.windows.firstIndex(where: { $0.id == hitId }) {
2056
+ editor.dragStartFrame = editor.windows[idx].editedFrame
2057
+ }
2058
+ controller.selectSingle(hitId)
2059
+ }
2060
+
2061
+ let effScale = editor.effectiveScale
2062
+ guard let startFrame = editor.dragStartFrame,
2063
+ effScale > 0,
2064
+ let idx = editor.windows.firstIndex(where: { $0.id == hitId }) else { return event }
2065
+ let screenDx = dx / effScale
2066
+ let screenDy = -dy / effScale // CG coords: Y flipped
2067
+ let mode = editor.canvasDragMode
2068
+ let minW: CGFloat = 100
2069
+ let minH: CGFloat = 50
2070
+
2071
+ var newFrame = startFrame
2072
+
2073
+ switch mode {
2074
+ case .move:
2075
+ newFrame.origin.x = startFrame.origin.x + screenDx
2076
+ newFrame.origin.y = startFrame.origin.y + screenDy
2077
+
2078
+ case .resizeRight:
2079
+ newFrame.size.width = max(minW, startFrame.width + screenDx)
2080
+ case .resizeLeft:
2081
+ let dw = min(screenDx, startFrame.width - minW)
2082
+ newFrame.origin.x = startFrame.origin.x + dw
2083
+ newFrame.size.width = startFrame.width - dw
2084
+ case .resizeBottom:
2085
+ newFrame.size.height = max(minH, startFrame.height + screenDy)
2086
+ case .resizeTop:
2087
+ let dh = min(screenDy, startFrame.height - minH)
2088
+ newFrame.origin.y = startFrame.origin.y + dh
2089
+ newFrame.size.height = startFrame.height - dh
2090
+
2091
+ case .resizeTopLeft:
2092
+ let dw = min(screenDx, startFrame.width - minW)
2093
+ newFrame.origin.x = startFrame.origin.x + dw
2094
+ newFrame.size.width = startFrame.width - dw
2095
+ let dh = min(screenDy, startFrame.height - minH)
2096
+ newFrame.origin.y = startFrame.origin.y + dh
2097
+ newFrame.size.height = startFrame.height - dh
2098
+ case .resizeTopRight:
2099
+ newFrame.size.width = max(minW, startFrame.width + screenDx)
2100
+ let dh = min(screenDy, startFrame.height - minH)
2101
+ newFrame.origin.y = startFrame.origin.y + dh
2102
+ newFrame.size.height = startFrame.height - dh
2103
+ case .resizeBottomLeft:
2104
+ let dw = min(screenDx, startFrame.width - minW)
2105
+ newFrame.origin.x = startFrame.origin.x + dw
2106
+ newFrame.size.width = startFrame.width - dw
2107
+ newFrame.size.height = max(minH, startFrame.height + screenDy)
2108
+ case .resizeBottomRight:
2109
+ newFrame.size.width = max(minW, startFrame.width + screenDx)
2110
+ newFrame.size.height = max(minH, startFrame.height + screenDy)
2111
+ }
2112
+
2113
+ editor.windows[idx].editedFrame = newFrame
2114
+ editor.objectWillChange.send()
2115
+ controller.objectWillChange.send()
2116
+ return nil
2117
+ }
2118
+
2119
+ mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
2120
+ // End space+drag pan
2121
+ if spaceDragStart != nil {
2122
+ spaceDragStart = nil
2123
+ NSCursor.pop() // pop closedHand, openHand remains
2124
+ return event
2125
+ }
2126
+ if screenMapClickWindowId != nil {
2127
+ if let editor = controller.editor, editor.draggingWindowId != nil {
2128
+ editor.draggingWindowId = nil
2129
+ editor.dragStartFrame = nil
2130
+ editor.canvasDragMode = .move
2131
+ editor.objectWillChange.send()
2132
+ }
2133
+ screenMapClickWindowId = nil
2134
+ }
2135
+ return event
2136
+ }
2137
+
2138
+ rightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { event in
2139
+ guard let eventWindow = event.window,
2140
+ eventWindow === ScreenMapWindowController.shared.nsWindow,
2141
+ let editor = controller.editor else { return event }
2142
+
2143
+ let flippedPt = flippedScreenPoint(event)
2144
+ let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
2145
+ guard canvasRect.contains(flippedPt) else { return event }
2146
+
2147
+ if let hitId = screenMapHitTest(flippedScreenPt: flippedPt, editor: editor) {
2148
+ if !controller.isSelected(hitId) {
2149
+ controller.selectSingle(hitId)
2150
+ }
2151
+ showLayerContextMenu(for: hitId, at: event.locationInWindow, in: eventWindow, editor: editor)
2152
+ return nil
2153
+ }
2154
+ return event
2155
+ }
2156
+
2157
+ scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
2158
+ guard let eventWindow = event.window,
2159
+ eventWindow === ScreenMapWindowController.shared.nsWindow,
2160
+ let editor = controller.editor else { return event }
2161
+
2162
+ // Let search overlay handle its own scroll
2163
+ if controller.isSearchActive {
2164
+ let screenPt = event.locationInWindow
2165
+ let windowPt = eventWindow.convertPoint(toScreen: screenPt)
2166
+ let flippedY = NSScreen.main.map { $0.frame.height - windowPt.y } ?? windowPt.y
2167
+ let testPt = CGPoint(x: windowPt.x, y: flippedY)
2168
+ if searchOverlayFrame.contains(testPt) {
2169
+ return event // pass to SwiftUI ScrollView
2170
+ }
2171
+ }
2172
+
2173
+ let flippedPt = flippedScreenPoint(event)
2174
+ let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
2175
+ guard canvasRect.contains(flippedPt) else { return event }
2176
+
2177
+ let isZoom = event.modifierFlags.contains(.command) || !event.hasPreciseScrollingDeltas
2178
+
2179
+ if isZoom {
2180
+ let zoomDelta: CGFloat = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY * 0.01 : event.scrollingDeltaY * 0.05
2181
+ let oldZoom = editor.zoomLevel
2182
+ let newZoom = max(ScreenMapEditorState.minZoom, min(ScreenMapEditorState.maxZoom, oldZoom + zoomDelta))
2183
+ guard newZoom != oldZoom else { return nil }
2184
+
2185
+ let canvasLocal = CGPoint(
2186
+ x: flippedPt.x - screenMapCanvasOrigin.x,
2187
+ y: flippedPt.y - screenMapCanvasOrigin.y
2188
+ )
2189
+ let canvasCenterX = screenMapCanvasSize.width / 2
2190
+ let canvasCenterY = screenMapCanvasSize.height / 2
2191
+ let cursorFromCenter = CGPoint(
2192
+ x: canvasLocal.x - canvasCenterX,
2193
+ y: canvasLocal.y - canvasCenterY
2194
+ )
2195
+
2196
+ let ratio = newZoom / oldZoom
2197
+ let newPanX = cursorFromCenter.x - ratio * (cursorFromCenter.x - editor.panOffset.x)
2198
+ let newPanY = cursorFromCenter.y - ratio * (cursorFromCenter.y - editor.panOffset.y)
2199
+
2200
+ editor.zoomLevel = newZoom
2201
+ editor.panOffset = CGPoint(x: newPanX, y: newPanY)
2202
+ editor.objectWillChange.send()
2203
+ controller.objectWillChange.send()
2204
+ } else {
2205
+ editor.panOffset = CGPoint(
2206
+ x: editor.panOffset.x + event.scrollingDeltaX,
2207
+ y: editor.panOffset.y - event.scrollingDeltaY
2208
+ )
2209
+ editor.objectWillChange.send()
2210
+ controller.objectWillChange.send()
2211
+ }
2212
+ return nil
2213
+ }
2214
+
2215
+ mouseMovedMonitor = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved) { event in
2216
+ guard let eventWindow = event.window,
2217
+ eventWindow === ScreenMapWindowController.shared.nsWindow,
2218
+ let editor = controller.editor else {
2219
+ resetCursorIfNeeded()
2220
+ return event
2221
+ }
2222
+
2223
+ let flippedPt = flippedScreenPoint(event)
2224
+ let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
2225
+ guard canvasRect.contains(flippedPt) else {
2226
+ resetCursorIfNeeded()
2227
+ return event
2228
+ }
2229
+
2230
+ if let hit = screenMapHitTestWithRect(flippedScreenPt: flippedPt, editor: editor) {
2231
+ let mode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
2232
+ if mode != editor.currentCursorMode {
2233
+ if editor.currentCursorMode != .move { NSCursor.pop() }
2234
+ editor.currentCursorMode = mode
2235
+ switch mode {
2236
+ case .resizeLeft, .resizeRight:
2237
+ NSCursor.resizeLeftRight.push()
2238
+ case .resizeTop, .resizeBottom:
2239
+ NSCursor.resizeUpDown.push()
2240
+ case .resizeTopLeft, .resizeTopRight, .resizeBottomLeft, .resizeBottomRight:
2241
+ NSCursor.crosshair.push()
2242
+ case .move:
2243
+ break
2244
+ }
2245
+ }
2246
+ } else {
2247
+ resetCursorIfNeeded()
2248
+ }
2249
+ return event
2250
+ }
2251
+ }
2252
+
2253
+ private func resetCursorIfNeeded() {
2254
+ guard let editor = controller.editor else { return }
2255
+ if editor.currentCursorMode != .move {
2256
+ NSCursor.pop()
2257
+ editor.currentCursorMode = .move
2258
+ }
2259
+ }
2260
+
2261
+ private func removeMouseMonitors() {
2262
+ if let m = mouseDownMonitor { NSEvent.removeMonitor(m); mouseDownMonitor = nil }
2263
+ if let m = mouseDragMonitor { NSEvent.removeMonitor(m); mouseDragMonitor = nil }
2264
+ if let m = mouseUpMonitor { NSEvent.removeMonitor(m); mouseUpMonitor = nil }
2265
+ if let m = rightClickMonitor { NSEvent.removeMonitor(m); rightClickMonitor = nil }
2266
+ if let m = scrollWheelMonitor { NSEvent.removeMonitor(m); scrollWheelMonitor = nil }
2267
+ if let m = mouseMovedMonitor { NSEvent.removeMonitor(m); mouseMovedMonitor = nil }
2268
+ resetCursorIfNeeded()
2269
+ }
2270
+
2271
+ // MARK: - Hit Test / Coordinate Conversion
2272
+
2273
+ private func screenMapHitTest(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> UInt32? {
2274
+ let effScale = editor.effectiveScale
2275
+ let origin = editor.mapOrigin
2276
+ let panOffset = editor.panOffset
2277
+ guard effScale > 0 else { return nil }
2278
+
2279
+ let canvasLocal = CGPoint(
2280
+ x: flippedScreenPt.x - screenMapCanvasOrigin.x,
2281
+ y: flippedScreenPt.y - screenMapCanvasOrigin.y
2282
+ )
2283
+ let mapPoint = CGPoint(
2284
+ x: canvasLocal.x - 8 - origin.x - panOffset.x,
2285
+ y: canvasLocal.y - 8 - origin.y - panOffset.y
2286
+ )
2287
+
2288
+ let bboxOrig = editor.bboxOrigin
2289
+ let windowPool = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.windows
2290
+ let sorted = windowPool.sorted(by: { $0.zIndex < $1.zIndex })
2291
+ for win in sorted {
2292
+ let f = win.editedFrame
2293
+ let mapRect = CGRect(
2294
+ x: (f.origin.x - bboxOrig.x) * effScale,
2295
+ y: (f.origin.y - bboxOrig.y) * effScale,
2296
+ width: max(f.width * effScale, 4),
2297
+ height: max(f.height * effScale, 4)
2298
+ )
2299
+ if mapRect.contains(mapPoint) { return win.id }
2300
+ }
2301
+ return nil
2302
+ }
2303
+
2304
+ private func screenMapHitTestWithRect(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> (id: UInt32, mapRect: CGRect, mapPoint: CGPoint)? {
2305
+ let effScale = editor.effectiveScale
2306
+ let origin = editor.mapOrigin
2307
+ let panOff = editor.panOffset
2308
+ guard effScale > 0 else { return nil }
2309
+
2310
+ let canvasLocal = CGPoint(
2311
+ x: flippedScreenPt.x - screenMapCanvasOrigin.x,
2312
+ y: flippedScreenPt.y - screenMapCanvasOrigin.y
2313
+ )
2314
+ let mapPoint = CGPoint(
2315
+ x: canvasLocal.x - 8 - origin.x - panOff.x,
2316
+ y: canvasLocal.y - 8 - origin.y - panOff.y
2317
+ )
2318
+
2319
+ let bboxOrig = editor.bboxOrigin
2320
+ let windowPool = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.windows
2321
+ let sorted = windowPool.sorted(by: { $0.zIndex < $1.zIndex })
2322
+ for win in sorted {
2323
+ let f = win.editedFrame
2324
+ let mapRect = CGRect(
2325
+ x: (f.origin.x - bboxOrig.x) * effScale,
2326
+ y: (f.origin.y - bboxOrig.y) * effScale,
2327
+ width: max(f.width * effScale, 4),
2328
+ height: max(f.height * effScale, 4)
2329
+ )
2330
+ if mapRect.contains(mapPoint) { return (win.id, mapRect, mapPoint) }
2331
+ }
2332
+ return nil
2333
+ }
2334
+
2335
+ private func detectDragMode(mapPoint: CGPoint, windowMapRect: CGRect) -> CanvasDragMode {
2336
+ let w = windowMapRect.width
2337
+ let h = windowMapRect.height
2338
+ let threshold = max(4, min(8, min(w, h) * 0.25))
2339
+
2340
+ let nearLeft = mapPoint.x - windowMapRect.minX < threshold
2341
+ let nearRight = windowMapRect.maxX - mapPoint.x < threshold
2342
+ let nearTop = mapPoint.y - windowMapRect.minY < threshold
2343
+ let nearBottom = windowMapRect.maxY - mapPoint.y < threshold
2344
+
2345
+ // Corners take priority
2346
+ if nearTop && nearLeft { return .resizeTopLeft }
2347
+ if nearTop && nearRight { return .resizeTopRight }
2348
+ if nearBottom && nearLeft { return .resizeBottomLeft }
2349
+ if nearBottom && nearRight { return .resizeBottomRight }
2350
+
2351
+ // Edges
2352
+ if nearLeft { return .resizeLeft }
2353
+ if nearRight { return .resizeRight }
2354
+ if nearTop { return .resizeTop }
2355
+ if nearBottom { return .resizeBottom }
2356
+
2357
+ return .move
2358
+ }
2359
+
2360
+ private func flippedScreenPoint(_ event: NSEvent) -> CGPoint {
2361
+ guard let nsWindow = event.window else { return .zero }
2362
+ let loc = event.locationInWindow
2363
+ let windowHeight = nsWindow.contentView?.frame.height ?? nsWindow.frame.height
2364
+ return CGPoint(x: loc.x, y: windowHeight - loc.y)
2365
+ }
2366
+
2367
+ // MARK: - Context Menu
2368
+
2369
+ private func showLayerContextMenu(for windowId: UInt32, at point: NSPoint, in window: NSWindow, editor: ScreenMapEditorState) {
2370
+ guard let winIdx = editor.windows.firstIndex(where: { $0.id == windowId }) else { return }
2371
+ let win = editor.windows[winIdx]
2372
+ let currentLayer = win.layer
2373
+
2374
+ let menu = NSMenu()
2375
+ let header = NSMenuItem(title: "\(win.app) — Layer \(currentLayer)", action: nil, keyEquivalent: "")
2376
+ header.isEnabled = false
2377
+ menu.addItem(header)
2378
+ menu.addItem(.separator())
2379
+
2380
+ // Focus window on screen
2381
+ let focusItem = NSMenuItem(title: "Show on Screen ⌘↩", action: nil, keyEquivalent: "")
2382
+ focusItem.representedObject = ScreenMapFocusMenuAction(windowId: windowId, controller: controller)
2383
+ focusItem.action = #selector(ScreenMapMenuTarget.performFocus(_:))
2384
+ focusItem.target = ScreenMapMenuTarget.shared
2385
+ menu.addItem(focusItem)
2386
+
2387
+ menu.addItem(.separator())
2388
+
2389
+ // Move to Layer → submenu
2390
+ let moveItem = NSMenuItem(title: "Move to Layer", action: nil, keyEquivalent: "")
2391
+ let layerSubmenu = NSMenu()
2392
+
2393
+ for layer in editor.effectiveLayers where layer != currentLayer {
2394
+ let name = editor.layerDisplayName(for: layer)
2395
+ let count = editor.effectiveWindowCount(for: layer)
2396
+ let item = NSMenuItem(title: "\(name) (\(count) windows)", action: nil, keyEquivalent: "")
2397
+ item.representedObject = ScreenMapLayerMenuAction(windowId: windowId, targetLayer: layer, editor: editor, controller: controller)
2398
+ item.action = #selector(ScreenMapMenuTarget.performLayerMove(_:))
2399
+ item.target = ScreenMapMenuTarget.shared
2400
+ layerSubmenu.addItem(item)
2401
+ }
2402
+
2403
+ layerSubmenu.addItem(.separator())
2404
+ let newLayerItem = NSMenuItem(title: "New Layer", action: nil, keyEquivalent: "")
2405
+ newLayerItem.representedObject = ScreenMapLayerMenuAction(windowId: windowId, targetLayer: editor.layerCount, editor: editor, controller: controller)
2406
+ newLayerItem.action = #selector(ScreenMapMenuTarget.performLayerMove(_:))
2407
+ newLayerItem.target = ScreenMapMenuTarget.shared
2408
+ layerSubmenu.addItem(newLayerItem)
2409
+
2410
+ moveItem.submenu = layerSubmenu
2411
+ menu.addItem(moveItem)
2412
+
2413
+ // Convert window coordinates to contentView coordinates for correct menu positioning
2414
+ let menuPoint: NSPoint
2415
+ if let contentView = window.contentView {
2416
+ menuPoint = contentView.convert(point, from: nil)
2417
+ } else {
2418
+ menuPoint = point
2419
+ }
2420
+ menu.popUp(positioning: nil, at: menuPoint, in: window.contentView)
2421
+ }
2422
+ }
2423
+
2424
+ // MARK: - Context Menu Helpers
2425
+
2426
+ struct ScreenMapLayerMenuAction {
2427
+ let windowId: UInt32
2428
+ let targetLayer: Int
2429
+ let editor: ScreenMapEditorState
2430
+ let controller: ScreenMapController
2431
+ }
2432
+
2433
+ struct ScreenMapFocusMenuAction {
2434
+ let windowId: UInt32
2435
+ let controller: ScreenMapController
2436
+ }
2437
+
2438
+ final class ScreenMapMenuTarget: NSObject {
2439
+ static let shared = ScreenMapMenuTarget()
2440
+
2441
+ @objc func performLayerMove(_ sender: NSMenuItem) {
2442
+ guard let action = sender.representedObject as? ScreenMapLayerMenuAction else { return }
2443
+ action.editor.reassignLayer(windowId: action.windowId, toLayer: action.targetLayer, fitToAvailable: true)
2444
+ action.controller.objectWillChange.send()
2445
+ }
2446
+
2447
+ @objc func performFocus(_ sender: NSMenuItem) {
2448
+ guard let action = sender.representedObject as? ScreenMapFocusMenuAction else { return }
2449
+ action.controller.focusWindowOnScreen(action.windowId)
2450
+ }
2451
+ }
2452
+
2453
+ // MARK: - Preview Overlay
2454
+
2455
+ struct ScreenMapPreviewOverlay: View {
2456
+ let windows: [ScreenMapWindowEntry]
2457
+ let layerLabel: String
2458
+ let captures: [UInt32: NSImage]
2459
+ let screenFrame: CGRect
2460
+ let screenCGOrigin: CGPoint
2461
+
2462
+ private static let layerColors: [Color] = [
2463
+ .green, .cyan, .orange, .purple, .pink, .yellow, .mint, .indigo
2464
+ ]
2465
+
2466
+ var body: some View {
2467
+ ZStack(alignment: .topLeading) {
2468
+ Color.black.opacity(0.88)
2469
+
2470
+ ForEach(windows) { win in
2471
+ let f = win.editedFrame
2472
+ let x = f.origin.x - screenCGOrigin.x
2473
+ let y = f.origin.y - screenCGOrigin.y
2474
+ let w = f.width
2475
+ let h = f.height
2476
+ let color = Self.layerColors[win.layer % Self.layerColors.count]
2477
+
2478
+ ZStack {
2479
+ RoundedRectangle(cornerRadius: 6)
2480
+ .fill(color.opacity(0.12))
2481
+ RoundedRectangle(cornerRadius: 6)
2482
+ .strokeBorder(color.opacity(0.7), lineWidth: 2)
2483
+
2484
+ VStack(spacing: 4) {
2485
+ Text(win.app)
2486
+ .font(.system(size: 13, weight: .bold, design: .monospaced))
2487
+ .foregroundColor(.white)
2488
+ if !win.title.isEmpty && h > 60 {
2489
+ Text(win.title)
2490
+ .font(.system(size: 10, design: .monospaced))
2491
+ .foregroundColor(.white.opacity(0.6))
2492
+ .lineLimit(1)
2493
+ }
2494
+ if h > 40 {
2495
+ Text("\(Int(w)) × \(Int(h))")
2496
+ .font(.system(size: 11, weight: .medium, design: .monospaced))
2497
+ .foregroundColor(color.opacity(0.7))
2498
+ }
2499
+ if win.hasEdits && h > 80 {
2500
+ Text("L\(win.layer)")
2501
+ .font(.system(size: 9, weight: .medium, design: .monospaced))
2502
+ .foregroundColor(color.opacity(0.5))
2503
+ }
2504
+ }
2505
+ .padding(8)
2506
+ }
2507
+ .shadow(color: color.opacity(0.3), radius: 8)
2508
+ .frame(width: w, height: h)
2509
+ .offset(x: x, y: y)
2510
+ }
2511
+
2512
+ VStack {
2513
+ Spacer()
2514
+ HStack {
2515
+ Spacer()
2516
+ Text("\(layerLabel) • \(windows.count) windows • click or press any key to dismiss")
2517
+ .font(.system(size: 14, weight: .bold, design: .monospaced))
2518
+ .foregroundColor(.white)
2519
+ .padding(.horizontal, 16)
2520
+ .padding(.vertical, 8)
2521
+ .background(Color.black.opacity(0.7))
2522
+ .cornerRadius(8)
2523
+ .padding(20)
2524
+ Spacer()
2525
+ }
2526
+ }
2527
+ }
2528
+ .frame(width: screenFrame.width, height: screenFrame.height)
2529
+ }
2530
+ }
2531
+
2532
+ // MARK: - Layer Row Frame Preference Key
2533
+
2534
+ private struct LayerRowFrameKey: PreferenceKey {
2535
+ static var defaultValue: [Int: CGRect] = [:]
2536
+ static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
2537
+ value.merge(nextValue(), uniquingKeysWith: { _, new in new })
2538
+ }
2539
+ }
2540
+
2541
+ // MARK: - Show on Screen Bezel
2542
+
2543
+ struct ShowOnScreenBezelView: View {
2544
+ let appName: String
2545
+ let windowTitle: String
2546
+ let displayName: String
2547
+ let displayNumber: Int
2548
+ let layerName: String
2549
+ let windowSize: String
2550
+ let windowsOnDisplay: Int
2551
+ let layersOnDisplay: Int
2552
+ let windowLocalFrame: CGRect // NS coordinates relative to tight window
2553
+ let screenSize: CGSize // tight window size (not full screen)
2554
+ let labelPlacement: LabelPlacement
2555
+ let flush: FlushEdges
2556
+ let windowSnapshot: NSImage? // pre-captured window content for screenshot tools
2557
+
2558
+ enum LabelPlacement { case below, above, right, left }
2559
+
2560
+ /// Which edges of the window are flush with the screen boundary
2561
+ struct FlushEdges {
2562
+ let top: Bool
2563
+ let bottom: Bool
2564
+ let left: Bool
2565
+ let right: Bool
2566
+ static let none = FlushEdges(top: false, bottom: false, left: false, right: false)
2567
+ }
2568
+
2569
+ // Inverted from OS appearance so bezel contrasts with desktop:
2570
+ // Dark mode desktop → light bezel, Light mode desktop → dark bezel
2571
+ @Environment(\.colorScheme) private var colorScheme
2572
+
2573
+ private let accent = Color(red: 0.13, green: 0.62, blue: 0.38)
2574
+
2575
+ private var bg: Color {
2576
+ colorScheme == .dark
2577
+ ? Color(red: 0.92, green: 0.92, blue: 0.93)
2578
+ : Color(red: 0.16, green: 0.16, blue: 0.18)
2579
+ }
2580
+ private var textPrimary: Color {
2581
+ colorScheme == .dark
2582
+ ? Color(red: 0.10, green: 0.10, blue: 0.12)
2583
+ : Color(red: 0.95, green: 0.95, blue: 0.97)
2584
+ }
2585
+ private var textSecondary: Color {
2586
+ colorScheme == .dark
2587
+ ? Color(red: 0.35, green: 0.35, blue: 0.38)
2588
+ : Color(red: 0.68, green: 0.68, blue: 0.72)
2589
+ }
2590
+ private var textTertiary: Color {
2591
+ colorScheme == .dark
2592
+ ? Color(red: 0.55, green: 0.55, blue: 0.58)
2593
+ : Color(red: 0.48, green: 0.48, blue: 0.52)
2594
+ }
2595
+
2596
+ // ZStack uses top-left origin; convert from NS bottom-left
2597
+ private var winX: CGFloat { windowLocalFrame.origin.x }
2598
+ private var winY: CGFloat { screenSize.height - windowLocalFrame.origin.y - windowLocalFrame.height }
2599
+ private var winW: CGFloat { windowLocalFrame.width }
2600
+ private var winH: CGFloat { windowLocalFrame.height }
2601
+
2602
+ // Frame dimensions
2603
+ private let edge: CGFloat = 5 // border thickness on non-flush edges
2604
+ private let shelfHeight: CGFloat = 40 // info shelf thickness
2605
+ private let cornerR: CGFloat = 10 // matches macOS window corners
2606
+
2607
+ // Edge insets: 0 on flush edges, `edge` on free edges
2608
+ private var insetTop: CGFloat { flush.top ? 0 : edge }
2609
+ private var insetBottom: CGFloat { flush.bottom ? 0 : edge }
2610
+ private var insetLeft: CGFloat { flush.left ? 0 : edge }
2611
+ private var insetRight: CGFloat { flush.right ? 0 : edge }
2612
+
2613
+ // Corner radii: 0 if either adjacent edge is flush
2614
+ private var rTL: CGFloat { (flush.top || flush.left) ? 0 : cornerR }
2615
+ private var rTR: CGFloat { (flush.top || flush.right) ? 0 : cornerR }
2616
+ private var rBL: CGFloat { (flush.bottom || flush.left) ? 0 : cornerR }
2617
+ private var rBR: CGFloat { (flush.bottom || flush.right) ? 0 : cornerR }
2618
+
2619
+ var body: some View {
2620
+ ZStack(alignment: .topLeading) {
2621
+ Color.clear
2622
+
2623
+ // Frame origin and size, accounting for flush edges and shelf placement
2624
+ let frameX = winX - insetLeft + shelfOffsetX
2625
+ let frameY = winY - insetTop + shelfOffsetY
2626
+ let frameW = winW + insetLeft + insetRight + shelfExtraW
2627
+ let frameH = winH + insetTop + insetBottom + shelfExtraH
2628
+
2629
+ // Adjust corner radii for shelf side
2630
+ let finalTL = adjustedCornerRadius(rTL, forShelf: labelPlacement, corner: .topLeft)
2631
+ let finalTR = adjustedCornerRadius(rTR, forShelf: labelPlacement, corner: .topRight)
2632
+ let finalBL = adjustedCornerRadius(rBL, forShelf: labelPlacement, corner: .bottomLeft)
2633
+ let finalBR = adjustedCornerRadius(rBR, forShelf: labelPlacement, corner: .bottomRight)
2634
+
2635
+ UnevenRoundedRectangle(
2636
+ topLeadingRadius: finalTL,
2637
+ bottomLeadingRadius: finalBL,
2638
+ bottomTrailingRadius: finalBR,
2639
+ topTrailingRadius: finalTR
2640
+ )
2641
+ .fill(bg)
2642
+ .frame(width: frameW, height: frameH)
2643
+ .offset(x: frameX, y: frameY)
2644
+
2645
+ // Window snapshot — baked into the bezel so screenshot tools get the full composite
2646
+ if let snapshot = windowSnapshot {
2647
+ Image(nsImage: snapshot)
2648
+ .resizable()
2649
+ .interpolation(.high)
2650
+ .frame(width: winW, height: winH)
2651
+ .clipped()
2652
+ .offset(x: winX, y: winY)
2653
+ }
2654
+
2655
+ // Shelf content
2656
+ switch labelPlacement {
2657
+ case .below:
2658
+ shelfContent
2659
+ .frame(width: winW + insetLeft + insetRight - 8, height: shelfHeight - 4)
2660
+ .offset(x: winX - insetLeft + 4, y: winY + winH + insetBottom)
2661
+ case .above:
2662
+ shelfContent
2663
+ .frame(width: winW + insetLeft + insetRight - 8, height: shelfHeight - 4)
2664
+ .offset(x: winX - insetLeft + 4, y: winY - insetTop - shelfHeight + 4)
2665
+ case .right:
2666
+ sideShelfContent
2667
+ .frame(width: 190, height: winH + insetTop + insetBottom)
2668
+ .offset(x: winX + winW + insetRight + 4, y: winY - insetTop)
2669
+ case .left:
2670
+ sideShelfContent
2671
+ .frame(width: 190, height: winH + insetTop + insetBottom)
2672
+ .offset(x: winX - insetLeft - 194, y: winY - insetTop)
2673
+ }
2674
+ }
2675
+ .frame(width: screenSize.width, height: screenSize.height)
2676
+ }
2677
+
2678
+ // MARK: - Shelf geometry helpers
2679
+
2680
+ /// How much extra width/height the shelf adds to the frame
2681
+ private var shelfExtraW: CGFloat {
2682
+ switch labelPlacement {
2683
+ case .below, .above: return 0
2684
+ case .right, .left: return 200
2685
+ }
2686
+ }
2687
+ private var shelfExtraH: CGFloat {
2688
+ switch labelPlacement {
2689
+ case .below, .above: return shelfHeight
2690
+ case .right, .left: return 0
2691
+ }
2692
+ }
2693
+
2694
+ /// Offset the frame origin for shelf on top/left
2695
+ private var shelfOffsetX: CGFloat {
2696
+ labelPlacement == .left ? -200 : 0
2697
+ }
2698
+ private var shelfOffsetY: CGFloat {
2699
+ labelPlacement == .above ? -shelfHeight : 0
2700
+ }
2701
+
2702
+ private enum Corner { case topLeft, topRight, bottomLeft, bottomRight }
2703
+
2704
+ /// Ensure the shelf-side corners are rounded even if the window edge is flush there
2705
+ private func adjustedCornerRadius(_ base: CGFloat, forShelf shelf: LabelPlacement, corner: Corner) -> CGFloat {
2706
+ // The shelf extends outward from the window, so its outer corners should be rounded
2707
+ switch (shelf, corner) {
2708
+ case (.below, .bottomLeft), (.below, .bottomRight):
2709
+ return cornerR
2710
+ case (.above, .topLeft), (.above, .topRight):
2711
+ return cornerR
2712
+ case (.right, .topRight), (.right, .bottomRight):
2713
+ return cornerR
2714
+ case (.left, .topLeft), (.left, .bottomLeft):
2715
+ return cornerR
2716
+ default:
2717
+ return base
2718
+ }
2719
+ }
2720
+
2721
+ // MARK: - Horizontal shelf (bottom / top)
2722
+
2723
+ private var shelfContent: some View {
2724
+ HStack(spacing: 8) {
2725
+ // App name — distinctive rounded font
2726
+ Text(appName)
2727
+ .font(.system(size: 12, weight: .semibold, design: .rounded))
2728
+ .foregroundColor(textPrimary)
2729
+ .lineLimit(1)
2730
+
2731
+ if !windowTitle.isEmpty {
2732
+ Text("·")
2733
+ .foregroundColor(textTertiary)
2734
+ Text(windowTitle)
2735
+ .font(.system(size: 10, design: .monospaced))
2736
+ .foregroundColor(textSecondary)
2737
+ .lineLimit(1)
2738
+ .frame(maxWidth: .infinity, alignment: .leading)
2739
+ } else {
2740
+ Spacer()
2741
+ }
2742
+
2743
+ bezelTag(layerName, color: accent)
2744
+ bezelTag(windowSize, color: textSecondary)
2745
+
2746
+ // Display badge
2747
+ HStack(spacing: 3) {
2748
+ Image(systemName: "display")
2749
+ .font(.system(size: 9))
2750
+ .foregroundColor(textTertiary)
2751
+ Text("\(displayNumber)")
2752
+ .font(.system(size: 11, weight: .semibold, design: .monospaced))
2753
+ .foregroundColor(textSecondary)
2754
+ }
2755
+ }
2756
+ .padding(.horizontal, 10)
2757
+ }
2758
+
2759
+ // MARK: - Side shelf (right)
2760
+
2761
+ private var sideShelfContent: some View {
2762
+ VStack(alignment: .leading, spacing: 6) {
2763
+ Text(appName)
2764
+ .font(.system(size: 12, weight: .semibold, design: .rounded))
2765
+ .foregroundColor(textPrimary)
2766
+ .lineLimit(1)
2767
+ if !windowTitle.isEmpty {
2768
+ Text(windowTitle)
2769
+ .font(.system(size: 9, design: .monospaced))
2770
+ .foregroundColor(textSecondary)
2771
+ .lineLimit(2)
2772
+ }
2773
+ HStack(spacing: 6) {
2774
+ bezelTag(layerName, color: accent)
2775
+ bezelTag(windowSize, color: textSecondary)
2776
+ }
2777
+ Spacer()
2778
+ HStack(spacing: 4) {
2779
+ Image(systemName: "display")
2780
+ .font(.system(size: 9))
2781
+ .foregroundColor(textTertiary)
2782
+ Text("\(displayNumber)")
2783
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
2784
+ .foregroundColor(textSecondary)
2785
+ Text(displayName)
2786
+ .font(.system(size: 8, design: .monospaced))
2787
+ .foregroundColor(textTertiary)
2788
+ .lineLimit(1)
2789
+ }
2790
+ }
2791
+ .padding(8)
2792
+ }
2793
+
2794
+ // MARK: - Helpers
2795
+
2796
+ private func bezelTag(_ text: String, color: Color) -> some View {
2797
+ Text(text)
2798
+ .font(.system(size: 9, weight: .medium, design: .monospaced))
2799
+ .foregroundColor(color)
2800
+ .padding(.horizontal, 5)
2801
+ .padding(.vertical, 2)
2802
+ .background(
2803
+ RoundedRectangle(cornerRadius: 3)
2804
+ .fill(color.opacity(0.08))
2805
+ .overlay(
2806
+ RoundedRectangle(cornerRadius: 3)
2807
+ .strokeBorder(color.opacity(0.15), lineWidth: 0.5)
2808
+ )
2809
+ )
2810
+ }
2811
+ }
2812
+
2813
+ // MARK: - Preference Keys
2814
+
2815
+ private struct SearchOverlayFrameKey: PreferenceKey {
2816
+ static var defaultValue: CGRect = .zero
2817
+ static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
2818
+ value = nextValue()
2819
+ }
2820
+ }