@lattices/cli 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +155 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/AccessibilityTextExtractor.swift +111 -0
  5. package/app/Sources/ActionRow.swift +61 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +242 -0
  8. package/app/Sources/AppShellView.swift +62 -0
  9. package/app/Sources/AppTypeClassifier.swift +70 -0
  10. package/app/Sources/AppWindowShell.swift +63 -0
  11. package/app/Sources/CheatSheetHUD.swift +332 -0
  12. package/app/Sources/CommandModeState.swift +1362 -0
  13. package/app/Sources/CommandModeView.swift +1405 -0
  14. package/app/Sources/CommandModeWindow.swift +192 -0
  15. package/app/Sources/CommandPaletteView.swift +307 -0
  16. package/app/Sources/CommandPaletteWindow.swift +134 -0
  17. package/app/Sources/DaemonProtocol.swift +101 -0
  18. package/app/Sources/DaemonServer.swift +414 -0
  19. package/app/Sources/DesktopModel.swift +149 -0
  20. package/app/Sources/DesktopModelTypes.swift +71 -0
  21. package/app/Sources/DiagnosticLog.swift +271 -0
  22. package/app/Sources/EventBus.swift +30 -0
  23. package/app/Sources/HotkeyManager.swift +254 -0
  24. package/app/Sources/HotkeyStore.swift +338 -0
  25. package/app/Sources/InventoryManager.swift +35 -0
  26. package/app/Sources/InventoryPath.swift +43 -0
  27. package/app/Sources/KeyRecorderView.swift +210 -0
  28. package/app/Sources/LatticesApi.swift +1234 -0
  29. package/app/Sources/LayerBezel.swift +203 -0
  30. package/app/Sources/MainView.swift +479 -0
  31. package/app/Sources/MainWindow.swift +83 -0
  32. package/app/Sources/OcrModel.swift +430 -0
  33. package/app/Sources/OcrStore.swift +329 -0
  34. package/app/Sources/OmniSearchState.swift +283 -0
  35. package/app/Sources/OmniSearchView.swift +288 -0
  36. package/app/Sources/OmniSearchWindow.swift +105 -0
  37. package/app/Sources/OrphanRow.swift +129 -0
  38. package/app/Sources/PaletteCommand.swift +419 -0
  39. package/app/Sources/PermissionChecker.swift +125 -0
  40. package/app/Sources/Preferences.swift +99 -0
  41. package/app/Sources/ProcessModel.swift +199 -0
  42. package/app/Sources/ProcessQuery.swift +151 -0
  43. package/app/Sources/Project.swift +28 -0
  44. package/app/Sources/ProjectRow.swift +368 -0
  45. package/app/Sources/ProjectScanner.swift +128 -0
  46. package/app/Sources/ScreenMapState.swift +2387 -0
  47. package/app/Sources/ScreenMapView.swift +2820 -0
  48. package/app/Sources/ScreenMapWindowController.swift +89 -0
  49. package/app/Sources/SessionManager.swift +72 -0
  50. package/app/Sources/SettingsView.swift +1064 -0
  51. package/app/Sources/SettingsWindow.swift +20 -0
  52. package/app/Sources/TabGroupRow.swift +178 -0
  53. package/app/Sources/Terminal.swift +259 -0
  54. package/app/Sources/TerminalQuery.swift +156 -0
  55. package/app/Sources/TerminalSynthesizer.swift +200 -0
  56. package/app/Sources/Theme.swift +163 -0
  57. package/app/Sources/TilePickerView.swift +209 -0
  58. package/app/Sources/TmuxModel.swift +53 -0
  59. package/app/Sources/TmuxQuery.swift +81 -0
  60. package/app/Sources/WindowTiler.swift +1778 -0
  61. package/app/Sources/WorkspaceManager.swift +575 -0
  62. package/bin/client.js +4 -0
  63. package/bin/daemon-client.js +187 -0
  64. package/bin/lattices-app.js +221 -0
  65. package/bin/lattices.js +1551 -0
  66. package/docs/api.md +924 -0
  67. package/docs/app.md +297 -0
  68. package/docs/concepts.md +135 -0
  69. package/docs/config.md +245 -0
  70. package/docs/layers.md +410 -0
  71. package/docs/ocr.md +185 -0
  72. package/docs/overview.md +94 -0
  73. package/docs/quickstart.md +75 -0
  74. package/package.json +42 -0
@@ -0,0 +1,2387 @@
1
+ import AppKit
2
+ import Foundation
3
+ import SwiftUI
4
+
5
+ // MARK: - Display Geometry
6
+
7
+ struct DisplayGeometry {
8
+ let index: Int
9
+ let cgRect: CGRect // in unified CG coords (top-left origin)
10
+ let label: String // e.g. "Built-in Retina Display", "LG UltraFine"
11
+ }
12
+
13
+ // MARK: - Screen Map Window Entry
14
+
15
+ struct ScreenMapWindowEntry: Identifiable {
16
+ let id: UInt32 // CGWindowID
17
+ let pid: Int32 // for AX API
18
+ let app: String
19
+ let title: String
20
+ var originalFrame: CGRect // frozen at snapshot time
21
+ var editedFrame: CGRect // mutated during drag
22
+ let zIndex: Int // 0 = frontmost
23
+ var layer: Int // assigned by iterative peeling (per-display)
24
+ let displayIndex: Int // which monitor this window belongs to
25
+ let isOnScreen: Bool // visible on current Space
26
+ var latticesSession: String? // parsed from [lattices:name] in title
27
+ var tmuxCommand: String? // running command from tmux pane (e.g. "vim", "node")
28
+ var tmuxPaneTitle: String? // tmux pane title (often cwd or custom label)
29
+ var hasEdits: Bool { originalFrame != editedFrame }
30
+
31
+ /// Rich search key combining all available metadata.
32
+ /// Format: m{spatial}.L{layer}.{layerName}.{app}.{title}.{session}.{command}.{paneTitle}.{state}
33
+ /// Example: m1.L0.primary.terminal.~/dev/lattices.session:myproject.cmd:vim.visible
34
+ func searchKey(spatialNumber: Int, layerName: String?) -> String {
35
+ var parts: [String] = []
36
+ parts.append("m\(spatialNumber)")
37
+ parts.append(layerName.map { "L\(layer).\($0)" } ?? "L\(layer)")
38
+ parts.append(app)
39
+ parts.append(title.isEmpty ? "_" : title)
40
+ if let session = latticesSession {
41
+ parts.append("session:\(session)")
42
+ }
43
+ if let cmd = tmuxCommand, !cmd.isEmpty {
44
+ parts.append("cmd:\(cmd)")
45
+ }
46
+ if let pTitle = tmuxPaneTitle, !pTitle.isEmpty, pTitle != title {
47
+ parts.append(pTitle)
48
+ }
49
+ parts.append(isOnScreen ? "visible" : "hidden")
50
+ return parts.joined(separator: ".").lowercased()
51
+ }
52
+ }
53
+
54
+ // MARK: - Canvas Drag Mode
55
+
56
+ enum CanvasDragMode {
57
+ case move
58
+ case resizeLeft, resizeRight, resizeTop, resizeBottom
59
+ case resizeTopLeft, resizeTopRight, resizeBottomLeft, resizeBottomRight
60
+ }
61
+
62
+ // MARK: - Screen Map Editor State
63
+
64
+ final class ScreenMapEditorState: ObservableObject {
65
+ @Published var windows: [ScreenMapWindowEntry]
66
+ @Published var selectedLayers: Set<Int> = [0] // empty = show all
67
+ @Published var draggingWindowId: UInt32? = nil
68
+ var canvasDragMode: CanvasDragMode = .move
69
+ var currentCursorMode: CanvasDragMode = .move
70
+ @Published var isPreviewing: Bool = false
71
+ @Published var lastActionRef: String? = nil
72
+ @Published var zoomLevel: CGFloat = 1.0 // 1.0 = fit-all
73
+ @Published var panOffset: CGPoint = .zero // canvas-local pixels
74
+ @Published var focusedDisplayIndex: Int? = nil // nil = all-displays view
75
+ @Published var windowSearchQuery: String = ""
76
+ @Published var isTilingMode: Bool = false
77
+ var isSearching: Bool { !windowSearchQuery.isEmpty }
78
+
79
+ var searchFilteredWindows: [ScreenMapWindowEntry] {
80
+ guard !windowSearchQuery.isEmpty else { return [] }
81
+ let terms = windowSearchQuery.lowercased()
82
+ .split(separator: " ")
83
+ .map(String.init)
84
+ .filter { !$0.isEmpty }
85
+ guard !terms.isEmpty else { return [] }
86
+
87
+ // Pre-compile glob patterns into matchers
88
+ let matchers: [(String) -> Bool] = terms.map { term in
89
+ if term.hasPrefix("/"), term.count > 1 {
90
+ // Raw regex: /pattern/
91
+ let raw = String(term.dropFirst().hasSuffix("/") ? term.dropFirst().dropLast() : term.dropFirst())
92
+ return Self.regexMatcher(raw)
93
+ } else if term.contains("*") || term.contains("?") {
94
+ return Self.globMatcher(term)
95
+ } else {
96
+ return { key in key.contains(term) }
97
+ }
98
+ }
99
+
100
+ return windows
101
+ .filter { win in
102
+ let key = win.searchKey(
103
+ spatialNumber: spatialNumber(for: win.displayIndex),
104
+ layerName: layerNames[win.layer]
105
+ )
106
+ return matchers.allSatisfy { $0(key) }
107
+ }
108
+ .sorted { $0.zIndex < $1.zIndex }
109
+ }
110
+
111
+ /// Convert a glob pattern (with * and ?) into a substring matcher closure.
112
+ /// `*` matches any sequence of characters, `?` matches exactly one character.
113
+ /// Pattern is matched as a substring unless anchored with `*` on both ends.
114
+ private static func globMatcher(_ pattern: String) -> (String) -> Bool {
115
+ // Convert glob to regex: escape regex-special chars, then * → .* and ? → .
116
+ var regex = ""
117
+ for ch in pattern {
118
+ switch ch {
119
+ case "*": regex += ".*"
120
+ case "?": regex += "."
121
+ case ".", "(", ")", "[", "]", "{", "}", "^", "$", "|", "+", "\\": regex += "\\\(ch)"
122
+ default: regex += String(ch)
123
+ }
124
+ }
125
+ guard let re = try? NSRegularExpression(pattern: regex, options: []) else {
126
+ return { key in key.contains(pattern) }
127
+ }
128
+ return { key in
129
+ re.firstMatch(in: key, range: NSRange(key.startIndex..., in: key)) != nil
130
+ }
131
+ }
132
+
133
+ /// Raw regex matcher — term is an unescaped regex pattern.
134
+ /// Case-insensitive. Falls back to literal contains on invalid regex.
135
+ private static func regexMatcher(_ pattern: String) -> (String) -> Bool {
136
+ guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
137
+ return { key in key.contains(pattern) }
138
+ }
139
+ return { key in
140
+ re.firstMatch(in: key, range: NSRange(key.startIndex..., in: key)) != nil
141
+ }
142
+ }
143
+
144
+ var searchTerms: [String] {
145
+ windowSearchQuery.lowercased()
146
+ .split(separator: " ")
147
+ .map(String.init)
148
+ .filter { !$0.isEmpty }
149
+ }
150
+
151
+ var searchHasDirectHit: Bool {
152
+ searchFilteredWindows.count == 1
153
+ }
154
+
155
+ var searchResultsByDisplay: [(displayIndex: Int, spatialNumber: Int, label: String, windows: [ScreenMapWindowEntry])] {
156
+ let filtered = searchFilteredWindows
157
+ guard !filtered.isEmpty else { return [] }
158
+ let grouped = Dictionary(grouping: filtered) { $0.displayIndex }
159
+ return grouped.keys.sorted { spatialNumber(for: $0) < spatialNumber(for: $1) }
160
+ .map { idx in
161
+ let label = displays.first(where: { $0.index == idx })?.label ?? "Display \(idx)"
162
+ let wins = grouped[idx]!.sorted { $0.zIndex < $1.zIndex }
163
+ return (idx, spatialNumber(for: idx), label, wins)
164
+ }
165
+ }
166
+
167
+ /// Workspace layer names from workspace.json (layer index → label)
168
+ var layerNames: [Int: String] = [:]
169
+
170
+ static let minZoom: CGFloat = 0.3
171
+ static let maxZoom: CGFloat = 5.0
172
+
173
+ var effectiveScale: CGFloat { scale * zoomLevel }
174
+
175
+ func resetZoomPan() {
176
+ zoomLevel = 1.0
177
+ panOffset = .zero
178
+ }
179
+
180
+ let actionLog = ScreenMapActionLog()
181
+
182
+ /// Backward-compat: single active layer when exactly one is selected
183
+ var activeLayer: Int? {
184
+ selectedLayers.count == 1 ? selectedLayers.first : nil
185
+ }
186
+
187
+ func isLayerSelected(_ layer: Int) -> Bool {
188
+ selectedLayers.isEmpty || selectedLayers.contains(layer)
189
+ }
190
+
191
+ var isShowingAll: Bool { selectedLayers.isEmpty }
192
+ var dragStartFrame: CGRect? = nil
193
+
194
+ // Cached geometry for coordinate conversion (set by the view)
195
+ var fitScale: CGFloat = 1 // base fit-all scale (before zoom)
196
+ var scale: CGFloat = 1 // effective scale (fitScale * zoomLevel)
197
+ var mapOrigin: CGPoint = .zero
198
+ var screenSize: CGSize = .zero
199
+ var bboxOrigin: CGPoint = .zero // top-left of the bounding box in CG coords
200
+
201
+ let displays: [DisplayGeometry]
202
+
203
+ init(windows: [ScreenMapWindowEntry], displays: [DisplayGeometry] = []) {
204
+ self.windows = windows
205
+ self.displays = displays
206
+ }
207
+
208
+ /// Number of distinct layers (global, all displays)
209
+ var layerCount: Int {
210
+ (windows.map(\.layer).max() ?? 0) + 1
211
+ }
212
+
213
+ /// Window count for a specific layer (for sidebar badges)
214
+ func windowCount(for layer: Int) -> Int {
215
+ windows.filter { $0.layer == layer }.count
216
+ }
217
+
218
+ // MARK: - Per-Display Layer Scoping
219
+
220
+ /// Sorted unique layers present on a given display
221
+ func layersForDisplay(_ displayIndex: Int) -> [Int] {
222
+ let displayWindows = windows.filter { $0.displayIndex == displayIndex }
223
+ return Array(Set(displayWindows.map(\.layer))).sorted()
224
+ }
225
+
226
+ /// Layers scoped to the focused display, or all layers when showing all displays
227
+ var effectiveLayers: [Int] {
228
+ guard let dIdx = focusedDisplayIndex else {
229
+ return Array(0..<layerCount)
230
+ }
231
+ return layersForDisplay(dIdx)
232
+ }
233
+
234
+ /// Count of layers on the focused display (or global count)
235
+ var effectiveLayerCount: Int {
236
+ effectiveLayers.count
237
+ }
238
+
239
+ /// Window count for a layer, scoped to the focused display
240
+ func effectiveWindowCount(for layer: Int) -> Int {
241
+ guard let dIdx = focusedDisplayIndex else {
242
+ return windowCount(for: layer)
243
+ }
244
+ return windows.filter { $0.layer == layer && $0.displayIndex == dIdx }.count
245
+ }
246
+
247
+ /// Visible window count per display index
248
+ func visibleWindowCount(for displayIndex: Int) -> Int {
249
+ visibleWindows.filter { $0.displayIndex == displayIndex }.count
250
+ }
251
+
252
+ /// Display name for a layer (from workspace config or fallback)
253
+ func layerDisplayName(for layer: Int) -> String {
254
+ if let name = layerNames[layer] {
255
+ return String(name.prefix(8))
256
+ }
257
+ return "L\(layer)"
258
+ }
259
+
260
+ /// Windows visible for the active layer filter
261
+ var visibleWindows: [ScreenMapWindowEntry] {
262
+ guard !selectedLayers.isEmpty else { return windows }
263
+ return windows.filter { selectedLayers.contains($0.layer) }
264
+ }
265
+
266
+ /// The focused display geometry (nil when showing all)
267
+ var focusedDisplay: DisplayGeometry? {
268
+ guard let idx = focusedDisplayIndex else { return nil }
269
+ return displays.first(where: { $0.index == idx })
270
+ }
271
+
272
+ /// Windows filtered by both layer AND focused display
273
+ var focusedVisibleWindows: [ScreenMapWindowEntry] {
274
+ let layerFiltered = visibleWindows
275
+ guard let dIdx = focusedDisplayIndex else { return layerFiltered }
276
+ return layerFiltered.filter { $0.displayIndex == dIdx }
277
+ }
278
+
279
+ /// Displays sorted by physical position (left-to-right, then top-to-bottom)
280
+ var spatialDisplayOrder: [DisplayGeometry] {
281
+ displays.sorted { a, b in
282
+ if abs(a.cgRect.origin.x - b.cgRect.origin.x) > 10 {
283
+ return a.cgRect.origin.x < b.cgRect.origin.x
284
+ }
285
+ return a.cgRect.origin.y < b.cgRect.origin.y
286
+ }
287
+ }
288
+
289
+ /// 1-based spatial position for a display (left-to-right numbering)
290
+ func spatialNumber(for displayIndex: Int) -> Int {
291
+ let order = spatialDisplayOrder
292
+ if let pos = order.firstIndex(where: { $0.index == displayIndex }) {
293
+ return pos + 1
294
+ }
295
+ return displayIndex + 1
296
+ }
297
+
298
+ /// Set focus to a specific display (nil = all-displays view)
299
+ func focusDisplay(_ index: Int?) {
300
+ focusedDisplayIndex = index
301
+ selectedLayers = [] // reset to "All" for the new display scope
302
+ resetZoomPan()
303
+ }
304
+
305
+ /// Cycle to the next display in spatial (left-to-right) order
306
+ func cycleNextDisplay() {
307
+ let order = spatialDisplayOrder
308
+ guard order.count > 1 else { return }
309
+ guard let current = focusedDisplayIndex else {
310
+ focusedDisplayIndex = order.first!.index
311
+ selectedLayers = []
312
+ resetZoomPan()
313
+ return
314
+ }
315
+ if let pos = order.firstIndex(where: { $0.index == current }) {
316
+ let next = pos + 1
317
+ if next >= order.count {
318
+ focusedDisplayIndex = nil // all-displays view
319
+ } else {
320
+ focusedDisplayIndex = order[next].index
321
+ }
322
+ } else {
323
+ focusedDisplayIndex = nil
324
+ }
325
+ selectedLayers = []
326
+ resetZoomPan()
327
+ }
328
+
329
+ /// Cycle to the previous display in spatial (right-to-left) order
330
+ func cyclePreviousDisplay() {
331
+ let order = spatialDisplayOrder
332
+ guard order.count > 1 else { return }
333
+ guard let current = focusedDisplayIndex else {
334
+ focusedDisplayIndex = order.last!.index
335
+ selectedLayers = []
336
+ resetZoomPan()
337
+ return
338
+ }
339
+ if let pos = order.firstIndex(where: { $0.index == current }) {
340
+ if pos == 0 {
341
+ focusedDisplayIndex = nil // all-displays view
342
+ } else {
343
+ focusedDisplayIndex = order[pos - 1].index
344
+ }
345
+ } else {
346
+ focusedDisplayIndex = nil
347
+ }
348
+ selectedLayers = []
349
+ resetZoomPan()
350
+ }
351
+
352
+ /// Number of windows with pending edits (position or size)
353
+ var pendingEditCount: Int {
354
+ windows.filter(\.hasEdits).count
355
+ }
356
+
357
+ /// Cycle layer: first → … → last → empty(all) → first
358
+ func cycleLayer() {
359
+ let layers = effectiveLayers
360
+ guard !layers.isEmpty else { return }
361
+
362
+ if selectedLayers.count > 1 {
363
+ selectedLayers = [layers[0]]
364
+ return
365
+ }
366
+ guard let current = activeLayer else {
367
+ selectedLayers = [layers[0]]
368
+ return
369
+ }
370
+ guard let idx = layers.firstIndex(of: current) else {
371
+ selectedLayers = [layers[0]]
372
+ return
373
+ }
374
+ let nextIdx = idx + 1
375
+ if nextIdx >= layers.count {
376
+ selectedLayers = [] // all
377
+ } else {
378
+ selectedLayers = [layers[nextIdx]]
379
+ }
380
+ }
381
+
382
+ /// Cycle layer backward
383
+ func cyclePreviousLayer() {
384
+ let layers = effectiveLayers
385
+ guard !layers.isEmpty else { return }
386
+
387
+ if selectedLayers.count > 1 {
388
+ selectedLayers = [layers.last!]
389
+ return
390
+ }
391
+ guard let current = activeLayer else {
392
+ selectedLayers = [layers.last!]
393
+ return
394
+ }
395
+ guard let idx = layers.firstIndex(of: current) else {
396
+ selectedLayers = [layers.last!]
397
+ return
398
+ }
399
+ if idx == 0 {
400
+ selectedLayers = [] // all
401
+ } else {
402
+ selectedLayers = [layers[idx - 1]]
403
+ }
404
+ }
405
+
406
+ /// Direct layer selection (from sidebar clicks)
407
+ func selectLayer(_ layer: Int?) {
408
+ DiagnosticLog.shared.info("[ScreenMap] selectLayer: \(layer.map { "\($0)" } ?? "all")")
409
+ if let layer {
410
+ selectedLayers = [layer]
411
+ } else {
412
+ selectedLayers = []
413
+ }
414
+ }
415
+
416
+ /// Toggle a layer in multi-select (Cmd+click)
417
+ func toggleLayerSelection(_ layer: Int) {
418
+ if selectedLayers.contains(layer) {
419
+ selectedLayers.remove(layer)
420
+ } else {
421
+ selectedLayers.insert(layer)
422
+ }
423
+ if selectedLayers.count >= effectiveLayerCount {
424
+ selectedLayers = []
425
+ }
426
+ DiagnosticLog.shared.info("[ScreenMap] toggleLayer \(layer) → \(selectedLayers.sorted())")
427
+ }
428
+
429
+ /// Move a window to a different layer
430
+ func reassignLayer(windowId: UInt32, toLayer: Int, fitToAvailable: Bool) {
431
+ guard let idx = windows.firstIndex(where: { $0.id == windowId }) else { return }
432
+ let oldFrame = windows[idx].editedFrame
433
+ windows[idx].layer = toLayer
434
+ if fitToAvailable {
435
+ fitWindowIntoLayer(at: idx)
436
+ }
437
+ let newFrame = windows[idx].editedFrame
438
+ if oldFrame != newFrame {
439
+ DiagnosticLog.shared.info("[ScreenMap] reassign wid=\(windowId): fitted \(Int(oldFrame.origin.x)),\(Int(oldFrame.origin.y)) → \(Int(newFrame.origin.x)),\(Int(newFrame.origin.y))")
440
+ }
441
+ }
442
+
443
+ /// Auto-resize a window to fit among siblings in its layer
444
+ func fitWindowIntoLayer(at idx: Int) {
445
+ let win = windows[idx]
446
+ let siblings = windows.enumerated().filter { $0.offset != idx && $0.element.layer == win.layer }
447
+ let siblingFrames = siblings.map(\.element.editedFrame)
448
+ let screenRect = CGRect(origin: .zero, size: screenSize)
449
+ if let fitted = fitRect(win.editedFrame, avoiding: siblingFrames, within: screenRect) {
450
+ windows[idx].editedFrame = fitted
451
+ }
452
+ }
453
+
454
+ /// Try to fit a rect avoiding collisions
455
+ func fitRect(_ rect: CGRect, avoiding others: [CGRect], within bounds: CGRect) -> CGRect? {
456
+ let collisions = others.filter { $0.intersects(rect) }
457
+ if collisions.isEmpty { return nil }
458
+
459
+ let minW: CGFloat = 100, minH: CGFloat = 50
460
+ var candidates: [CGRect] = []
461
+
462
+ for blocker in collisions {
463
+ let rightClip = CGRect(x: rect.minX, y: rect.minY,
464
+ width: blocker.minX - rect.minX, height: rect.height)
465
+ if rightClip.width >= minW && rightClip.height >= minH { candidates.append(rightClip) }
466
+
467
+ let bottomClip = CGRect(x: rect.minX, y: rect.minY,
468
+ width: rect.width, height: blocker.minY - rect.minY)
469
+ if bottomClip.width >= minW && bottomClip.height >= minH { candidates.append(bottomClip) }
470
+
471
+ let pushRight = CGRect(x: blocker.maxX, y: rect.minY,
472
+ width: rect.width, height: rect.height)
473
+ if pushRight.maxX <= bounds.maxX { candidates.append(pushRight) }
474
+
475
+ let pushDown = CGRect(x: rect.minX, y: blocker.maxY,
476
+ width: rect.width, height: rect.height)
477
+ if pushDown.maxY <= bounds.maxY { candidates.append(pushDown) }
478
+ }
479
+
480
+ let valid = candidates.filter { cand in
481
+ cand.width >= minW && cand.height >= minH &&
482
+ bounds.contains(cand) &&
483
+ !others.contains(where: { $0.intersects(cand) })
484
+ }
485
+ return valid.max(by: { $0.width * $0.height < $1.width * $1.height })
486
+ }
487
+
488
+ /// Auto-tile the active layer's windows into a grid
489
+ func autoTileLayer() -> Int {
490
+ guard let layer = activeLayer else { return 0 }
491
+
492
+ let screens = NSScreen.screens
493
+ let primaryHeight = screens.first?.frame.height ?? 0
494
+ var totalTiled = 0
495
+ let diag = DiagnosticLog.shared
496
+
497
+ diag.info("[Tile] autoTileLayer layer=\(layer) screens=\(screens.count)")
498
+ var displayIndices = Set(windows.filter { $0.layer == layer }.map(\.displayIndex))
499
+ if let focused = focusedDisplayIndex { displayIndices = displayIndices.intersection([focused]) }
500
+ for dIdx in displayIndices.sorted() {
501
+ var indices = windows.indices.filter { windows[$0].layer == layer && windows[$0].displayIndex == dIdx }
502
+ guard indices.count >= 1 else { continue }
503
+ indices.sort { windows[$0].zIndex < windows[$1].zIndex }
504
+
505
+ let screen = dIdx < screens.count ? screens[dIdx] : screens.first!
506
+ let visible = screen.visibleFrame
507
+ let axTop = primaryHeight - visible.maxY
508
+
509
+ if indices.count == 1 {
510
+ let frame = CGRect(x: visible.origin.x, y: axTop, width: visible.width, height: visible.height)
511
+ windows[indices[0]].editedFrame = frame
512
+ totalTiled += 1
513
+ continue
514
+ }
515
+
516
+ let shape = WindowTiler.gridShape(for: indices.count)
517
+ let rowCount = shape.count
518
+ let totalW = Int(visible.width)
519
+ let totalH = Int(visible.height)
520
+ let baseX = Int(visible.origin.x)
521
+ let baseY = Int(axTop)
522
+
523
+ var slotIdx = 0
524
+ for (row, cols) in shape.enumerated() {
525
+ let y0 = baseY + (row * totalH) / rowCount
526
+ let y1 = baseY + ((row + 1) * totalH) / rowCount
527
+ for col in 0..<cols {
528
+ guard slotIdx < indices.count else { break }
529
+ let x0 = baseX + (col * totalW) / cols
530
+ let x1 = baseX + ((col + 1) * totalW) / cols
531
+ let frame = CGRect(x: CGFloat(x0), y: CGFloat(y0), width: CGFloat(x1 - x0), height: CGFloat(y1 - y0))
532
+ windows[indices[slotIdx]].editedFrame = frame
533
+ slotIdx += 1
534
+ }
535
+ }
536
+ totalTiled += indices.count
537
+ }
538
+ return totalTiled
539
+ }
540
+
541
+ /// Expose the active layer's windows with gaps
542
+ func exposeLayer() -> Int {
543
+ guard let layer = activeLayer else { return 0 }
544
+
545
+ let screens = NSScreen.screens
546
+ let primaryHeight = screens.first?.frame.height ?? 0
547
+ var totalExposed = 0
548
+
549
+ var displayIndices = Set(windows.filter { $0.layer == layer }.map(\.displayIndex))
550
+ if let focused = focusedDisplayIndex { displayIndices = displayIndices.intersection([focused]) }
551
+ for dIdx in displayIndices {
552
+ var indices = windows.indices.filter { windows[$0].layer == layer && windows[$0].displayIndex == dIdx }
553
+ guard indices.count >= 2 else { totalExposed += indices.count; continue }
554
+ indices.sort { windows[$0].zIndex < windows[$1].zIndex }
555
+
556
+ let screen = dIdx < screens.count ? screens[dIdx] : screens.first!
557
+ let visible = screen.visibleFrame
558
+ let axTop = primaryHeight - visible.maxY
559
+ let padding: CGFloat = 20
560
+ let shape = WindowTiler.gridShape(for: indices.count)
561
+ let rowCount = shape.count
562
+ let rowH = visible.height / CGFloat(rowCount)
563
+
564
+ var slotIdx = 0
565
+ for (row, cols) in shape.enumerated() {
566
+ let colW = visible.width / CGFloat(cols)
567
+ let axY = axTop + CGFloat(row) * rowH
568
+ for col in 0..<cols {
569
+ guard slotIdx < indices.count else { break }
570
+ let idx = indices[slotIdx]
571
+ let orig = windows[idx].originalFrame
572
+
573
+ let cellX = visible.origin.x + CGFloat(col) * colW + padding
574
+ let cellY = axY + padding
575
+ let cellW = colW - padding * 2
576
+ let cellH = rowH - padding * 2
577
+
578
+ let aspect = orig.width / max(orig.height, 1)
579
+ var fitW = cellW
580
+ var fitH = fitW / aspect
581
+ if fitH > cellH {
582
+ fitH = cellH
583
+ fitW = fitH * aspect
584
+ }
585
+
586
+ let x = cellX + (cellW - fitW) / 2
587
+ let y = cellY + (cellH - fitH) / 2
588
+
589
+ windows[idx].editedFrame = CGRect(x: x, y: y, width: fitW, height: fitH)
590
+ slotIdx += 1
591
+ }
592
+ }
593
+ totalExposed += indices.count
594
+ }
595
+ return totalExposed
596
+ }
597
+
598
+ /// Push overlapping windows apart with minimal movement
599
+ func smartSpreadLayer() -> Int {
600
+ guard let layer = activeLayer else { return 0 }
601
+
602
+ let screens = NSScreen.screens
603
+ let primaryHeight = screens.first?.frame.height ?? 0
604
+ var totalAffected = 0
605
+
606
+ var displayIndices = Set(windows.filter { $0.layer == layer }.map(\.displayIndex))
607
+ if let focused = focusedDisplayIndex { displayIndices = displayIndices.intersection([focused]) }
608
+ for dIdx in displayIndices {
609
+ let indices = windows.indices.filter { windows[$0].layer == layer && windows[$0].displayIndex == dIdx }
610
+ guard indices.count >= 2 else { continue }
611
+
612
+ let screen = dIdx < screens.count ? screens[dIdx] : screens.first!
613
+ let axTop = primaryHeight - screen.frame.maxY
614
+ let screenRect = CGRect(x: screen.frame.origin.x, y: axTop,
615
+ width: screen.frame.width, height: screen.frame.height)
616
+ var affected: Set<Int> = []
617
+
618
+ for _ in 0..<15 {
619
+ var hadOverlap = false
620
+ for i in 0..<indices.count {
621
+ for j in (i + 1)..<indices.count {
622
+ let idxA = indices[i]
623
+ let idxB = indices[j]
624
+ let a = windows[idxA].editedFrame
625
+ let b = windows[idxB].editedFrame
626
+ guard a.intersects(b) else { continue }
627
+ hadOverlap = true
628
+
629
+ let overlapW = min(a.maxX, b.maxX) - max(a.minX, b.minX)
630
+ let overlapH = min(a.maxY, b.maxY) - max(a.minY, b.minY)
631
+
632
+ if overlapW < overlapH {
633
+ let push = (overlapW / 2).rounded(.up) + 1
634
+ if a.midX <= b.midX {
635
+ windows[idxA].editedFrame.origin.x -= push
636
+ windows[idxB].editedFrame.origin.x += push
637
+ } else {
638
+ windows[idxA].editedFrame.origin.x += push
639
+ windows[idxB].editedFrame.origin.x -= push
640
+ }
641
+ } else {
642
+ let push = (overlapH / 2).rounded(.up) + 1
643
+ if a.midY <= b.midY {
644
+ windows[idxA].editedFrame.origin.y -= push
645
+ windows[idxB].editedFrame.origin.y += push
646
+ } else {
647
+ windows[idxA].editedFrame.origin.y += push
648
+ windows[idxB].editedFrame.origin.y -= push
649
+ }
650
+ }
651
+ affected.insert(idxA)
652
+ affected.insert(idxB)
653
+ }
654
+ }
655
+ for idx in indices { clampToScreen(at: idx, bounds: screenRect) }
656
+ if !hadOverlap { break }
657
+ }
658
+ totalAffected += affected.count
659
+ }
660
+ return totalAffected
661
+ }
662
+
663
+ private func clampToScreen(at idx: Int, bounds: CGRect) {
664
+ var f = windows[idx].editedFrame
665
+ if f.minX < bounds.minX { f.origin.x = bounds.minX }
666
+ if f.minY < bounds.minY { f.origin.y = bounds.minY }
667
+ if f.maxX > bounds.maxX { f.origin.x = bounds.maxX - f.width }
668
+ if f.maxY > bounds.maxY { f.origin.y = bounds.maxY - f.height }
669
+ windows[idx].editedFrame = f
670
+ }
671
+
672
+ /// Grow each window outward until it hits a neighbor or screen edge
673
+ func fitAvailableSpace() -> Int {
674
+ guard let layer = activeLayer else { return 0 }
675
+
676
+ let screens = NSScreen.screens
677
+ let primaryHeight = screens.first?.frame.height ?? 0
678
+ var totalAffected = 0
679
+
680
+ var displayIndices = Set(windows.filter { $0.layer == layer }.map(\.displayIndex))
681
+ if let focused = focusedDisplayIndex { displayIndices = displayIndices.intersection([focused]) }
682
+
683
+ for dIdx in displayIndices {
684
+ var indices = windows.indices.filter { windows[$0].layer == layer && windows[$0].displayIndex == dIdx }
685
+ guard !indices.isEmpty else { continue }
686
+ indices.sort { windows[$0].zIndex < windows[$1].zIndex }
687
+
688
+ let screen = dIdx < screens.count ? screens[dIdx] : screens.first!
689
+ let axTop = primaryHeight - screen.frame.maxY
690
+ let bounds = CGRect(x: screen.frame.origin.x, y: axTop,
691
+ width: screen.frame.width, height: screen.frame.height)
692
+
693
+ // Snapshot original positions for neighbor detection
694
+ let origFrames = indices.map { windows[$0].editedFrame }
695
+
696
+ for (i, idx) in indices.enumerated() {
697
+ let me = origFrames[i]
698
+
699
+ // Find nearest obstacle in each direction (only neighbors that overlap on the perpendicular axis)
700
+ var left = bounds.minX
701
+ var right = bounds.maxX
702
+ var top = bounds.minY
703
+ var bottom = bounds.maxY
704
+
705
+ for (j, otherFrame) in origFrames.enumerated() where j != i {
706
+ // Left: other window whose right edge is to my left, overlapping vertically
707
+ if otherFrame.maxX <= me.minX + 1 &&
708
+ otherFrame.maxY > me.minY && otherFrame.minY < me.maxY {
709
+ left = max(left, otherFrame.maxX)
710
+ }
711
+ // Right: other window whose left edge is to my right, overlapping vertically
712
+ if otherFrame.minX >= me.maxX - 1 &&
713
+ otherFrame.maxY > me.minY && otherFrame.minY < me.maxY {
714
+ right = min(right, otherFrame.minX)
715
+ }
716
+ // Top: other window whose bottom edge is above me, overlapping horizontally
717
+ if otherFrame.maxY <= me.minY + 1 &&
718
+ otherFrame.maxX > me.minX && otherFrame.minX < me.maxX {
719
+ top = max(top, otherFrame.maxY)
720
+ }
721
+ // Bottom: other window whose top edge is below me, overlapping horizontally
722
+ if otherFrame.minY >= me.maxY - 1 &&
723
+ otherFrame.maxX > me.minX && otherFrame.minX < me.maxX {
724
+ bottom = min(bottom, otherFrame.minY)
725
+ }
726
+ }
727
+
728
+ let newFrame = CGRect(x: left, y: top, width: right - left, height: bottom - top)
729
+ if newFrame != windows[idx].editedFrame {
730
+ windows[idx].editedFrame = newFrame
731
+ totalAffected += 1
732
+ }
733
+ }
734
+ }
735
+ return totalAffected
736
+ }
737
+
738
+ /// Distribute visible windows into a grid (staged — edits frames only)
739
+ func distributeLayer() -> Int {
740
+ let screens = NSScreen.screens
741
+ guard !screens.isEmpty else { return 0 }
742
+ var totalDistributed = 0
743
+
744
+ // Group by display
745
+ var displayIndices: Set<Int>
746
+ if let focused = focusedDisplayIndex {
747
+ displayIndices = [focused]
748
+ } else {
749
+ displayIndices = Set(focusedVisibleWindows.map(\.displayIndex))
750
+ if displayIndices.isEmpty {
751
+ displayIndices = Set(visibleWindows.map(\.displayIndex))
752
+ }
753
+ }
754
+
755
+ for dIdx in displayIndices.sorted() {
756
+ // Get windows to distribute on this display
757
+ var indices = windows.indices.filter { idx in
758
+ let win = windows[idx]
759
+ let layerMatch = selectedLayers.isEmpty || selectedLayers.contains(win.layer)
760
+ return win.displayIndex == dIdx && layerMatch
761
+ }
762
+ guard !indices.isEmpty else { continue }
763
+ indices.sort { windows[$0].zIndex < windows[$1].zIndex }
764
+
765
+ let screen = dIdx < screens.count ? screens[dIdx] : screens.first!
766
+ let slots = WindowTiler.computeGridSlots(count: indices.count, screen: screen)
767
+ guard slots.count == indices.count else { continue }
768
+
769
+ for (i, idx) in indices.enumerated() {
770
+ windows[idx].editedFrame = slots[i]
771
+ }
772
+ totalDistributed += indices.count
773
+ }
774
+ return totalDistributed
775
+ }
776
+
777
+ /// Reset all edited frames back to original
778
+ func discardEdits() {
779
+ for i in windows.indices {
780
+ windows[i].editedFrame = windows[i].originalFrame
781
+ }
782
+ }
783
+
784
+ /// Remap sparse layer numbers to contiguous
785
+ func renumberLayersContiguous() {
786
+ let usedLayers = Set(windows.map(\.layer)).sorted()
787
+ guard usedLayers != Array(0..<usedLayers.count) else { return }
788
+ let mapping = Dictionary(uniqueKeysWithValues: usedLayers.enumerated().map { ($1, $0) })
789
+ for i in windows.indices {
790
+ windows[i].layer = mapping[windows[i].layer] ?? windows[i].layer
791
+ }
792
+ selectedLayers = Set(selectedLayers.compactMap { mapping[$0] })
793
+ }
794
+
795
+ /// Consolidate windows into fewer layers
796
+ func consolidateLayers() -> (old: Int, new: Int) {
797
+ let oldCount = effectiveLayerCount
798
+ let scopedLayers = effectiveLayers
799
+ guard let maxLayer = scopedLayers.last, maxLayer >= 1 else { return (oldCount, oldCount) }
800
+
801
+ let screenRect = CGRect(origin: .zero, size: screenSize)
802
+ let dIdx = focusedDisplayIndex
803
+
804
+ for sourceLayer in stride(from: maxLayer, through: 1, by: -1) {
805
+ guard scopedLayers.contains(sourceLayer) else { continue }
806
+ let windowIndices = windows.indices.filter {
807
+ windows[$0].layer == sourceLayer &&
808
+ (dIdx == nil || windows[$0].displayIndex == dIdx!)
809
+ }
810
+ for idx in windowIndices {
811
+ let win = windows[idx]
812
+ for targetLayer in scopedLayers where targetLayer < sourceLayer {
813
+ let siblings = windows.enumerated().filter {
814
+ $0.offset != idx && $0.element.layer == targetLayer &&
815
+ (dIdx == nil || $0.element.displayIndex == dIdx!)
816
+ }.map(\.element.editedFrame)
817
+
818
+ let collisions = siblings.filter { $0.intersects(win.editedFrame) }
819
+ if collisions.isEmpty {
820
+ windows[idx].layer = targetLayer
821
+ break
822
+ }
823
+ if let fitted = fitRect(win.editedFrame, avoiding: siblings, within: screenRect) {
824
+ windows[idx].editedFrame = fitted
825
+ windows[idx].layer = targetLayer
826
+ break
827
+ }
828
+ }
829
+ }
830
+ }
831
+
832
+ renumberLayersContiguous()
833
+ let newLayers = effectiveLayers
834
+ selectedLayers = newLayers.isEmpty ? [] : [newLayers[0]]
835
+ return (old: oldCount, new: effectiveLayerCount)
836
+ }
837
+
838
+ var layerLabel: String {
839
+ if selectedLayers.isEmpty { return "ALL" }
840
+ if selectedLayers.count == 1 { return "LAYER \(selectedLayers.first!)" }
841
+ return selectedLayers.sorted().map { "L\($0)" }.joined(separator: "+")
842
+ }
843
+
844
+ /// Merge all windows from selected layers into the lowest one
845
+ func flattenSelectedLayers() -> (count: Int, target: Int)? {
846
+ guard selectedLayers.count >= 2 else { return nil }
847
+ let sorted = selectedLayers.sorted()
848
+ let target = sorted[0]
849
+ let higherLayers = Set(sorted.dropFirst())
850
+ let dIdx = focusedDisplayIndex
851
+
852
+ var moveCount = 0
853
+ for idx in windows.indices where higherLayers.contains(windows[idx].layer) {
854
+ if let dIdx, windows[idx].displayIndex != dIdx { continue }
855
+ windows[idx].layer = target
856
+ fitWindowIntoLayer(at: idx)
857
+ moveCount += 1
858
+ }
859
+
860
+ renumberLayersContiguous()
861
+ selectedLayers = target < layerCount ? [target] : []
862
+ return (count: moveCount, target: target)
863
+ }
864
+ }
865
+
866
+ // MARK: - Screen Map Action Log
867
+
868
+ final class ScreenMapActionLog {
869
+ struct WindowSnapshot: Codable {
870
+ let wid: UInt32
871
+ let app: String
872
+ let title: String
873
+ let frame: FrameSnapshot
874
+ let layer: Int
875
+
876
+ struct FrameSnapshot: Codable {
877
+ let x: Int
878
+ let y: Int
879
+ let w: Int
880
+ let h: Int
881
+ }
882
+ }
883
+
884
+ struct MovedWindow: Codable {
885
+ let wid: UInt32
886
+ let app: String
887
+ let title: String
888
+ let fromFrame: WindowSnapshot.FrameSnapshot
889
+ let toFrame: WindowSnapshot.FrameSnapshot
890
+ let fromLayer: Int
891
+ let toLayer: Int
892
+ }
893
+
894
+ struct Entry: Codable {
895
+ let ref: String
896
+ let action: String
897
+ let timestamp: String
898
+ let summary: String
899
+ let before: [WindowSnapshot]
900
+ let after: [WindowSnapshot]
901
+ let moved: [MovedWindow]
902
+ }
903
+
904
+ private(set) var lastEntry: Entry? = nil
905
+
906
+ private static var logFileURL: URL = {
907
+ let dir = FileManager.default.homeDirectoryForCurrentUser
908
+ .appendingPathComponent(".lattices", isDirectory: true)
909
+ .appendingPathComponent("logs", isDirectory: true)
910
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
911
+ return dir.appendingPathComponent("actions.jsonl")
912
+ }()
913
+
914
+ private static func shortUUID() -> String {
915
+ let uuid = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
916
+ return String(uuid.suffix(8))
917
+ }
918
+
919
+ func snapshot(_ windows: [ScreenMapWindowEntry]) -> [WindowSnapshot] {
920
+ windows.map { win in
921
+ WindowSnapshot(
922
+ wid: win.id, app: win.app, title: win.title,
923
+ frame: .init(
924
+ x: Int(win.editedFrame.origin.x), y: Int(win.editedFrame.origin.y),
925
+ w: Int(win.editedFrame.width), h: Int(win.editedFrame.height)
926
+ ),
927
+ layer: win.layer
928
+ )
929
+ }
930
+ }
931
+
932
+ func record(action: String, summary: String,
933
+ before: [WindowSnapshot], after: [WindowSnapshot]) -> Entry {
934
+ let ref = Self.shortUUID()
935
+
936
+ var afterByWid: [UInt32: WindowSnapshot] = [:]
937
+ for snap in after { afterByWid[snap.wid] = snap }
938
+
939
+ var moved: [MovedWindow] = []
940
+ for b in before {
941
+ guard let a = afterByWid[b.wid] else { continue }
942
+ let frameChanged = b.frame.x != a.frame.x || b.frame.y != a.frame.y
943
+ || b.frame.w != a.frame.w || b.frame.h != a.frame.h
944
+ let layerChanged = b.layer != a.layer
945
+ if frameChanged || layerChanged {
946
+ moved.append(MovedWindow(
947
+ wid: b.wid, app: b.app, title: b.title,
948
+ fromFrame: b.frame, toFrame: a.frame,
949
+ fromLayer: b.layer, toLayer: a.layer
950
+ ))
951
+ }
952
+ }
953
+
954
+ let iso = ISO8601DateFormatter()
955
+ iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
956
+
957
+ let entry = Entry(
958
+ ref: ref, action: action,
959
+ timestamp: iso.string(from: Date()),
960
+ summary: summary,
961
+ before: before, after: after, moved: moved
962
+ )
963
+
964
+ let compactEncoder = JSONEncoder()
965
+ compactEncoder.outputFormatting = [.sortedKeys]
966
+ if let data = try? compactEncoder.encode(entry),
967
+ var line = String(data: data, encoding: .utf8) {
968
+ line += "\n"
969
+ if let lineData = line.data(using: .utf8) {
970
+ if FileManager.default.fileExists(atPath: Self.logFileURL.path) {
971
+ if let fh = try? FileHandle(forWritingTo: Self.logFileURL) {
972
+ fh.seekToEndOfFile()
973
+ fh.write(lineData)
974
+ fh.closeFile()
975
+ }
976
+ } else {
977
+ try? lineData.write(to: Self.logFileURL)
978
+ }
979
+ }
980
+ }
981
+
982
+ DiagnosticLog.shared.info("[ScreenMapAction] \(ref) \(action): \(summary) (\(moved.count) moved)")
983
+ lastEntry = entry
984
+ return entry
985
+ }
986
+
987
+ func lastEntryJSON() -> String? {
988
+ guard let entry = lastEntry else { return nil }
989
+ let encoder = JSONEncoder()
990
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
991
+ guard let data = try? encoder.encode(entry) else { return nil }
992
+ return String(data: data, encoding: .utf8)
993
+ }
994
+
995
+ func verify() {
996
+ guard let last = lastEntry else { return }
997
+ let intendedByWid: [UInt32: WindowSnapshot] = Dictionary(
998
+ last.after.map { ($0.wid, $0) }, uniquingKeysWith: { _, b in b }
999
+ )
1000
+ guard !intendedByWid.isEmpty else { return }
1001
+
1002
+ guard let rawList = CGWindowListCopyWindowInfo(
1003
+ [.optionAll, .excludeDesktopElements], kCGNullWindowID
1004
+ ) as? [[String: Any]] else { return }
1005
+
1006
+ var actual: [WindowSnapshot] = []
1007
+ var drifted: [MovedWindow] = []
1008
+
1009
+ for info in rawList {
1010
+ guard let wid = info[kCGWindowNumber as String] as? UInt32,
1011
+ let intended = intendedByWid[wid],
1012
+ let bounds = info[kCGWindowBounds as String] as? [String: Any],
1013
+ let cgX = bounds["X"] as? CGFloat,
1014
+ let cgY = bounds["Y"] as? CGFloat,
1015
+ let cgW = bounds["Width"] as? CGFloat,
1016
+ let cgH = bounds["Height"] as? CGFloat else { continue }
1017
+
1018
+ let snap = WindowSnapshot(
1019
+ wid: wid, app: intended.app, title: intended.title,
1020
+ frame: .init(x: Int(cgX), y: Int(cgY), w: Int(cgW), h: Int(cgH)),
1021
+ layer: intended.layer
1022
+ )
1023
+ actual.append(snap)
1024
+
1025
+ let i = intended.frame
1026
+ let a = snap.frame
1027
+ if i.x != a.x || i.y != a.y || i.w != a.w || i.h != a.h {
1028
+ drifted.append(MovedWindow(
1029
+ wid: wid, app: intended.app, title: intended.title,
1030
+ fromFrame: intended.frame, toFrame: a,
1031
+ fromLayer: intended.layer, toLayer: intended.layer
1032
+ ))
1033
+ }
1034
+ }
1035
+
1036
+ let iso = ISO8601DateFormatter()
1037
+ iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
1038
+
1039
+ let summary = drifted.isEmpty
1040
+ ? "Verified \(actual.count) windows — all match"
1041
+ : "Verified \(actual.count) windows — \(drifted.count) drifted"
1042
+
1043
+ let entry = Entry(
1044
+ ref: last.ref, action: "verify",
1045
+ timestamp: iso.string(from: Date()),
1046
+ summary: summary,
1047
+ before: last.after, after: actual, moved: drifted
1048
+ )
1049
+
1050
+ let compactEncoder = JSONEncoder()
1051
+ compactEncoder.outputFormatting = [.sortedKeys]
1052
+ if let data = try? compactEncoder.encode(entry),
1053
+ var line = String(data: data, encoding: .utf8) {
1054
+ line += "\n"
1055
+ if let lineData = line.data(using: .utf8) {
1056
+ if FileManager.default.fileExists(atPath: Self.logFileURL.path) {
1057
+ if let fh = try? FileHandle(forWritingTo: Self.logFileURL) {
1058
+ fh.seekToEndOfFile()
1059
+ fh.write(lineData)
1060
+ fh.closeFile()
1061
+ }
1062
+ } else {
1063
+ try? lineData.write(to: Self.logFileURL)
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ DiagnosticLog.shared.info("[ScreenMapAction] verify \(last.ref): \(summary)")
1069
+ }
1070
+ }
1071
+
1072
+ // MARK: - Screen Map Controller
1073
+
1074
+ final class ScreenMapController: ObservableObject {
1075
+ @Published var editor: ScreenMapEditorState?
1076
+ @Published var selectedWindowIds: Set<UInt32> = []
1077
+ @Published var flashMessage: String? = nil
1078
+ @Published var previewCaptures: [UInt32: NSImage] = [:]
1079
+ @Published var savedPositions: [UInt32: (pid: Int32, frame: WindowFrame)]? = nil
1080
+ @Published var isSearchActive: Bool = false
1081
+ @Published var searchHighlightIndex: Int = 0
1082
+
1083
+ enum DisplayTransitionDirection {
1084
+ case left, right, none
1085
+ }
1086
+ @Published var displayTransition: DisplayTransitionDirection = .none
1087
+
1088
+ var previewWindow: NSWindow? = nil
1089
+ private var previewGlobalMonitor: Any? = nil
1090
+ private var previewLocalMonitor: Any? = nil
1091
+
1092
+ var onDismiss: (() -> Void)?
1093
+
1094
+ // MARK: - Selection
1095
+
1096
+ func isSelected(_ id: UInt32) -> Bool { selectedWindowIds.contains(id) }
1097
+
1098
+ func selectSingle(_ id: UInt32) {
1099
+ navigateToWindowDisplay(id)
1100
+ selectedWindowIds = [id]
1101
+ }
1102
+
1103
+ func toggleSelection(_ id: UInt32) {
1104
+ if selectedWindowIds.contains(id) {
1105
+ selectedWindowIds.remove(id)
1106
+ } else {
1107
+ selectedWindowIds.insert(id)
1108
+ }
1109
+ }
1110
+
1111
+ func clearSelection() {
1112
+ selectedWindowIds = []
1113
+ }
1114
+
1115
+ func selectNextWindow() {
1116
+ guard let ed = editor else { return }
1117
+ let wins = ed.focusedVisibleWindows.sorted(by: { $0.zIndex < $1.zIndex })
1118
+ guard !wins.isEmpty else { return }
1119
+ if selectedWindowIds.count == 1, let current = selectedWindowIds.first,
1120
+ let idx = wins.firstIndex(where: { $0.id == current }) {
1121
+ let next = wins[(idx + 1) % wins.count]
1122
+ selectedWindowIds = [next.id]
1123
+ } else {
1124
+ selectedWindowIds = [wins[0].id]
1125
+ }
1126
+ objectWillChange.send()
1127
+ }
1128
+
1129
+ func selectPreviousWindow() {
1130
+ guard let ed = editor else { return }
1131
+ let wins = ed.focusedVisibleWindows.sorted(by: { $0.zIndex < $1.zIndex })
1132
+ guard !wins.isEmpty else { return }
1133
+ if selectedWindowIds.count == 1, let current = selectedWindowIds.first,
1134
+ let idx = wins.firstIndex(where: { $0.id == current }) {
1135
+ let prev = wins[(idx - 1 + wins.count) % wins.count]
1136
+ selectedWindowIds = [prev.id]
1137
+ } else {
1138
+ selectedWindowIds = [wins[wins.count - 1].id]
1139
+ }
1140
+ objectWillChange.send()
1141
+ }
1142
+
1143
+ func selectAll() {
1144
+ guard let ed = editor else { return }
1145
+ let allIds = Set(ed.focusedVisibleWindows.map(\.id))
1146
+ selectedWindowIds = allIds
1147
+ flash("Selected \(allIds.count) windows")
1148
+ objectWillChange.send()
1149
+ }
1150
+
1151
+ // MARK: - Search
1152
+
1153
+ var searchHighlightedWindowId: UInt32? {
1154
+ guard isSearchActive, let ed = editor else { return nil }
1155
+ let results = ed.searchFilteredWindows
1156
+ guard !results.isEmpty else { return nil }
1157
+ let idx = max(0, min(searchHighlightIndex, results.count - 1))
1158
+ return results[idx].id
1159
+ }
1160
+
1161
+ func openSearch() {
1162
+ isSearchActive = true
1163
+ searchHighlightIndex = 0
1164
+ }
1165
+
1166
+ func closeSearch() {
1167
+ isSearchActive = false
1168
+ searchHighlightIndex = 0
1169
+ editor?.windowSearchQuery = ""
1170
+ }
1171
+
1172
+ func searchSelectHighlighted() {
1173
+ guard let wid = searchHighlightedWindowId else { return }
1174
+ selectSingle(wid)
1175
+ // Direct hit (single result) → close search immediately
1176
+ if editor?.searchHasDirectHit == true {
1177
+ closeSearch()
1178
+ }
1179
+ // Multiple results → stay open, just select
1180
+ }
1181
+
1182
+ /// Switch display focus to match a window's display, with directional animation
1183
+ func navigateToWindowDisplay(_ windowId: UInt32) {
1184
+ guard let ed = editor,
1185
+ let win = ed.windows.first(where: { $0.id == windowId }) else { return }
1186
+ let targetDisplay = win.displayIndex
1187
+ guard ed.focusedDisplayIndex != nil,
1188
+ ed.focusedDisplayIndex != targetDisplay else { return }
1189
+
1190
+ let fromSpatial = ed.spatialNumber(for: ed.focusedDisplayIndex!)
1191
+ let toSpatial = ed.spatialNumber(for: targetDisplay)
1192
+ displayTransition = toSpatial > fromSpatial ? .right : .left
1193
+
1194
+ ed.focusDisplay(targetDisplay)
1195
+ objectWillChange.send()
1196
+
1197
+ // Clear transition after animation completes
1198
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
1199
+ self?.displayTransition = .none
1200
+ }
1201
+ }
1202
+
1203
+ func focusWindowOnScreen(_ windowId: UInt32) {
1204
+ guard let ed = editor,
1205
+ let win = ed.windows.first(where: { $0.id == windowId }) else { return }
1206
+ if isSearchActive { closeSearch() }
1207
+ selectSingle(windowId)
1208
+ WindowTiler.raiseWindowAndReactivate(wid: win.id, pid: win.pid)
1209
+ // Show bezel after a short delay so the target window is raised first
1210
+ // and we can order the bezel behind it
1211
+ let winCopy = win
1212
+ let edCopy = ed
1213
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
1214
+ WindowBezel.shared.show(for: winCopy, editor: edCopy)
1215
+ }
1216
+ }
1217
+
1218
+ func focusSelectedWindowOnScreen() {
1219
+ if isSearchActive, let wid = searchHighlightedWindowId {
1220
+ focusWindowOnScreen(wid)
1221
+ } else if selectedWindowIds.count == 1, let wid = selectedWindowIds.first {
1222
+ focusWindowOnScreen(wid)
1223
+ }
1224
+ }
1225
+
1226
+ func searchNavigate(delta: Int) {
1227
+ guard let ed = editor else { return }
1228
+ let count = ed.searchFilteredWindows.count
1229
+ guard count > 0 else { return }
1230
+ searchHighlightIndex = (searchHighlightIndex + delta + count) % count
1231
+ objectWillChange.send()
1232
+ }
1233
+
1234
+ // MARK: - Enter
1235
+
1236
+ func enter() {
1237
+ guard let windowList = CGWindowListCopyWindowInfo(
1238
+ [.optionAll, .excludeDesktopElements], kCGNullWindowID
1239
+ ) as? [[String: Any]] else { return }
1240
+
1241
+ struct CGWin {
1242
+ let wid: UInt32; let pid: Int32; let app: String; let title: String
1243
+ let frame: CGRect; let layer: Int; let displayIndex: Int
1244
+ let isOnScreen: Bool
1245
+ }
1246
+
1247
+ let screens = NSScreen.screens
1248
+ let primaryHeight = screens.first?.frame.height ?? 0
1249
+
1250
+ func displayIndex(for frame: CGRect) -> Int {
1251
+ let centerX = frame.midX
1252
+ let centerY = frame.midY
1253
+ for (i, screen) in screens.enumerated() {
1254
+ let cgOriginY = primaryHeight - screen.frame.maxY
1255
+ let cgRect = CGRect(x: screen.frame.origin.x, y: cgOriginY,
1256
+ width: screen.frame.width, height: screen.frame.height)
1257
+ if cgRect.contains(CGPoint(x: centerX, y: centerY)) {
1258
+ return i
1259
+ }
1260
+ }
1261
+ var bestIdx = 0
1262
+ var bestDist = CGFloat.infinity
1263
+ for (i, screen) in screens.enumerated() {
1264
+ let cgOriginY = primaryHeight - screen.frame.maxY
1265
+ let cgRect = CGRect(x: screen.frame.origin.x, y: cgOriginY,
1266
+ width: screen.frame.width, height: screen.frame.height)
1267
+ let dx = centerX - cgRect.midX
1268
+ let dy = centerY - cgRect.midY
1269
+ let dist = dx * dx + dy * dy
1270
+ if dist < bestDist { bestDist = dist; bestIdx = i }
1271
+ }
1272
+ return bestIdx
1273
+ }
1274
+
1275
+ var ordered: [CGWin] = []
1276
+ for info in windowList {
1277
+ guard let wid = info[kCGWindowNumber as String] as? UInt32,
1278
+ let layer = info[kCGWindowLayer as String] as? Int,
1279
+ layer == 0,
1280
+ let boundsDict = info[kCGWindowBounds as String] as? NSDictionary else { continue }
1281
+ var rect = CGRect.zero
1282
+ guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { continue }
1283
+ guard rect.width >= 100 && rect.height >= 50 else { continue }
1284
+ let app = info[kCGWindowOwnerName as String] as? String ?? ""
1285
+ if app == "Lattices" || app == "lattices" || app == "Lattices" { continue }
1286
+ let pid = info[kCGWindowOwnerPID as String] as? Int32 ?? 0
1287
+ let title = info[kCGWindowName as String] as? String ?? ""
1288
+ let dIdx = displayIndex(for: rect)
1289
+ let onScreen = (info[kCGWindowIsOnscreen as String] as? Bool) ?? false
1290
+ ordered.append(CGWin(wid: wid, pid: pid, app: app, title: title, frame: rect, layer: layer, displayIndex: dIdx, isOnScreen: onScreen))
1291
+ }
1292
+
1293
+ NSLog("[ScreenMap] enter: %d windows after filtering", ordered.count)
1294
+
1295
+ // Iterative peeling PER DISPLAY
1296
+ func significantOverlap(_ a: CGRect, _ b: CGRect) -> Bool {
1297
+ let inter = a.intersection(b)
1298
+ guard !inter.isNull && inter.width > 0 && inter.height > 0 else { return false }
1299
+ let interArea = inter.width * inter.height
1300
+ let smallerArea = min(a.width * a.height, b.width * b.height)
1301
+ guard smallerArea > 0 else { return false }
1302
+ return interArea / smallerArea >= 0.15
1303
+ }
1304
+
1305
+ var byDisplay: [Int: [Int]] = [:]
1306
+ for i in ordered.indices {
1307
+ byDisplay[ordered[i].displayIndex, default: []].append(i)
1308
+ }
1309
+
1310
+ var layerAssignment = [Int: Int]()
1311
+ for (_, displayIndices) in byDisplay {
1312
+ var remaining = Set(displayIndices)
1313
+ var layer = 0
1314
+ while !remaining.isEmpty {
1315
+ var unoccluded: [Int] = []
1316
+ for i in remaining {
1317
+ let frame = ordered[i].frame
1318
+ let isOccluded = remaining.contains(where: { j in
1319
+ j < i && significantOverlap(ordered[j].frame, frame)
1320
+ })
1321
+ if !isOccluded { unoccluded.append(i) }
1322
+ }
1323
+ if unoccluded.isEmpty {
1324
+ for i in remaining { layerAssignment[i] = layer }
1325
+ remaining.removeAll()
1326
+ break
1327
+ }
1328
+ for i in unoccluded {
1329
+ layerAssignment[i] = layer
1330
+ remaining.remove(i)
1331
+ }
1332
+ layer += 1
1333
+ }
1334
+ }
1335
+
1336
+ // Build tmux PID → context lookup from TmuxModel
1337
+ let latticesSessionRegex = try? NSRegularExpression(pattern: "\\[lattices:([^\\]]+)\\]")
1338
+ var tmuxPidLookup: [Int32: (command: String, paneTitle: String, session: String)] = [:]
1339
+ for session in TmuxModel.shared.sessions {
1340
+ for pane in session.panes {
1341
+ // Map pane PID and all child PIDs to this context
1342
+ tmuxPidLookup[Int32(pane.pid)] = (pane.currentCommand, pane.title, session.name)
1343
+ }
1344
+ }
1345
+
1346
+ var mapWindows: [ScreenMapWindowEntry] = []
1347
+ for (i, win) in ordered.enumerated() {
1348
+ let assignedLayer = layerAssignment[i] ?? 0
1349
+
1350
+ // Parse [lattices:session] from title
1351
+ var latticesSession: String?
1352
+ if let regex = latticesSessionRegex,
1353
+ let match = regex.firstMatch(in: win.title, range: NSRange(win.title.startIndex..., in: win.title)),
1354
+ let range = Range(match.range(at: 1), in: win.title) {
1355
+ latticesSession = String(win.title[range])
1356
+ }
1357
+
1358
+ // Cross-reference with tmux — match by PID (window owner PID or child)
1359
+ let tmuxCtx = tmuxPidLookup[win.pid]
1360
+ // If no direct PID match, try looking up by lattices session name
1361
+ let tmuxBySession: (command: String, paneTitle: String, session: String)? = {
1362
+ guard let session = latticesSession else { return nil }
1363
+ guard tmuxCtx == nil else { return nil }
1364
+ for s in TmuxModel.shared.sessions where s.name == session {
1365
+ if let active = s.panes.first(where: { $0.isActive }) {
1366
+ return (active.currentCommand, active.title, s.name)
1367
+ }
1368
+ }
1369
+ return nil
1370
+ }()
1371
+ let ctx = tmuxCtx ?? tmuxBySession
1372
+
1373
+ mapWindows.append(ScreenMapWindowEntry(
1374
+ id: win.wid, pid: win.pid, app: win.app, title: win.title,
1375
+ originalFrame: win.frame, editedFrame: win.frame,
1376
+ zIndex: i, layer: assignedLayer, displayIndex: win.displayIndex,
1377
+ isOnScreen: win.isOnScreen,
1378
+ latticesSession: latticesSession ?? ctx?.session,
1379
+ tmuxCommand: ctx?.command,
1380
+ tmuxPaneTitle: ctx?.paneTitle
1381
+ ))
1382
+ }
1383
+
1384
+ let totalLayers = (mapWindows.map(\.layer).max() ?? 0) + 1
1385
+ NSLog("[ScreenMap] Peeling complete: %d layers from %d windows across %d displays (tmux panes indexed: %d)", totalLayers, mapWindows.count, byDisplay.count, tmuxPidLookup.count)
1386
+
1387
+ // Build display geometries
1388
+ var displayGeometries: [DisplayGeometry] = []
1389
+ for (i, screen) in screens.enumerated() {
1390
+ let cgOriginY = primaryHeight - screen.frame.maxY
1391
+ let cgRect = CGRect(x: screen.frame.origin.x, y: cgOriginY,
1392
+ width: screen.frame.width, height: screen.frame.height)
1393
+ displayGeometries.append(DisplayGeometry(index: i, cgRect: cgRect, label: screen.localizedName))
1394
+ }
1395
+
1396
+ let newEditor = ScreenMapEditorState(windows: mapWindows, displays: displayGeometries)
1397
+
1398
+ // Populate layer names from workspace config
1399
+ if let layers = WorkspaceManager.shared.config?.layers {
1400
+ for (i, layer) in layers.enumerated() {
1401
+ newEditor.layerNames[i] = layer.label
1402
+ }
1403
+ }
1404
+
1405
+ // Auto-focus the display where the mouse cursor is
1406
+ if screens.count > 1 {
1407
+ let mouseLocation = NSEvent.mouseLocation
1408
+ let mouseCG = CGPoint(x: mouseLocation.x, y: primaryHeight - mouseLocation.y)
1409
+ for disp in displayGeometries {
1410
+ if disp.cgRect.contains(mouseCG) {
1411
+ newEditor.focusedDisplayIndex = disp.index
1412
+ break
1413
+ }
1414
+ }
1415
+ }
1416
+
1417
+ editor = newEditor
1418
+ selectedWindowIds = []
1419
+ }
1420
+
1421
+ /// Re-snapshot, preserving display/layer context
1422
+ func refresh() {
1423
+ let savedDisplay = editor?.focusedDisplayIndex
1424
+ let savedLayers = editor?.selectedLayers ?? []
1425
+ enter()
1426
+ if let ed = editor {
1427
+ ed.focusedDisplayIndex = savedDisplay
1428
+ ed.selectedLayers = savedLayers
1429
+ }
1430
+ }
1431
+
1432
+ // MARK: - Key Handler
1433
+
1434
+ func handleKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
1435
+ let diag = DiagnosticLog.shared
1436
+ diag.info("[ScreenMap] key: \(keyCode)")
1437
+
1438
+ // Tiling mode intercepts keys before anything else
1439
+ if editor?.isTilingMode == true {
1440
+ switch keyCode {
1441
+ case 53: // Escape — always dismiss
1442
+ onDismiss?()
1443
+ return true
1444
+ case 123: // ← → left
1445
+ tileSelectedWindowInEditor(to: .left)
1446
+ return true
1447
+ case 124: // → → right
1448
+ tileSelectedWindowInEditor(to: .right)
1449
+ return true
1450
+ case 126: // ↑ → top (Shift = maximize)
1451
+ if modifiers.contains(.shift) {
1452
+ tileSelectedWindowInEditor(to: .maximize)
1453
+ } else {
1454
+ tileSelectedWindowInEditor(to: .top)
1455
+ }
1456
+ return true
1457
+ case 125: // ↓ → bottom
1458
+ tileSelectedWindowInEditor(to: .bottom)
1459
+ return true
1460
+ case 8: // c → center
1461
+ tileSelectedWindowInEditor(to: .center)
1462
+ return true
1463
+ case 18: // 1 → topLeft
1464
+ tileSelectedWindowInEditor(to: .topLeft)
1465
+ return true
1466
+ case 19: // 2 → topRight
1467
+ tileSelectedWindowInEditor(to: .topRight)
1468
+ return true
1469
+ case 20: // 3 → bottomLeft
1470
+ tileSelectedWindowInEditor(to: .bottomLeft)
1471
+ return true
1472
+ case 21: // 4 → bottomRight
1473
+ tileSelectedWindowInEditor(to: .bottomRight)
1474
+ return true
1475
+ case 23: // 5 → leftThird
1476
+ tileSelectedWindowInEditor(to: .leftThird)
1477
+ return true
1478
+ case 22: // 6 → centerThird
1479
+ tileSelectedWindowInEditor(to: .centerThird)
1480
+ return true
1481
+ case 26: // 7 → rightThird
1482
+ tileSelectedWindowInEditor(to: .rightThird)
1483
+ return true
1484
+ default:
1485
+ exitTilingMode()
1486
+ flash("Tiling cancelled")
1487
+ return true
1488
+ }
1489
+ }
1490
+
1491
+ // Ctrl+Option direct tiling shortcuts (always active, single selection)
1492
+ if modifiers.contains([.control, .option]) && selectedWindowIds.count == 1 {
1493
+ switch keyCode {
1494
+ case 123: // Ctrl+Opt+← → left
1495
+ tileSelectedWindowInEditor(to: .left)
1496
+ return true
1497
+ case 124: // Ctrl+Opt+→ → right
1498
+ tileSelectedWindowInEditor(to: .right)
1499
+ return true
1500
+ case 126: // Ctrl+Opt+↑ → top (+ Shift = maximize)
1501
+ if modifiers.contains(.shift) {
1502
+ tileSelectedWindowInEditor(to: .maximize)
1503
+ } else {
1504
+ tileSelectedWindowInEditor(to: .top)
1505
+ }
1506
+ return true
1507
+ case 125: // Ctrl+Opt+↓ → bottom
1508
+ tileSelectedWindowInEditor(to: .bottom)
1509
+ return true
1510
+ default:
1511
+ break
1512
+ }
1513
+ }
1514
+
1515
+ // Search mode intercepts keys before normal handling
1516
+ if isSearchActive {
1517
+ switch keyCode {
1518
+ case 53: // Escape — always dismiss
1519
+ onDismiss?()
1520
+ return true
1521
+ case 36: // Enter → select or focus
1522
+ if modifiers.contains(.command) {
1523
+ focusSelectedWindowOnScreen()
1524
+ } else {
1525
+ searchSelectHighlighted()
1526
+ }
1527
+ return true
1528
+ case 125: // ↓ → next result
1529
+ searchNavigate(delta: 1)
1530
+ return true
1531
+ case 126: // ↑ → previous result
1532
+ searchNavigate(delta: -1)
1533
+ return true
1534
+ default:
1535
+ // Let other keys pass through to the text field
1536
+ return false
1537
+ }
1538
+ }
1539
+
1540
+ switch keyCode {
1541
+ case 53: // Escape — always dismiss
1542
+ diag.info("[ScreenMap] exit")
1543
+ onDismiss?()
1544
+ return true
1545
+
1546
+ case 36: // Enter
1547
+ if modifiers.contains(.command) {
1548
+ // ⌘↩ → focus selected window on screen
1549
+ focusSelectedWindowOnScreen()
1550
+ } else {
1551
+ // ↩ → apply edits
1552
+ if editor?.isPreviewing == true { endPreview() }
1553
+ diag.info("[ScreenMap] apply edits")
1554
+ applyEdits()
1555
+ }
1556
+ return true
1557
+
1558
+ // MARK: Right hand — Navigation
1559
+
1560
+ case 4: // h → previous display
1561
+ if let ed = editor, ed.displays.count > 1 {
1562
+ ed.cyclePreviousDisplay()
1563
+ let label = ed.focusedDisplay?.label ?? "All displays"
1564
+ flash(label)
1565
+ objectWillChange.send()
1566
+ }
1567
+ return true
1568
+
1569
+ case 37: // l → next display
1570
+ if let ed = editor, ed.displays.count > 1 {
1571
+ ed.cycleNextDisplay()
1572
+ let label = ed.focusedDisplay?.label ?? "All displays"
1573
+ flash(label)
1574
+ objectWillChange.send()
1575
+ }
1576
+ return true
1577
+
1578
+ case 38: // j → next layer
1579
+ editor?.cycleLayer()
1580
+ diag.info("[ScreenMap] layer → \(editor?.layerLabel ?? "nil")")
1581
+ objectWillChange.send()
1582
+ return true
1583
+
1584
+ case 40: // k → previous layer
1585
+ editor?.cyclePreviousLayer()
1586
+ diag.info("[ScreenMap] layer → \(editor?.layerLabel ?? "nil")")
1587
+ objectWillChange.send()
1588
+ return true
1589
+
1590
+ case 45: // n → next window
1591
+ selectNextWindow()
1592
+ return true
1593
+
1594
+ case 35: // p → previous window
1595
+ selectPreviousWindow()
1596
+ return true
1597
+
1598
+ case 48: // Tab → cycle windows
1599
+ if modifiers.contains(.shift) {
1600
+ selectPreviousWindow()
1601
+ } else {
1602
+ selectNextWindow()
1603
+ }
1604
+ return true
1605
+
1606
+ case 33: // [ → move to previous layer
1607
+ if let ed = editor {
1608
+ for wid in selectedWindowIds {
1609
+ if let idx = ed.windows.firstIndex(where: { $0.id == wid }) {
1610
+ let oldLayer = ed.windows[idx].layer
1611
+ let newLayer = max(0, oldLayer - 1)
1612
+ ed.reassignLayer(windowId: wid, toLayer: newLayer, fitToAvailable: true)
1613
+ }
1614
+ }
1615
+ objectWillChange.send()
1616
+ }
1617
+ return true
1618
+
1619
+ case 30: // ] → move to next layer
1620
+ if let ed = editor {
1621
+ for wid in selectedWindowIds {
1622
+ if let idx = ed.windows.firstIndex(where: { $0.id == wid }) {
1623
+ let oldLayer = ed.windows[idx].layer
1624
+ ed.reassignLayer(windowId: wid, toLayer: oldLayer + 1, fitToAvailable: true)
1625
+ }
1626
+ }
1627
+ objectWillChange.send()
1628
+ }
1629
+ return true
1630
+
1631
+ // MARK: Left hand — Actions
1632
+
1633
+ case 1: // s → spread
1634
+ smartSpreadLayer()
1635
+ return true
1636
+
1637
+ case 14: // e → expose
1638
+ exposeLayer()
1639
+ return true
1640
+
1641
+ case 17: // t → tile (1 window = tiling mode, otherwise bulk tile)
1642
+ if selectedWindowIds.count == 1 {
1643
+ enterTilingMode()
1644
+ } else {
1645
+ tileLayer()
1646
+ }
1647
+ return true
1648
+
1649
+ case 2: // d → distribute
1650
+ distributeVisible()
1651
+ return true
1652
+
1653
+ case 15: // r → reset zoom/pan
1654
+ editor?.resetZoomPan()
1655
+ flash("Fit all")
1656
+ return true
1657
+
1658
+ case 5: // g → grow to fill
1659
+ fitAvailableSpace()
1660
+ return true
1661
+
1662
+ case 3: // f → flatten
1663
+ flattenLayers()
1664
+ return true
1665
+
1666
+ case 8: // c → consolidate
1667
+ consolidateLayers()
1668
+ return true
1669
+
1670
+ case 9: // v → toggle preview
1671
+ previewLayer()
1672
+ return true
1673
+
1674
+ case 0: // a → select all
1675
+ selectAll()
1676
+ return true
1677
+
1678
+ case 7: // x → deselect all
1679
+ clearSelection()
1680
+ flash("Deselected")
1681
+ return true
1682
+
1683
+ case 6: // z → discard edits
1684
+ if let ed = editor, ed.pendingEditCount > 0 {
1685
+ ed.discardEdits()
1686
+ flash("Edits discarded")
1687
+ } else {
1688
+ flash("No edits to discard")
1689
+ }
1690
+ return true
1691
+
1692
+ case 29: // 0 → fit all (secondary)
1693
+ editor?.resetZoomPan()
1694
+ flash("Fit all")
1695
+ return true
1696
+
1697
+ case 123: // ← previous display (secondary)
1698
+ if let ed = editor, ed.displays.count > 1 {
1699
+ ed.cyclePreviousDisplay()
1700
+ let label = ed.focusedDisplay?.label ?? "All displays"
1701
+ flash(label)
1702
+ objectWillChange.send()
1703
+ }
1704
+ return true
1705
+
1706
+ case 124: // → next display (secondary)
1707
+ if let ed = editor, ed.displays.count > 1 {
1708
+ ed.cycleNextDisplay()
1709
+ let label = ed.focusedDisplay?.label ?? "All displays"
1710
+ flash(label)
1711
+ objectWillChange.send()
1712
+ }
1713
+ return true
1714
+
1715
+ case 44: // / → open window search
1716
+ openSearch()
1717
+ return true
1718
+
1719
+ case 12: // q → dismiss screen map
1720
+ if editor?.isPreviewing == true { endPreview() }
1721
+ WindowBezel.shared.dismiss()
1722
+ onDismiss?()
1723
+ return true
1724
+
1725
+ default:
1726
+ return true
1727
+ }
1728
+ }
1729
+
1730
+ // MARK: - Actions
1731
+
1732
+ func applyEdits() {
1733
+ guard let ed = editor else { return }
1734
+ let pendingEdits = ed.windows.filter(\.hasEdits)
1735
+ guard !pendingEdits.isEmpty else {
1736
+ flash("No changes to apply")
1737
+ return
1738
+ }
1739
+
1740
+ var positions: [UInt32: (pid: Int32, frame: WindowFrame)] = [:]
1741
+ for win in pendingEdits {
1742
+ positions[win.id] = (pid: win.pid, frame: WindowFrame(
1743
+ x: Double(win.originalFrame.origin.x), y: Double(win.originalFrame.origin.y),
1744
+ w: Double(win.originalFrame.width), h: Double(win.originalFrame.height)
1745
+ ))
1746
+ }
1747
+ savedPositions = positions
1748
+
1749
+ let sorted = pendingEdits.sorted(by: { $0.layer > $1.layer })
1750
+ let allMoves = sorted.map { (wid: $0.id, pid: $0.pid, frame: $0.editedFrame) }
1751
+ NSLog("[ScreenMap] Applying %d edits", allMoves.count)
1752
+
1753
+ let actionLog = ed.actionLog
1754
+
1755
+ // Apply AX changes (no hide/show — Screen Map stays visible)
1756
+ WindowTiler.batchMoveWindows(allMoves)
1757
+
1758
+ // Commit edited frames as new originals so the map doesn't reload
1759
+ for i in ed.windows.indices {
1760
+ ed.windows[i].originalFrame = ed.windows[i].editedFrame
1761
+ }
1762
+ ed.objectWillChange.send()
1763
+ objectWillChange.send()
1764
+
1765
+ // Verify in background — if anything drifted, retry once then refresh
1766
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
1767
+ let drifted = WindowTiler.verifyMoves(allMoves)
1768
+ if !drifted.isEmpty {
1769
+ NSLog("[ScreenMap] %d/%d windows drifted, retrying", drifted.count, allMoves.count)
1770
+ WindowTiler.batchMoveWindows(drifted)
1771
+ }
1772
+ actionLog.verify()
1773
+ }
1774
+
1775
+ let noun = pendingEdits.count == 1 ? "edit" : "edits"
1776
+ flash("Applied \(pendingEdits.count) \(noun)")
1777
+ }
1778
+
1779
+ func applyEditsFromButton() {
1780
+ if editor?.isPreviewing == true { endPreview() }
1781
+ applyEdits()
1782
+ }
1783
+
1784
+ func exitScreenMap() {
1785
+ if editor?.isPreviewing == true { endPreview() }
1786
+ WindowBezel.shared.dismiss()
1787
+ if let ed = editor, ed.pendingEditCount > 0 {
1788
+ ed.discardEdits()
1789
+ flash("Edits discarded")
1790
+ } else {
1791
+ onDismiss?()
1792
+ }
1793
+ }
1794
+
1795
+ func tileLayer() {
1796
+ guard let ed = editor else { return }
1797
+ let before = ed.actionLog.snapshot(ed.windows)
1798
+ let count = ed.autoTileLayer()
1799
+ let after = ed.actionLog.snapshot(ed.windows)
1800
+ let summary: String
1801
+ if count >= 2 { summary = "Tiled \(count) windows" }
1802
+ else if count == 1 { summary = "Only 1 window in layer" }
1803
+ else { summary = "Select a single layer first" }
1804
+ let entry = ed.actionLog.record(action: "tile", summary: summary, before: before, after: after)
1805
+ ed.lastActionRef = entry.ref
1806
+ flash("\(summary) [\(entry.ref)]")
1807
+ objectWillChange.send()
1808
+ }
1809
+
1810
+ func exposeLayer() {
1811
+ guard let ed = editor else { return }
1812
+ let before = ed.actionLog.snapshot(ed.windows)
1813
+ let count = ed.exposeLayer()
1814
+ let after = ed.actionLog.snapshot(ed.windows)
1815
+ let summary: String
1816
+ if count >= 2 { summary = "Exposed \(count) windows" }
1817
+ else if count == 1 { summary = "Only 1 window in layer" }
1818
+ else { summary = "Select a single layer first" }
1819
+ let entry = ed.actionLog.record(action: "expose", summary: summary, before: before, after: after)
1820
+ ed.lastActionRef = entry.ref
1821
+ flash("\(summary) [\(entry.ref)]")
1822
+ objectWillChange.send()
1823
+ }
1824
+
1825
+ func smartSpreadLayer() {
1826
+ guard let ed = editor else { return }
1827
+ let before = ed.actionLog.snapshot(ed.windows)
1828
+ let count = ed.smartSpreadLayer()
1829
+ let after = ed.actionLog.snapshot(ed.windows)
1830
+ let summary: String
1831
+ if count >= 2 { summary = "Spread \(count) windows" }
1832
+ else if count == 1 { summary = "Only 1 window in layer" }
1833
+ else { summary = "Select a single layer first" }
1834
+ let entry = ed.actionLog.record(action: "spread", summary: summary, before: before, after: after)
1835
+ ed.lastActionRef = entry.ref
1836
+ flash("\(summary) [\(entry.ref)]")
1837
+ objectWillChange.send()
1838
+ }
1839
+
1840
+ func distributeVisible() {
1841
+ guard let ed = editor else { return }
1842
+ let before = ed.actionLog.snapshot(ed.windows)
1843
+ let count = ed.distributeLayer()
1844
+ let after = ed.actionLog.snapshot(ed.windows)
1845
+ let summary: String
1846
+ if count >= 2 { summary = "Distributed \(count) windows" }
1847
+ else if count == 1 { summary = "Only 1 window to distribute" }
1848
+ else { summary = "No visible windows to distribute" }
1849
+ let entry = ed.actionLog.record(action: "distribute", summary: summary, before: before, after: after)
1850
+ ed.lastActionRef = entry.ref
1851
+ flash("\(summary) [\(entry.ref)]")
1852
+ objectWillChange.send()
1853
+ }
1854
+
1855
+ func fitAvailableSpace() {
1856
+ guard let ed = editor else { return }
1857
+ let before = ed.actionLog.snapshot(ed.windows)
1858
+ let count = ed.fitAvailableSpace()
1859
+ let after = ed.actionLog.snapshot(ed.windows)
1860
+ let summary: String
1861
+ if count >= 2 { summary = "Grew \(count) windows to fill" }
1862
+ else if count == 1 { summary = "Grew 1 window to fill" }
1863
+ else { summary = "No windows to grow" }
1864
+ let entry = ed.actionLog.record(action: "fit", summary: summary, before: before, after: after)
1865
+ ed.lastActionRef = entry.ref
1866
+ flash("\(summary) [\(entry.ref)]")
1867
+ objectWillChange.send()
1868
+ }
1869
+
1870
+ func consolidateLayers() {
1871
+ guard let ed = editor else { return }
1872
+ let before = ed.actionLog.snapshot(ed.windows)
1873
+ let result = ed.consolidateLayers()
1874
+ let after = ed.actionLog.snapshot(ed.windows)
1875
+ let summary = result.old == result.new
1876
+ ? "Already optimal"
1877
+ : "Consolidated \(result.old) → \(result.new) layers"
1878
+ let entry = ed.actionLog.record(action: "merge", summary: summary, before: before, after: after)
1879
+ ed.lastActionRef = entry.ref
1880
+ flash("\(summary) [\(entry.ref)]")
1881
+ objectWillChange.send()
1882
+ }
1883
+
1884
+ func flattenLayers() {
1885
+ guard let ed = editor else { return }
1886
+ let before = ed.actionLog.snapshot(ed.windows)
1887
+ let result = ed.flattenSelectedLayers()
1888
+ let after = ed.actionLog.snapshot(ed.windows)
1889
+ let summary: String
1890
+ if let result = result {
1891
+ summary = "Merged \(result.count) windows into L\(result.target)"
1892
+ } else {
1893
+ summary = "Select 2+ layers to flatten"
1894
+ }
1895
+ let entry = ed.actionLog.record(action: "flatten", summary: summary, before: before, after: after)
1896
+ ed.lastActionRef = entry.ref
1897
+ flash("\(summary) [\(entry.ref)]")
1898
+ objectWillChange.send()
1899
+ }
1900
+
1901
+ // MARK: - Per-Window Tiling
1902
+
1903
+ func tileSelectedWindowInEditor(to position: TilePosition) {
1904
+ guard let ed = editor, selectedWindowIds.count == 1,
1905
+ let winId = selectedWindowIds.first,
1906
+ let idx = ed.windows.firstIndex(where: { $0.id == winId }),
1907
+ let display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex })
1908
+ else { return }
1909
+ ed.windows[idx].editedFrame = WindowTiler.tileFrame(for: position, inDisplay: display.cgRect)
1910
+ ed.isTilingMode = false
1911
+ ed.objectWillChange.send(); objectWillChange.send()
1912
+ flash(position.label)
1913
+ }
1914
+
1915
+ func tileSelectedWindowInEditor(fractions: (CGFloat, CGFloat, CGFloat, CGFloat), label: String) {
1916
+ guard let ed = editor, selectedWindowIds.count == 1,
1917
+ let winId = selectedWindowIds.first,
1918
+ let idx = ed.windows.firstIndex(where: { $0.id == winId }),
1919
+ let display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex })
1920
+ else { return }
1921
+ ed.windows[idx].editedFrame = WindowTiler.tileFrame(fractions: fractions, inDisplay: display.cgRect)
1922
+ ed.isTilingMode = false
1923
+ ed.objectWillChange.send(); objectWillChange.send()
1924
+ flash(label)
1925
+ }
1926
+
1927
+ func applyLayout(name: String) {
1928
+ guard let ed = editor else { return }
1929
+ let wm = WorkspaceManager.shared
1930
+ guard let layout = wm.gridLayouts[name] else {
1931
+ flash("Layout '\(name)' not found")
1932
+ return
1933
+ }
1934
+
1935
+ var matched = 0
1936
+ for spec in layout.windows {
1937
+ // Find matching window(s) by app name (case-insensitive substring)
1938
+ let appLower = spec.app.lowercased()
1939
+ let candidates = ed.windows.indices.filter { idx in
1940
+ let win = ed.windows[idx]
1941
+ let nameMatch = win.app.lowercased().contains(appLower)
1942
+ if let titleFilter = spec.title {
1943
+ return nameMatch && win.title.lowercased().contains(titleFilter.lowercased())
1944
+ }
1945
+ return nameMatch
1946
+ }
1947
+ guard let idx = candidates.first else { continue }
1948
+
1949
+ // Resolve tile position (check presets first, then built-in)
1950
+ guard let fractions = wm.resolveTileFractions(spec.tile) else { continue }
1951
+
1952
+ // Resolve display (spatial number → displayIndex)
1953
+ let display: DisplayGeometry
1954
+ if let spatialNum = spec.display {
1955
+ let order = ed.spatialDisplayOrder
1956
+ if spatialNum >= 1 && spatialNum <= order.count {
1957
+ display = order[spatialNum - 1]
1958
+ } else {
1959
+ display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex }) ?? ed.displays[0]
1960
+ }
1961
+ } else {
1962
+ display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex }) ?? ed.displays[0]
1963
+ }
1964
+
1965
+ ed.windows[idx].editedFrame = WindowTiler.tileFrame(fractions: fractions, inDisplay: display.cgRect)
1966
+ // Update display index if layout spec moves to a different display
1967
+ if let spatialNum = spec.display {
1968
+ let order = ed.spatialDisplayOrder
1969
+ if spatialNum >= 1 && spatialNum <= order.count {
1970
+ let moved = ed.windows[idx]
1971
+ ed.windows[idx] = ScreenMapWindowEntry(
1972
+ id: moved.id, pid: moved.pid,
1973
+ app: moved.app, title: moved.title,
1974
+ originalFrame: moved.originalFrame,
1975
+ editedFrame: moved.editedFrame,
1976
+ zIndex: moved.zIndex, layer: moved.layer,
1977
+ displayIndex: order[spatialNum - 1].index,
1978
+ isOnScreen: moved.isOnScreen,
1979
+ latticesSession: moved.latticesSession,
1980
+ tmuxCommand: moved.tmuxCommand,
1981
+ tmuxPaneTitle: moved.tmuxPaneTitle
1982
+ )
1983
+ }
1984
+ }
1985
+ matched += 1
1986
+ }
1987
+
1988
+ ed.objectWillChange.send(); objectWillChange.send()
1989
+ flash("Layout '\(name)': \(matched)/\(layout.windows.count) matched")
1990
+ }
1991
+
1992
+ func enterTilingMode() {
1993
+ guard let ed = editor, selectedWindowIds.count == 1 else { return }
1994
+ ed.isTilingMode = true
1995
+ ed.objectWillChange.send(); objectWillChange.send()
1996
+ }
1997
+
1998
+ func exitTilingMode() {
1999
+ guard let ed = editor else { return }
2000
+ ed.isTilingMode = false
2001
+ ed.objectWillChange.send(); objectWillChange.send()
2002
+ }
2003
+
2004
+ // MARK: - Preview
2005
+
2006
+ func previewLayer() {
2007
+ guard let ed = editor else { return }
2008
+ if ed.isPreviewing { endPreview(); return }
2009
+
2010
+ let visible = ed.focusedVisibleWindows
2011
+ guard !visible.isEmpty else { flash("No windows to preview"); return }
2012
+
2013
+ var captures: [UInt32: NSImage] = [:]
2014
+ for win in visible {
2015
+ if let cgImage = CGWindowListCreateImage(
2016
+ .null, .optionIncludingWindow, CGWindowID(win.id),
2017
+ [.boundsIgnoreFraming, .bestResolution]
2018
+ ) {
2019
+ captures[win.id] = NSImage(cgImage: cgImage,
2020
+ size: NSSize(width: win.editedFrame.width, height: win.editedFrame.height))
2021
+ }
2022
+ }
2023
+ previewCaptures = captures
2024
+ ed.isPreviewing = true
2025
+ objectWillChange.send()
2026
+ }
2027
+
2028
+ func showPreviewWindow(contentView: NSView, frame: NSRect) {
2029
+ let window = NSWindow(
2030
+ contentRect: frame, styleMask: .borderless,
2031
+ backing: .buffered, defer: false
2032
+ )
2033
+ window.isOpaque = false
2034
+ window.backgroundColor = .clear
2035
+ window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
2036
+ window.hasShadow = false
2037
+ window.contentView = contentView
2038
+ window.setFrame(frame, display: true)
2039
+ window.orderFrontRegardless()
2040
+ previewWindow = window
2041
+
2042
+ previewGlobalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown, .leftMouseDown, .rightMouseDown]) { [weak self] _ in
2043
+ self?.endPreview()
2044
+ }
2045
+ previewLocalMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .leftMouseDown, .rightMouseDown]) { [weak self] event in
2046
+ self?.endPreview()
2047
+ return nil
2048
+ }
2049
+ }
2050
+
2051
+ func endPreview() {
2052
+ guard editor?.isPreviewing == true else { return }
2053
+ previewWindow?.orderOut(nil)
2054
+ previewWindow = nil
2055
+ if let m = previewGlobalMonitor { NSEvent.removeMonitor(m) }
2056
+ if let m = previewLocalMonitor { NSEvent.removeMonitor(m) }
2057
+ previewGlobalMonitor = nil
2058
+ previewLocalMonitor = nil
2059
+ previewCaptures = [:]
2060
+ editor?.isPreviewing = false
2061
+ objectWillChange.send()
2062
+ }
2063
+
2064
+ // MARK: - Flash
2065
+
2066
+ func flash(_ message: String) {
2067
+ flashMessage = message
2068
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
2069
+ if self?.flashMessage == message { self?.flashMessage = nil }
2070
+ }
2071
+ }
2072
+ }
2073
+
2074
+ // MARK: - Bezel Panel (custom NSPanel)
2075
+
2076
+ /// Panel that stays behind its target window and supports dragging both together.
2077
+ private class BezelPanel: NSPanel {
2078
+ var targetWid: UInt32 = 0
2079
+ var targetPid: Int32 = 0
2080
+ private var dragOrigin: NSPoint?
2081
+ private var panelOriginAtDrag: NSPoint?
2082
+ private var targetOriginAtDrag: CGPoint?
2083
+
2084
+ // Never come to front on click — stay behind target
2085
+ override func mouseDown(with event: NSEvent) {
2086
+ let loc = event.locationInWindow
2087
+ dragOrigin = NSEvent.mouseLocation
2088
+ panelOriginAtDrag = frame.origin
2089
+
2090
+ // Read current target window position (CG coords, top-left origin)
2091
+ if let axWin = WindowTiler.findAXWindowByFrame(wid: targetWid, pid: targetPid) {
2092
+ var posRef: CFTypeRef?
2093
+ AXUIElementCopyAttributeValue(axWin, kAXPositionAttribute as CFString, &posRef)
2094
+ var pos = CGPoint.zero
2095
+ if let pv = posRef { AXValueGetValue(pv as! AXValue, .cgPoint, &pos) }
2096
+ targetOriginAtDrag = pos
2097
+ }
2098
+
2099
+ // Keep behind target — don't call super which would order front
2100
+ _ = loc // suppress unused warning
2101
+ }
2102
+
2103
+ override func mouseDragged(with event: NSEvent) {
2104
+ guard let dragStart = dragOrigin,
2105
+ let panelStart = panelOriginAtDrag else { return }
2106
+
2107
+ let current = NSEvent.mouseLocation
2108
+ let dx = current.x - dragStart.x
2109
+ let dy = current.y - dragStart.y
2110
+
2111
+ // Move the bezel panel
2112
+ setFrameOrigin(NSPoint(x: panelStart.x + dx, y: panelStart.y + dy))
2113
+
2114
+ // Move the target window via AX (CG coords: top-left origin, so dy is inverted)
2115
+ if let targetStart = targetOriginAtDrag,
2116
+ let axWin = WindowTiler.findAXWindowByFrame(wid: targetWid, pid: targetPid) {
2117
+ var newPos = CGPoint(x: targetStart.x + dx, y: targetStart.y - dy)
2118
+ let posVal: AXValue? = AXValueCreate(.cgPoint, &newPos)
2119
+ if let pv = posVal {
2120
+ AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
2121
+ }
2122
+ }
2123
+ }
2124
+
2125
+ override func mouseUp(with event: NSEvent) {
2126
+ dragOrigin = nil
2127
+ panelOriginAtDrag = nil
2128
+ targetOriginAtDrag = nil
2129
+ }
2130
+ }
2131
+
2132
+ // MARK: - Window Bezel (standalone companion window)
2133
+
2134
+ /// Persistent chromeless companion window that frames a target window with info.
2135
+ /// Singleton — reuses a single NSPanel, repositions/updates content for each target.
2136
+ final class WindowBezel {
2137
+ static let shared = WindowBezel()
2138
+
2139
+ private var panel: BezelPanel?
2140
+ private var currentTargetWid: UInt32?
2141
+
2142
+ var isVisible: Bool { panel?.isVisible ?? false }
2143
+
2144
+ /// Show or update bezel for a known ScreenMapWindowEntry.
2145
+ func show(for win: ScreenMapWindowEntry, editor: ScreenMapEditorState) {
2146
+ let screens = NSScreen.screens
2147
+ guard !screens.isEmpty else { return }
2148
+ let primaryHeight = screens.first!.frame.height
2149
+ let targetScreen: NSScreen
2150
+ if win.displayIndex < screens.count {
2151
+ targetScreen = screens[win.displayIndex]
2152
+ } else {
2153
+ targetScreen = screens.first!
2154
+ }
2155
+
2156
+ let targetWindowNumber = Self.findNSWindowNumber(forCGWindowID: win.id)
2157
+
2158
+ let displayName = editor.displays.first(where: { $0.index == win.displayIndex })?.label ?? "Display \(win.displayIndex)"
2159
+ let displayNumber = editor.spatialNumber(for: win.displayIndex)
2160
+ let layerName = editor.layerDisplayName(for: win.layer)
2161
+ let windowsOnDisplay = editor.windows.filter { $0.displayIndex == win.displayIndex }.count
2162
+ let layersOnDisplay = editor.layersForDisplay(win.displayIndex).count
2163
+
2164
+ let cgFrame = win.editedFrame
2165
+ let screenNS = targetScreen.frame
2166
+
2167
+ let winLocalX = cgFrame.origin.x - screenNS.origin.x
2168
+ let winLocalY = (primaryHeight - cgFrame.origin.y - cgFrame.height) - screenNS.origin.y
2169
+
2170
+ // Detect flush edges
2171
+ let tolerance: CGFloat = 10
2172
+ let flush = ShowOnScreenBezelView.FlushEdges(
2173
+ top: (screenNS.height - (winLocalY + cgFrame.height)) < tolerance,
2174
+ bottom: winLocalY < tolerance,
2175
+ left: winLocalX < tolerance,
2176
+ right: (screenNS.width - (winLocalX + cgFrame.width)) < tolerance
2177
+ )
2178
+
2179
+ // Shelf placement: prefer non-flush edges
2180
+ let bezelH: CGFloat = 48
2181
+ let spaceBelow = winLocalY - bezelH
2182
+ let spaceAbove = screenNS.height - (winLocalY + cgFrame.height) - bezelH
2183
+ let spaceLeft = winLocalX
2184
+ let spaceRight = screenNS.width - (winLocalX + cgFrame.width)
2185
+
2186
+ let placement: ShowOnScreenBezelView.LabelPlacement
2187
+ if !flush.bottom && spaceBelow >= 0 {
2188
+ placement = .below
2189
+ } else if !flush.top && spaceAbove >= 0 {
2190
+ placement = .above
2191
+ } else if !flush.right && spaceRight >= 200 {
2192
+ placement = .right
2193
+ } else if !flush.left && spaceLeft >= 200 {
2194
+ placement = .left
2195
+ } else if spaceBelow >= 0 {
2196
+ placement = .below
2197
+ } else if spaceAbove >= 0 {
2198
+ placement = .above
2199
+ } else {
2200
+ placement = .right
2201
+ }
2202
+
2203
+ // Compute tight frame
2204
+ let edgePx: CGFloat = 5
2205
+ let shelfPx: CGFloat = 40
2206
+ let inL: CGFloat = flush.left ? 0 : edgePx
2207
+ let inR: CGFloat = flush.right ? 0 : edgePx
2208
+ let inT: CGFloat = flush.top ? 0 : edgePx
2209
+ let inB: CGFloat = flush.bottom ? 0 : edgePx
2210
+
2211
+ var fX = winLocalX - inL
2212
+ var fY = winLocalY - inB
2213
+ var fW = cgFrame.width + inL + inR
2214
+ var fH = cgFrame.height + inT + inB
2215
+
2216
+ switch placement {
2217
+ case .below: fY -= shelfPx; fH += shelfPx
2218
+ case .above: fH += shelfPx
2219
+ case .right: fW += 200
2220
+ case .left: fX -= 200; fW += 200
2221
+ }
2222
+
2223
+ let tightFrame = NSRect(
2224
+ x: screenNS.origin.x + fX,
2225
+ y: screenNS.origin.y + fY,
2226
+ width: fW,
2227
+ height: fH
2228
+ )
2229
+
2230
+ let localWinFrame = CGRect(
2231
+ x: winLocalX - fX,
2232
+ y: winLocalY - fY,
2233
+ width: cgFrame.width,
2234
+ height: cgFrame.height
2235
+ )
2236
+ let tightSize = CGSize(width: tightFrame.width, height: tightFrame.height)
2237
+
2238
+ // Capture window content for screenshot tool compositing
2239
+ let windowSnapshot: NSImage? = {
2240
+ guard let cgImage = CGWindowListCreateImage(
2241
+ .null,
2242
+ .optionIncludingWindow,
2243
+ win.id,
2244
+ [.bestResolution, .boundsIgnoreFraming]
2245
+ ) else { return nil }
2246
+ return NSImage(cgImage: cgImage, size: NSSize(width: cgFrame.width, height: cgFrame.height))
2247
+ }()
2248
+
2249
+ let bezelView = ShowOnScreenBezelView(
2250
+ appName: win.app,
2251
+ windowTitle: win.title,
2252
+ displayName: displayName,
2253
+ displayNumber: displayNumber,
2254
+ layerName: layerName,
2255
+ windowSize: "\(Int(cgFrame.width))×\(Int(cgFrame.height))",
2256
+ windowsOnDisplay: windowsOnDisplay,
2257
+ layersOnDisplay: layersOnDisplay,
2258
+ windowLocalFrame: localWinFrame,
2259
+ screenSize: tightSize,
2260
+ labelPlacement: placement,
2261
+ flush: flush,
2262
+ windowSnapshot: windowSnapshot
2263
+ )
2264
+
2265
+ let hostingView = NSHostingView(rootView: bezelView)
2266
+ let isNewWindow = (panel == nil)
2267
+
2268
+ if panel == nil {
2269
+ let p = BezelPanel(
2270
+ contentRect: tightFrame,
2271
+ styleMask: [.borderless, .nonactivatingPanel],
2272
+ backing: .buffered,
2273
+ defer: false
2274
+ )
2275
+ p.isOpaque = false
2276
+ p.backgroundColor = .clear
2277
+ p.level = .normal
2278
+ p.hasShadow = true
2279
+ p.hidesOnDeactivate = false
2280
+ p.isReleasedWhenClosed = false
2281
+ p.isMovable = false // we handle dragging ourselves
2282
+ p.appearance = nil
2283
+ panel = p
2284
+ }
2285
+
2286
+ guard let p = panel else { return }
2287
+
2288
+ p.contentView = hostingView
2289
+ p.targetWid = win.id
2290
+ p.targetPid = win.pid
2291
+ currentTargetWid = win.id
2292
+
2293
+ if isNewWindow {
2294
+ // First show: position and fade in
2295
+ p.setFrame(tightFrame, display: false)
2296
+ p.alphaValue = 0
2297
+
2298
+ if let targetWinNum = targetWindowNumber {
2299
+ p.orderFrontRegardless()
2300
+ p.order(.below, relativeTo: targetWinNum)
2301
+ } else {
2302
+ p.orderFrontRegardless()
2303
+ }
2304
+
2305
+ NSAnimationContext.runAnimationGroup { ctx in
2306
+ ctx.duration = 0.15
2307
+ p.animator().alphaValue = 1.0
2308
+ }
2309
+ } else {
2310
+ // Reuse: animate to new position/size
2311
+ if let targetWinNum = targetWindowNumber {
2312
+ p.order(.below, relativeTo: targetWinNum)
2313
+ }
2314
+
2315
+ NSAnimationContext.runAnimationGroup { ctx in
2316
+ ctx.duration = 0.2
2317
+ ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
2318
+ p.animator().setFrame(tightFrame, display: true)
2319
+ p.animator().alphaValue = 1.0
2320
+ }
2321
+ }
2322
+ }
2323
+
2324
+ /// Toggle bezel for the frontmost window (global hotkey).
2325
+ static func showBezelForFrontmostWindow() {
2326
+ if shared.isVisible {
2327
+ shared.dismiss()
2328
+ return
2329
+ }
2330
+
2331
+ guard let frontApp = NSWorkspace.shared.frontmostApplication,
2332
+ frontApp.bundleIdentifier != "com.arach.lattices" else { return }
2333
+
2334
+ let pid = frontApp.processIdentifier
2335
+
2336
+ guard let infoList = CGWindowListCopyWindowInfo(
2337
+ [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
2338
+ ) as? [[String: Any]] else { return }
2339
+
2340
+ var targetInfo: [String: Any]?
2341
+ for info in infoList {
2342
+ guard let wPid = info[kCGWindowOwnerPID as String] as? Int32,
2343
+ wPid == pid,
2344
+ let wLayer = info[kCGWindowLayer as String] as? Int,
2345
+ wLayer == 0 else { continue }
2346
+ if let bounds = info[kCGWindowBounds as String] as? [String: CGFloat],
2347
+ let w = bounds["Width"], let h = bounds["Height"],
2348
+ w > 50, h > 50 {
2349
+ targetInfo = info
2350
+ break
2351
+ }
2352
+ }
2353
+ guard let info = targetInfo,
2354
+ let wid = info[kCGWindowNumber as String] as? UInt32 else { return }
2355
+
2356
+ let ctrl = ScreenMapController()
2357
+ ctrl.enter()
2358
+ guard let ed = ctrl.editor,
2359
+ let win = ed.windows.first(where: { $0.id == wid }) else { return }
2360
+
2361
+ shared.show(for: win, editor: ed)
2362
+ }
2363
+
2364
+ func dismiss() {
2365
+ guard let p = panel else { return }
2366
+ NSAnimationContext.runAnimationGroup({ ctx in
2367
+ ctx.duration = 0.2
2368
+ p.animator().alphaValue = 0
2369
+ }) { [weak self] in
2370
+ p.orderOut(nil)
2371
+ self?.panel = nil
2372
+ self?.currentTargetWid = nil
2373
+ }
2374
+ }
2375
+
2376
+ private static func findNSWindowNumber(forCGWindowID cgWid: UInt32) -> Int? {
2377
+ guard let infoList = CGWindowListCopyWindowInfo(
2378
+ [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
2379
+ ) as? [[String: Any]] else { return nil }
2380
+ for info in infoList {
2381
+ guard let wid = info[kCGWindowNumber as String] as? UInt32,
2382
+ wid == cgWid else { continue }
2383
+ return Int(wid)
2384
+ }
2385
+ return nil
2386
+ }
2387
+ }