@arach/lattices 0.1.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 (64) hide show
  1. package/README.md +157 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/App.swift +49 -0
  5. package/app/Sources/AppDelegate.swift +104 -0
  6. package/app/Sources/AppShellView.swift +62 -0
  7. package/app/Sources/AppTypeClassifier.swift +70 -0
  8. package/app/Sources/AppWindowShell.swift +63 -0
  9. package/app/Sources/CheatSheetHUD.swift +331 -0
  10. package/app/Sources/CommandModeState.swift +1341 -0
  11. package/app/Sources/CommandModeView.swift +1380 -0
  12. package/app/Sources/CommandModeWindow.swift +192 -0
  13. package/app/Sources/CommandPaletteView.swift +307 -0
  14. package/app/Sources/CommandPaletteWindow.swift +134 -0
  15. package/app/Sources/DaemonProtocol.swift +101 -0
  16. package/app/Sources/DaemonServer.swift +406 -0
  17. package/app/Sources/DesktopModel.swift +121 -0
  18. package/app/Sources/DesktopModelTypes.swift +71 -0
  19. package/app/Sources/DiagnosticLog.swift +253 -0
  20. package/app/Sources/EventBus.swift +29 -0
  21. package/app/Sources/HotkeyManager.swift +249 -0
  22. package/app/Sources/HotkeyStore.swift +330 -0
  23. package/app/Sources/InventoryManager.swift +35 -0
  24. package/app/Sources/InventoryPath.swift +43 -0
  25. package/app/Sources/KeyRecorderView.swift +210 -0
  26. package/app/Sources/LatticesApi.swift +915 -0
  27. package/app/Sources/MainView.swift +507 -0
  28. package/app/Sources/MainWindow.swift +70 -0
  29. package/app/Sources/OrphanRow.swift +129 -0
  30. package/app/Sources/PaletteCommand.swift +409 -0
  31. package/app/Sources/PermissionChecker.swift +115 -0
  32. package/app/Sources/Preferences.swift +48 -0
  33. package/app/Sources/ProcessModel.swift +199 -0
  34. package/app/Sources/ProcessQuery.swift +151 -0
  35. package/app/Sources/Project.swift +28 -0
  36. package/app/Sources/ProjectRow.swift +368 -0
  37. package/app/Sources/ProjectScanner.swift +121 -0
  38. package/app/Sources/ScreenMapState.swift +2397 -0
  39. package/app/Sources/ScreenMapView.swift +2817 -0
  40. package/app/Sources/ScreenMapWindowController.swift +89 -0
  41. package/app/Sources/SessionManager.swift +72 -0
  42. package/app/Sources/SettingsView.swift +641 -0
  43. package/app/Sources/SettingsWindow.swift +20 -0
  44. package/app/Sources/TabGroupRow.swift +178 -0
  45. package/app/Sources/Terminal.swift +259 -0
  46. package/app/Sources/TerminalQuery.swift +156 -0
  47. package/app/Sources/TerminalSynthesizer.swift +200 -0
  48. package/app/Sources/Theme.swift +124 -0
  49. package/app/Sources/TilePickerView.swift +209 -0
  50. package/app/Sources/TmuxModel.swift +53 -0
  51. package/app/Sources/TmuxQuery.swift +81 -0
  52. package/app/Sources/WindowTiler.swift +1752 -0
  53. package/app/Sources/WorkspaceManager.swift +434 -0
  54. package/bin/daemon-client.js +187 -0
  55. package/bin/lattices-app.js +205 -0
  56. package/bin/lattices.js +1295 -0
  57. package/docs/api.md +707 -0
  58. package/docs/app.md +250 -0
  59. package/docs/concepts.md +225 -0
  60. package/docs/config.md +234 -0
  61. package/docs/layers.md +317 -0
  62. package/docs/overview.md +74 -0
  63. package/docs/quickstart.md +82 -0
  64. package/package.json +38 -0
@@ -0,0 +1,2817 @@
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
+ // Track space key for canvas drag-to-pan
1976
+ if event.keyCode == 49 && !controller.isSearchActive {
1977
+ if event.type == .keyDown && !event.isARepeat {
1978
+ isSpaceHeld = true
1979
+ NSCursor.openHand.push()
1980
+ return nil
1981
+ } else if event.type == .keyUp {
1982
+ isSpaceHeld = false
1983
+ spaceDragStart = nil
1984
+ NSCursor.pop()
1985
+ return nil
1986
+ }
1987
+ }
1988
+ guard event.type == .keyDown else { return event }
1989
+ let consumed = controller.handleKey(event.keyCode, modifiers: event.modifierFlags)
1990
+ return consumed ? nil : event
1991
+ }
1992
+ }
1993
+
1994
+ private func removeKeyHandler() {
1995
+ if let monitor = eventMonitor {
1996
+ NSEvent.removeMonitor(monitor)
1997
+ eventMonitor = nil
1998
+ }
1999
+ }
2000
+
2001
+ // MARK: - Mouse Monitors
2002
+
2003
+ private func installMouseMonitors() {
2004
+ let dragThreshold: CGFloat = 4
2005
+
2006
+ mouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { event in
2007
+ guard let eventWindow = event.window,
2008
+ eventWindow === ScreenMapWindowController.shared.nsWindow else { return event }
2009
+
2010
+ // Space+click → begin canvas pan
2011
+ if isSpaceHeld, let editor = controller.editor {
2012
+ spaceDragStart = event.locationInWindow
2013
+ spaceDragPanStart = editor.panOffset
2014
+ NSCursor.closedHand.push()
2015
+ return nil
2016
+ }
2017
+
2018
+ if let hitId = hoveredWindowId, let editor = controller.editor {
2019
+ screenMapClickWindowId = hitId
2020
+ screenMapClickPoint = event.locationInWindow
2021
+ let flippedPt = flippedScreenPoint(event)
2022
+ if let hit = screenMapHitTestWithRect(flippedScreenPt: flippedPt, editor: editor) {
2023
+ editor.canvasDragMode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
2024
+ } else {
2025
+ editor.canvasDragMode = .move
2026
+ }
2027
+ } else {
2028
+ screenMapClickWindowId = nil
2029
+ }
2030
+ return event
2031
+ }
2032
+
2033
+ mouseDragMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
2034
+ // Space+drag → pan canvas
2035
+ if isSpaceHeld, let start = spaceDragStart, let editor = controller.editor {
2036
+ let dx = event.locationInWindow.x - start.x
2037
+ let dy = event.locationInWindow.y - start.y
2038
+ editor.panOffset = CGPoint(x: spaceDragPanStart.x + dx, y: spaceDragPanStart.y - dy)
2039
+ editor.objectWillChange.send()
2040
+ controller.objectWillChange.send()
2041
+ return nil
2042
+ }
2043
+
2044
+ guard let hitId = screenMapClickWindowId,
2045
+ let editor = controller.editor else { return event }
2046
+ let dx = event.locationInWindow.x - screenMapClickPoint.x
2047
+ let dy = event.locationInWindow.y - screenMapClickPoint.y
2048
+ guard sqrt(dx * dx + dy * dy) >= dragThreshold else { return event }
2049
+
2050
+ if editor.draggingWindowId != hitId {
2051
+ editor.draggingWindowId = hitId
2052
+ if let idx = editor.windows.firstIndex(where: { $0.id == hitId }) {
2053
+ editor.dragStartFrame = editor.windows[idx].editedFrame
2054
+ }
2055
+ controller.selectSingle(hitId)
2056
+ }
2057
+
2058
+ let effScale = editor.effectiveScale
2059
+ guard let startFrame = editor.dragStartFrame,
2060
+ effScale > 0,
2061
+ let idx = editor.windows.firstIndex(where: { $0.id == hitId }) else { return event }
2062
+ let screenDx = dx / effScale
2063
+ let screenDy = -dy / effScale // CG coords: Y flipped
2064
+ let mode = editor.canvasDragMode
2065
+ let minW: CGFloat = 100
2066
+ let minH: CGFloat = 50
2067
+
2068
+ var newFrame = startFrame
2069
+
2070
+ switch mode {
2071
+ case .move:
2072
+ newFrame.origin.x = startFrame.origin.x + screenDx
2073
+ newFrame.origin.y = startFrame.origin.y + screenDy
2074
+
2075
+ case .resizeRight:
2076
+ newFrame.size.width = max(minW, startFrame.width + screenDx)
2077
+ case .resizeLeft:
2078
+ let dw = min(screenDx, startFrame.width - minW)
2079
+ newFrame.origin.x = startFrame.origin.x + dw
2080
+ newFrame.size.width = startFrame.width - dw
2081
+ case .resizeBottom:
2082
+ newFrame.size.height = max(minH, startFrame.height + screenDy)
2083
+ case .resizeTop:
2084
+ let dh = min(screenDy, startFrame.height - minH)
2085
+ newFrame.origin.y = startFrame.origin.y + dh
2086
+ newFrame.size.height = startFrame.height - dh
2087
+
2088
+ case .resizeTopLeft:
2089
+ let dw = min(screenDx, startFrame.width - minW)
2090
+ newFrame.origin.x = startFrame.origin.x + dw
2091
+ newFrame.size.width = startFrame.width - dw
2092
+ let dh = min(screenDy, startFrame.height - minH)
2093
+ newFrame.origin.y = startFrame.origin.y + dh
2094
+ newFrame.size.height = startFrame.height - dh
2095
+ case .resizeTopRight:
2096
+ newFrame.size.width = max(minW, startFrame.width + screenDx)
2097
+ let dh = min(screenDy, startFrame.height - minH)
2098
+ newFrame.origin.y = startFrame.origin.y + dh
2099
+ newFrame.size.height = startFrame.height - dh
2100
+ case .resizeBottomLeft:
2101
+ let dw = min(screenDx, startFrame.width - minW)
2102
+ newFrame.origin.x = startFrame.origin.x + dw
2103
+ newFrame.size.width = startFrame.width - dw
2104
+ newFrame.size.height = max(minH, startFrame.height + screenDy)
2105
+ case .resizeBottomRight:
2106
+ newFrame.size.width = max(minW, startFrame.width + screenDx)
2107
+ newFrame.size.height = max(minH, startFrame.height + screenDy)
2108
+ }
2109
+
2110
+ editor.windows[idx].editedFrame = newFrame
2111
+ editor.objectWillChange.send()
2112
+ controller.objectWillChange.send()
2113
+ return nil
2114
+ }
2115
+
2116
+ mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
2117
+ // End space+drag pan
2118
+ if spaceDragStart != nil {
2119
+ spaceDragStart = nil
2120
+ NSCursor.pop() // pop closedHand, openHand remains
2121
+ return event
2122
+ }
2123
+ if screenMapClickWindowId != nil {
2124
+ if let editor = controller.editor, editor.draggingWindowId != nil {
2125
+ editor.draggingWindowId = nil
2126
+ editor.dragStartFrame = nil
2127
+ editor.canvasDragMode = .move
2128
+ editor.objectWillChange.send()
2129
+ }
2130
+ screenMapClickWindowId = nil
2131
+ }
2132
+ return event
2133
+ }
2134
+
2135
+ rightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { event in
2136
+ guard let eventWindow = event.window,
2137
+ eventWindow === ScreenMapWindowController.shared.nsWindow,
2138
+ let editor = controller.editor else { return event }
2139
+
2140
+ let flippedPt = flippedScreenPoint(event)
2141
+ let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
2142
+ guard canvasRect.contains(flippedPt) else { return event }
2143
+
2144
+ if let hitId = screenMapHitTest(flippedScreenPt: flippedPt, editor: editor) {
2145
+ if !controller.isSelected(hitId) {
2146
+ controller.selectSingle(hitId)
2147
+ }
2148
+ showLayerContextMenu(for: hitId, at: event.locationInWindow, in: eventWindow, editor: editor)
2149
+ return nil
2150
+ }
2151
+ return event
2152
+ }
2153
+
2154
+ scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
2155
+ guard let eventWindow = event.window,
2156
+ eventWindow === ScreenMapWindowController.shared.nsWindow,
2157
+ let editor = controller.editor else { return event }
2158
+
2159
+ // Let search overlay handle its own scroll
2160
+ if controller.isSearchActive {
2161
+ let screenPt = event.locationInWindow
2162
+ let windowPt = eventWindow.convertPoint(toScreen: screenPt)
2163
+ let flippedY = NSScreen.main.map { $0.frame.height - windowPt.y } ?? windowPt.y
2164
+ let testPt = CGPoint(x: windowPt.x, y: flippedY)
2165
+ if searchOverlayFrame.contains(testPt) {
2166
+ return event // pass to SwiftUI ScrollView
2167
+ }
2168
+ }
2169
+
2170
+ let flippedPt = flippedScreenPoint(event)
2171
+ let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
2172
+ guard canvasRect.contains(flippedPt) else { return event }
2173
+
2174
+ let isZoom = event.modifierFlags.contains(.command) || !event.hasPreciseScrollingDeltas
2175
+
2176
+ if isZoom {
2177
+ let zoomDelta: CGFloat = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY * 0.01 : event.scrollingDeltaY * 0.05
2178
+ let oldZoom = editor.zoomLevel
2179
+ let newZoom = max(ScreenMapEditorState.minZoom, min(ScreenMapEditorState.maxZoom, oldZoom + zoomDelta))
2180
+ guard newZoom != oldZoom else { return nil }
2181
+
2182
+ let canvasLocal = CGPoint(
2183
+ x: flippedPt.x - screenMapCanvasOrigin.x,
2184
+ y: flippedPt.y - screenMapCanvasOrigin.y
2185
+ )
2186
+ let canvasCenterX = screenMapCanvasSize.width / 2
2187
+ let canvasCenterY = screenMapCanvasSize.height / 2
2188
+ let cursorFromCenter = CGPoint(
2189
+ x: canvasLocal.x - canvasCenterX,
2190
+ y: canvasLocal.y - canvasCenterY
2191
+ )
2192
+
2193
+ let ratio = newZoom / oldZoom
2194
+ let newPanX = cursorFromCenter.x - ratio * (cursorFromCenter.x - editor.panOffset.x)
2195
+ let newPanY = cursorFromCenter.y - ratio * (cursorFromCenter.y - editor.panOffset.y)
2196
+
2197
+ editor.zoomLevel = newZoom
2198
+ editor.panOffset = CGPoint(x: newPanX, y: newPanY)
2199
+ editor.objectWillChange.send()
2200
+ controller.objectWillChange.send()
2201
+ } else {
2202
+ editor.panOffset = CGPoint(
2203
+ x: editor.panOffset.x + event.scrollingDeltaX,
2204
+ y: editor.panOffset.y - event.scrollingDeltaY
2205
+ )
2206
+ editor.objectWillChange.send()
2207
+ controller.objectWillChange.send()
2208
+ }
2209
+ return nil
2210
+ }
2211
+
2212
+ mouseMovedMonitor = NSEvent.addLocalMonitorForEvents(matching: .mouseMoved) { event in
2213
+ guard let eventWindow = event.window,
2214
+ eventWindow === ScreenMapWindowController.shared.nsWindow,
2215
+ let editor = controller.editor else {
2216
+ resetCursorIfNeeded()
2217
+ return event
2218
+ }
2219
+
2220
+ let flippedPt = flippedScreenPoint(event)
2221
+ let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
2222
+ guard canvasRect.contains(flippedPt) else {
2223
+ resetCursorIfNeeded()
2224
+ return event
2225
+ }
2226
+
2227
+ if let hit = screenMapHitTestWithRect(flippedScreenPt: flippedPt, editor: editor) {
2228
+ let mode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
2229
+ if mode != editor.currentCursorMode {
2230
+ if editor.currentCursorMode != .move { NSCursor.pop() }
2231
+ editor.currentCursorMode = mode
2232
+ switch mode {
2233
+ case .resizeLeft, .resizeRight:
2234
+ NSCursor.resizeLeftRight.push()
2235
+ case .resizeTop, .resizeBottom:
2236
+ NSCursor.resizeUpDown.push()
2237
+ case .resizeTopLeft, .resizeTopRight, .resizeBottomLeft, .resizeBottomRight:
2238
+ NSCursor.crosshair.push()
2239
+ case .move:
2240
+ break
2241
+ }
2242
+ }
2243
+ } else {
2244
+ resetCursorIfNeeded()
2245
+ }
2246
+ return event
2247
+ }
2248
+ }
2249
+
2250
+ private func resetCursorIfNeeded() {
2251
+ guard let editor = controller.editor else { return }
2252
+ if editor.currentCursorMode != .move {
2253
+ NSCursor.pop()
2254
+ editor.currentCursorMode = .move
2255
+ }
2256
+ }
2257
+
2258
+ private func removeMouseMonitors() {
2259
+ if let m = mouseDownMonitor { NSEvent.removeMonitor(m); mouseDownMonitor = nil }
2260
+ if let m = mouseDragMonitor { NSEvent.removeMonitor(m); mouseDragMonitor = nil }
2261
+ if let m = mouseUpMonitor { NSEvent.removeMonitor(m); mouseUpMonitor = nil }
2262
+ if let m = rightClickMonitor { NSEvent.removeMonitor(m); rightClickMonitor = nil }
2263
+ if let m = scrollWheelMonitor { NSEvent.removeMonitor(m); scrollWheelMonitor = nil }
2264
+ if let m = mouseMovedMonitor { NSEvent.removeMonitor(m); mouseMovedMonitor = nil }
2265
+ resetCursorIfNeeded()
2266
+ }
2267
+
2268
+ // MARK: - Hit Test / Coordinate Conversion
2269
+
2270
+ private func screenMapHitTest(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> UInt32? {
2271
+ let effScale = editor.effectiveScale
2272
+ let origin = editor.mapOrigin
2273
+ let panOffset = editor.panOffset
2274
+ guard effScale > 0 else { return nil }
2275
+
2276
+ let canvasLocal = CGPoint(
2277
+ x: flippedScreenPt.x - screenMapCanvasOrigin.x,
2278
+ y: flippedScreenPt.y - screenMapCanvasOrigin.y
2279
+ )
2280
+ let mapPoint = CGPoint(
2281
+ x: canvasLocal.x - 8 - origin.x - panOffset.x,
2282
+ y: canvasLocal.y - 8 - origin.y - panOffset.y
2283
+ )
2284
+
2285
+ let bboxOrig = editor.bboxOrigin
2286
+ let windowPool = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.windows
2287
+ let sorted = windowPool.sorted(by: { $0.zIndex < $1.zIndex })
2288
+ for win in sorted {
2289
+ let f = win.editedFrame
2290
+ let mapRect = CGRect(
2291
+ x: (f.origin.x - bboxOrig.x) * effScale,
2292
+ y: (f.origin.y - bboxOrig.y) * effScale,
2293
+ width: max(f.width * effScale, 4),
2294
+ height: max(f.height * effScale, 4)
2295
+ )
2296
+ if mapRect.contains(mapPoint) { return win.id }
2297
+ }
2298
+ return nil
2299
+ }
2300
+
2301
+ private func screenMapHitTestWithRect(flippedScreenPt: CGPoint, editor: ScreenMapEditorState) -> (id: UInt32, mapRect: CGRect, mapPoint: CGPoint)? {
2302
+ let effScale = editor.effectiveScale
2303
+ let origin = editor.mapOrigin
2304
+ let panOff = editor.panOffset
2305
+ guard effScale > 0 else { return nil }
2306
+
2307
+ let canvasLocal = CGPoint(
2308
+ x: flippedScreenPt.x - screenMapCanvasOrigin.x,
2309
+ y: flippedScreenPt.y - screenMapCanvasOrigin.y
2310
+ )
2311
+ let mapPoint = CGPoint(
2312
+ x: canvasLocal.x - 8 - origin.x - panOff.x,
2313
+ y: canvasLocal.y - 8 - origin.y - panOff.y
2314
+ )
2315
+
2316
+ let bboxOrig = editor.bboxOrigin
2317
+ let windowPool = editor.focusedDisplayIndex != nil ? editor.focusedVisibleWindows : editor.windows
2318
+ let sorted = windowPool.sorted(by: { $0.zIndex < $1.zIndex })
2319
+ for win in sorted {
2320
+ let f = win.editedFrame
2321
+ let mapRect = CGRect(
2322
+ x: (f.origin.x - bboxOrig.x) * effScale,
2323
+ y: (f.origin.y - bboxOrig.y) * effScale,
2324
+ width: max(f.width * effScale, 4),
2325
+ height: max(f.height * effScale, 4)
2326
+ )
2327
+ if mapRect.contains(mapPoint) { return (win.id, mapRect, mapPoint) }
2328
+ }
2329
+ return nil
2330
+ }
2331
+
2332
+ private func detectDragMode(mapPoint: CGPoint, windowMapRect: CGRect) -> CanvasDragMode {
2333
+ let w = windowMapRect.width
2334
+ let h = windowMapRect.height
2335
+ let threshold = max(4, min(8, min(w, h) * 0.25))
2336
+
2337
+ let nearLeft = mapPoint.x - windowMapRect.minX < threshold
2338
+ let nearRight = windowMapRect.maxX - mapPoint.x < threshold
2339
+ let nearTop = mapPoint.y - windowMapRect.minY < threshold
2340
+ let nearBottom = windowMapRect.maxY - mapPoint.y < threshold
2341
+
2342
+ // Corners take priority
2343
+ if nearTop && nearLeft { return .resizeTopLeft }
2344
+ if nearTop && nearRight { return .resizeTopRight }
2345
+ if nearBottom && nearLeft { return .resizeBottomLeft }
2346
+ if nearBottom && nearRight { return .resizeBottomRight }
2347
+
2348
+ // Edges
2349
+ if nearLeft { return .resizeLeft }
2350
+ if nearRight { return .resizeRight }
2351
+ if nearTop { return .resizeTop }
2352
+ if nearBottom { return .resizeBottom }
2353
+
2354
+ return .move
2355
+ }
2356
+
2357
+ private func flippedScreenPoint(_ event: NSEvent) -> CGPoint {
2358
+ guard let nsWindow = event.window else { return .zero }
2359
+ let loc = event.locationInWindow
2360
+ let windowHeight = nsWindow.contentView?.frame.height ?? nsWindow.frame.height
2361
+ return CGPoint(x: loc.x, y: windowHeight - loc.y)
2362
+ }
2363
+
2364
+ // MARK: - Context Menu
2365
+
2366
+ private func showLayerContextMenu(for windowId: UInt32, at point: NSPoint, in window: NSWindow, editor: ScreenMapEditorState) {
2367
+ guard let winIdx = editor.windows.firstIndex(where: { $0.id == windowId }) else { return }
2368
+ let win = editor.windows[winIdx]
2369
+ let currentLayer = win.layer
2370
+
2371
+ let menu = NSMenu()
2372
+ let header = NSMenuItem(title: "\(win.app) — Layer \(currentLayer)", action: nil, keyEquivalent: "")
2373
+ header.isEnabled = false
2374
+ menu.addItem(header)
2375
+ menu.addItem(.separator())
2376
+
2377
+ // Focus window on screen
2378
+ let focusItem = NSMenuItem(title: "Show on Screen ⌘↩", action: nil, keyEquivalent: "")
2379
+ focusItem.representedObject = ScreenMapFocusMenuAction(windowId: windowId, controller: controller)
2380
+ focusItem.action = #selector(ScreenMapMenuTarget.performFocus(_:))
2381
+ focusItem.target = ScreenMapMenuTarget.shared
2382
+ menu.addItem(focusItem)
2383
+
2384
+ menu.addItem(.separator())
2385
+
2386
+ // Move to Layer → submenu
2387
+ let moveItem = NSMenuItem(title: "Move to Layer", action: nil, keyEquivalent: "")
2388
+ let layerSubmenu = NSMenu()
2389
+
2390
+ for layer in editor.effectiveLayers where layer != currentLayer {
2391
+ let name = editor.layerDisplayName(for: layer)
2392
+ let count = editor.effectiveWindowCount(for: layer)
2393
+ let item = NSMenuItem(title: "\(name) (\(count) windows)", action: nil, keyEquivalent: "")
2394
+ item.representedObject = ScreenMapLayerMenuAction(windowId: windowId, targetLayer: layer, editor: editor, controller: controller)
2395
+ item.action = #selector(ScreenMapMenuTarget.performLayerMove(_:))
2396
+ item.target = ScreenMapMenuTarget.shared
2397
+ layerSubmenu.addItem(item)
2398
+ }
2399
+
2400
+ layerSubmenu.addItem(.separator())
2401
+ let newLayerItem = NSMenuItem(title: "New Layer", action: nil, keyEquivalent: "")
2402
+ newLayerItem.representedObject = ScreenMapLayerMenuAction(windowId: windowId, targetLayer: editor.layerCount, editor: editor, controller: controller)
2403
+ newLayerItem.action = #selector(ScreenMapMenuTarget.performLayerMove(_:))
2404
+ newLayerItem.target = ScreenMapMenuTarget.shared
2405
+ layerSubmenu.addItem(newLayerItem)
2406
+
2407
+ moveItem.submenu = layerSubmenu
2408
+ menu.addItem(moveItem)
2409
+
2410
+ // Convert window coordinates to contentView coordinates for correct menu positioning
2411
+ let menuPoint: NSPoint
2412
+ if let contentView = window.contentView {
2413
+ menuPoint = contentView.convert(point, from: nil)
2414
+ } else {
2415
+ menuPoint = point
2416
+ }
2417
+ menu.popUp(positioning: nil, at: menuPoint, in: window.contentView)
2418
+ }
2419
+ }
2420
+
2421
+ // MARK: - Context Menu Helpers
2422
+
2423
+ struct ScreenMapLayerMenuAction {
2424
+ let windowId: UInt32
2425
+ let targetLayer: Int
2426
+ let editor: ScreenMapEditorState
2427
+ let controller: ScreenMapController
2428
+ }
2429
+
2430
+ struct ScreenMapFocusMenuAction {
2431
+ let windowId: UInt32
2432
+ let controller: ScreenMapController
2433
+ }
2434
+
2435
+ final class ScreenMapMenuTarget: NSObject {
2436
+ static let shared = ScreenMapMenuTarget()
2437
+
2438
+ @objc func performLayerMove(_ sender: NSMenuItem) {
2439
+ guard let action = sender.representedObject as? ScreenMapLayerMenuAction else { return }
2440
+ action.editor.reassignLayer(windowId: action.windowId, toLayer: action.targetLayer, fitToAvailable: true)
2441
+ action.controller.objectWillChange.send()
2442
+ }
2443
+
2444
+ @objc func performFocus(_ sender: NSMenuItem) {
2445
+ guard let action = sender.representedObject as? ScreenMapFocusMenuAction else { return }
2446
+ action.controller.focusWindowOnScreen(action.windowId)
2447
+ }
2448
+ }
2449
+
2450
+ // MARK: - Preview Overlay
2451
+
2452
+ struct ScreenMapPreviewOverlay: View {
2453
+ let windows: [ScreenMapWindowEntry]
2454
+ let layerLabel: String
2455
+ let captures: [UInt32: NSImage]
2456
+ let screenFrame: CGRect
2457
+ let screenCGOrigin: CGPoint
2458
+
2459
+ private static let layerColors: [Color] = [
2460
+ .green, .cyan, .orange, .purple, .pink, .yellow, .mint, .indigo
2461
+ ]
2462
+
2463
+ var body: some View {
2464
+ ZStack(alignment: .topLeading) {
2465
+ Color.black.opacity(0.88)
2466
+
2467
+ ForEach(windows) { win in
2468
+ let f = win.editedFrame
2469
+ let x = f.origin.x - screenCGOrigin.x
2470
+ let y = f.origin.y - screenCGOrigin.y
2471
+ let w = f.width
2472
+ let h = f.height
2473
+ let color = Self.layerColors[win.layer % Self.layerColors.count]
2474
+
2475
+ ZStack {
2476
+ RoundedRectangle(cornerRadius: 6)
2477
+ .fill(color.opacity(0.12))
2478
+ RoundedRectangle(cornerRadius: 6)
2479
+ .strokeBorder(color.opacity(0.7), lineWidth: 2)
2480
+
2481
+ VStack(spacing: 4) {
2482
+ Text(win.app)
2483
+ .font(.system(size: 13, weight: .bold, design: .monospaced))
2484
+ .foregroundColor(.white)
2485
+ if !win.title.isEmpty && h > 60 {
2486
+ Text(win.title)
2487
+ .font(.system(size: 10, design: .monospaced))
2488
+ .foregroundColor(.white.opacity(0.6))
2489
+ .lineLimit(1)
2490
+ }
2491
+ if h > 40 {
2492
+ Text("\(Int(w)) × \(Int(h))")
2493
+ .font(.system(size: 11, weight: .medium, design: .monospaced))
2494
+ .foregroundColor(color.opacity(0.7))
2495
+ }
2496
+ if win.hasEdits && h > 80 {
2497
+ Text("L\(win.layer)")
2498
+ .font(.system(size: 9, weight: .medium, design: .monospaced))
2499
+ .foregroundColor(color.opacity(0.5))
2500
+ }
2501
+ }
2502
+ .padding(8)
2503
+ }
2504
+ .shadow(color: color.opacity(0.3), radius: 8)
2505
+ .frame(width: w, height: h)
2506
+ .offset(x: x, y: y)
2507
+ }
2508
+
2509
+ VStack {
2510
+ Spacer()
2511
+ HStack {
2512
+ Spacer()
2513
+ Text("\(layerLabel) • \(windows.count) windows • click or press any key to dismiss")
2514
+ .font(.system(size: 14, weight: .bold, design: .monospaced))
2515
+ .foregroundColor(.white)
2516
+ .padding(.horizontal, 16)
2517
+ .padding(.vertical, 8)
2518
+ .background(Color.black.opacity(0.7))
2519
+ .cornerRadius(8)
2520
+ .padding(20)
2521
+ Spacer()
2522
+ }
2523
+ }
2524
+ }
2525
+ .frame(width: screenFrame.width, height: screenFrame.height)
2526
+ }
2527
+ }
2528
+
2529
+ // MARK: - Layer Row Frame Preference Key
2530
+
2531
+ private struct LayerRowFrameKey: PreferenceKey {
2532
+ static var defaultValue: [Int: CGRect] = [:]
2533
+ static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
2534
+ value.merge(nextValue(), uniquingKeysWith: { _, new in new })
2535
+ }
2536
+ }
2537
+
2538
+ // MARK: - Show on Screen Bezel
2539
+
2540
+ struct ShowOnScreenBezelView: View {
2541
+ let appName: String
2542
+ let windowTitle: String
2543
+ let displayName: String
2544
+ let displayNumber: Int
2545
+ let layerName: String
2546
+ let windowSize: String
2547
+ let windowsOnDisplay: Int
2548
+ let layersOnDisplay: Int
2549
+ let windowLocalFrame: CGRect // NS coordinates relative to tight window
2550
+ let screenSize: CGSize // tight window size (not full screen)
2551
+ let labelPlacement: LabelPlacement
2552
+ let flush: FlushEdges
2553
+ let windowSnapshot: NSImage? // pre-captured window content for screenshot tools
2554
+
2555
+ enum LabelPlacement { case below, above, right, left }
2556
+
2557
+ /// Which edges of the window are flush with the screen boundary
2558
+ struct FlushEdges {
2559
+ let top: Bool
2560
+ let bottom: Bool
2561
+ let left: Bool
2562
+ let right: Bool
2563
+ static let none = FlushEdges(top: false, bottom: false, left: false, right: false)
2564
+ }
2565
+
2566
+ // Inverted from OS appearance so bezel contrasts with desktop:
2567
+ // Dark mode desktop → light bezel, Light mode desktop → dark bezel
2568
+ @Environment(\.colorScheme) private var colorScheme
2569
+
2570
+ private let accent = Color(red: 0.13, green: 0.62, blue: 0.38)
2571
+
2572
+ private var bg: Color {
2573
+ colorScheme == .dark
2574
+ ? Color(red: 0.92, green: 0.92, blue: 0.93)
2575
+ : Color(red: 0.16, green: 0.16, blue: 0.18)
2576
+ }
2577
+ private var textPrimary: Color {
2578
+ colorScheme == .dark
2579
+ ? Color(red: 0.10, green: 0.10, blue: 0.12)
2580
+ : Color(red: 0.95, green: 0.95, blue: 0.97)
2581
+ }
2582
+ private var textSecondary: Color {
2583
+ colorScheme == .dark
2584
+ ? Color(red: 0.35, green: 0.35, blue: 0.38)
2585
+ : Color(red: 0.68, green: 0.68, blue: 0.72)
2586
+ }
2587
+ private var textTertiary: Color {
2588
+ colorScheme == .dark
2589
+ ? Color(red: 0.55, green: 0.55, blue: 0.58)
2590
+ : Color(red: 0.48, green: 0.48, blue: 0.52)
2591
+ }
2592
+
2593
+ // ZStack uses top-left origin; convert from NS bottom-left
2594
+ private var winX: CGFloat { windowLocalFrame.origin.x }
2595
+ private var winY: CGFloat { screenSize.height - windowLocalFrame.origin.y - windowLocalFrame.height }
2596
+ private var winW: CGFloat { windowLocalFrame.width }
2597
+ private var winH: CGFloat { windowLocalFrame.height }
2598
+
2599
+ // Frame dimensions
2600
+ private let edge: CGFloat = 5 // border thickness on non-flush edges
2601
+ private let shelfHeight: CGFloat = 40 // info shelf thickness
2602
+ private let cornerR: CGFloat = 10 // matches macOS window corners
2603
+
2604
+ // Edge insets: 0 on flush edges, `edge` on free edges
2605
+ private var insetTop: CGFloat { flush.top ? 0 : edge }
2606
+ private var insetBottom: CGFloat { flush.bottom ? 0 : edge }
2607
+ private var insetLeft: CGFloat { flush.left ? 0 : edge }
2608
+ private var insetRight: CGFloat { flush.right ? 0 : edge }
2609
+
2610
+ // Corner radii: 0 if either adjacent edge is flush
2611
+ private var rTL: CGFloat { (flush.top || flush.left) ? 0 : cornerR }
2612
+ private var rTR: CGFloat { (flush.top || flush.right) ? 0 : cornerR }
2613
+ private var rBL: CGFloat { (flush.bottom || flush.left) ? 0 : cornerR }
2614
+ private var rBR: CGFloat { (flush.bottom || flush.right) ? 0 : cornerR }
2615
+
2616
+ var body: some View {
2617
+ ZStack(alignment: .topLeading) {
2618
+ Color.clear
2619
+
2620
+ // Frame origin and size, accounting for flush edges and shelf placement
2621
+ let frameX = winX - insetLeft + shelfOffsetX
2622
+ let frameY = winY - insetTop + shelfOffsetY
2623
+ let frameW = winW + insetLeft + insetRight + shelfExtraW
2624
+ let frameH = winH + insetTop + insetBottom + shelfExtraH
2625
+
2626
+ // Adjust corner radii for shelf side
2627
+ let finalTL = adjustedCornerRadius(rTL, forShelf: labelPlacement, corner: .topLeft)
2628
+ let finalTR = adjustedCornerRadius(rTR, forShelf: labelPlacement, corner: .topRight)
2629
+ let finalBL = adjustedCornerRadius(rBL, forShelf: labelPlacement, corner: .bottomLeft)
2630
+ let finalBR = adjustedCornerRadius(rBR, forShelf: labelPlacement, corner: .bottomRight)
2631
+
2632
+ UnevenRoundedRectangle(
2633
+ topLeadingRadius: finalTL,
2634
+ bottomLeadingRadius: finalBL,
2635
+ bottomTrailingRadius: finalBR,
2636
+ topTrailingRadius: finalTR
2637
+ )
2638
+ .fill(bg)
2639
+ .frame(width: frameW, height: frameH)
2640
+ .offset(x: frameX, y: frameY)
2641
+
2642
+ // Window snapshot — baked into the bezel so screenshot tools get the full composite
2643
+ if let snapshot = windowSnapshot {
2644
+ Image(nsImage: snapshot)
2645
+ .resizable()
2646
+ .interpolation(.high)
2647
+ .frame(width: winW, height: winH)
2648
+ .clipped()
2649
+ .offset(x: winX, y: winY)
2650
+ }
2651
+
2652
+ // Shelf content
2653
+ switch labelPlacement {
2654
+ case .below:
2655
+ shelfContent
2656
+ .frame(width: winW + insetLeft + insetRight - 8, height: shelfHeight - 4)
2657
+ .offset(x: winX - insetLeft + 4, y: winY + winH + insetBottom)
2658
+ case .above:
2659
+ shelfContent
2660
+ .frame(width: winW + insetLeft + insetRight - 8, height: shelfHeight - 4)
2661
+ .offset(x: winX - insetLeft + 4, y: winY - insetTop - shelfHeight + 4)
2662
+ case .right:
2663
+ sideShelfContent
2664
+ .frame(width: 190, height: winH + insetTop + insetBottom)
2665
+ .offset(x: winX + winW + insetRight + 4, y: winY - insetTop)
2666
+ case .left:
2667
+ sideShelfContent
2668
+ .frame(width: 190, height: winH + insetTop + insetBottom)
2669
+ .offset(x: winX - insetLeft - 194, y: winY - insetTop)
2670
+ }
2671
+ }
2672
+ .frame(width: screenSize.width, height: screenSize.height)
2673
+ }
2674
+
2675
+ // MARK: - Shelf geometry helpers
2676
+
2677
+ /// How much extra width/height the shelf adds to the frame
2678
+ private var shelfExtraW: CGFloat {
2679
+ switch labelPlacement {
2680
+ case .below, .above: return 0
2681
+ case .right, .left: return 200
2682
+ }
2683
+ }
2684
+ private var shelfExtraH: CGFloat {
2685
+ switch labelPlacement {
2686
+ case .below, .above: return shelfHeight
2687
+ case .right, .left: return 0
2688
+ }
2689
+ }
2690
+
2691
+ /// Offset the frame origin for shelf on top/left
2692
+ private var shelfOffsetX: CGFloat {
2693
+ labelPlacement == .left ? -200 : 0
2694
+ }
2695
+ private var shelfOffsetY: CGFloat {
2696
+ labelPlacement == .above ? -shelfHeight : 0
2697
+ }
2698
+
2699
+ private enum Corner { case topLeft, topRight, bottomLeft, bottomRight }
2700
+
2701
+ /// Ensure the shelf-side corners are rounded even if the window edge is flush there
2702
+ private func adjustedCornerRadius(_ base: CGFloat, forShelf shelf: LabelPlacement, corner: Corner) -> CGFloat {
2703
+ // The shelf extends outward from the window, so its outer corners should be rounded
2704
+ switch (shelf, corner) {
2705
+ case (.below, .bottomLeft), (.below, .bottomRight):
2706
+ return cornerR
2707
+ case (.above, .topLeft), (.above, .topRight):
2708
+ return cornerR
2709
+ case (.right, .topRight), (.right, .bottomRight):
2710
+ return cornerR
2711
+ case (.left, .topLeft), (.left, .bottomLeft):
2712
+ return cornerR
2713
+ default:
2714
+ return base
2715
+ }
2716
+ }
2717
+
2718
+ // MARK: - Horizontal shelf (bottom / top)
2719
+
2720
+ private var shelfContent: some View {
2721
+ HStack(spacing: 8) {
2722
+ // App name — distinctive rounded font
2723
+ Text(appName)
2724
+ .font(.system(size: 12, weight: .semibold, design: .rounded))
2725
+ .foregroundColor(textPrimary)
2726
+ .lineLimit(1)
2727
+
2728
+ if !windowTitle.isEmpty {
2729
+ Text("·")
2730
+ .foregroundColor(textTertiary)
2731
+ Text(windowTitle)
2732
+ .font(.system(size: 10, design: .monospaced))
2733
+ .foregroundColor(textSecondary)
2734
+ .lineLimit(1)
2735
+ .frame(maxWidth: .infinity, alignment: .leading)
2736
+ } else {
2737
+ Spacer()
2738
+ }
2739
+
2740
+ bezelTag(layerName, color: accent)
2741
+ bezelTag(windowSize, color: textSecondary)
2742
+
2743
+ // Display badge
2744
+ HStack(spacing: 3) {
2745
+ Image(systemName: "display")
2746
+ .font(.system(size: 9))
2747
+ .foregroundColor(textTertiary)
2748
+ Text("\(displayNumber)")
2749
+ .font(.system(size: 11, weight: .semibold, design: .monospaced))
2750
+ .foregroundColor(textSecondary)
2751
+ }
2752
+ }
2753
+ .padding(.horizontal, 10)
2754
+ }
2755
+
2756
+ // MARK: - Side shelf (right)
2757
+
2758
+ private var sideShelfContent: some View {
2759
+ VStack(alignment: .leading, spacing: 6) {
2760
+ Text(appName)
2761
+ .font(.system(size: 12, weight: .semibold, design: .rounded))
2762
+ .foregroundColor(textPrimary)
2763
+ .lineLimit(1)
2764
+ if !windowTitle.isEmpty {
2765
+ Text(windowTitle)
2766
+ .font(.system(size: 9, design: .monospaced))
2767
+ .foregroundColor(textSecondary)
2768
+ .lineLimit(2)
2769
+ }
2770
+ HStack(spacing: 6) {
2771
+ bezelTag(layerName, color: accent)
2772
+ bezelTag(windowSize, color: textSecondary)
2773
+ }
2774
+ Spacer()
2775
+ HStack(spacing: 4) {
2776
+ Image(systemName: "display")
2777
+ .font(.system(size: 9))
2778
+ .foregroundColor(textTertiary)
2779
+ Text("\(displayNumber)")
2780
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
2781
+ .foregroundColor(textSecondary)
2782
+ Text(displayName)
2783
+ .font(.system(size: 8, design: .monospaced))
2784
+ .foregroundColor(textTertiary)
2785
+ .lineLimit(1)
2786
+ }
2787
+ }
2788
+ .padding(8)
2789
+ }
2790
+
2791
+ // MARK: - Helpers
2792
+
2793
+ private func bezelTag(_ text: String, color: Color) -> some View {
2794
+ Text(text)
2795
+ .font(.system(size: 9, weight: .medium, design: .monospaced))
2796
+ .foregroundColor(color)
2797
+ .padding(.horizontal, 5)
2798
+ .padding(.vertical, 2)
2799
+ .background(
2800
+ RoundedRectangle(cornerRadius: 3)
2801
+ .fill(color.opacity(0.08))
2802
+ .overlay(
2803
+ RoundedRectangle(cornerRadius: 3)
2804
+ .strokeBorder(color.opacity(0.15), lineWidth: 0.5)
2805
+ )
2806
+ )
2807
+ }
2808
+ }
2809
+
2810
+ // MARK: - Preference Keys
2811
+
2812
+ private struct SearchOverlayFrameKey: PreferenceKey {
2813
+ static var defaultValue: CGRect = .zero
2814
+ static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
2815
+ value = nextValue()
2816
+ }
2817
+ }