@lattices/cli 0.3.0 → 0.4.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 (95) hide show
  1. package/README.md +85 -9
  2. package/app/Package.swift +8 -1
  3. package/app/Sources/AdvisorLearningStore.swift +90 -0
  4. package/app/Sources/AgentSession.swift +377 -0
  5. package/app/Sources/AppDelegate.swift +44 -12
  6. package/app/Sources/AppShellView.swift +81 -8
  7. package/app/Sources/AudioProvider.swift +386 -0
  8. package/app/Sources/CheatSheetHUD.swift +261 -19
  9. package/app/Sources/DaemonProtocol.swift +13 -0
  10. package/app/Sources/DaemonServer.swift +8 -0
  11. package/app/Sources/DesktopModel.swift +164 -5
  12. package/app/Sources/DesktopModelTypes.swift +2 -0
  13. package/app/Sources/DiagnosticLog.swift +104 -2
  14. package/app/Sources/EventBus.swift +1 -0
  15. package/app/Sources/HUDBottomBar.swift +279 -0
  16. package/app/Sources/HUDController.swift +1158 -0
  17. package/app/Sources/HUDLeftBar.swift +849 -0
  18. package/app/Sources/HUDMinimap.swift +179 -0
  19. package/app/Sources/HUDRightBar.swift +774 -0
  20. package/app/Sources/HUDState.swift +367 -0
  21. package/app/Sources/HUDTopBar.swift +243 -0
  22. package/app/Sources/HandsOffSession.swift +733 -0
  23. package/app/Sources/HomeDashboardView.swift +125 -0
  24. package/app/Sources/HotkeyManager.swift +2 -0
  25. package/app/Sources/HotkeyStore.swift +45 -9
  26. package/app/Sources/IntentEngine.swift +925 -0
  27. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  28. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  29. package/app/Sources/Intents/FocusIntent.swift +69 -0
  30. package/app/Sources/Intents/HelpIntent.swift +41 -0
  31. package/app/Sources/Intents/KillIntent.swift +47 -0
  32. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  33. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  34. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  35. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  36. package/app/Sources/Intents/ScanIntent.swift +52 -0
  37. package/app/Sources/Intents/SearchIntent.swift +190 -0
  38. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  39. package/app/Sources/Intents/TileIntent.swift +61 -0
  40. package/app/Sources/LatticesApi.swift +1235 -30
  41. package/app/Sources/LauncherHUD.swift +348 -0
  42. package/app/Sources/MainView.swift +147 -44
  43. package/app/Sources/OcrModel.swift +34 -1
  44. package/app/Sources/OmniSearchState.swift +99 -102
  45. package/app/Sources/OnboardingView.swift +457 -0
  46. package/app/Sources/PermissionChecker.swift +2 -12
  47. package/app/Sources/PiChatDock.swift +454 -0
  48. package/app/Sources/PiChatSession.swift +815 -0
  49. package/app/Sources/PiWorkspaceView.swift +364 -0
  50. package/app/Sources/PlacementSpec.swift +195 -0
  51. package/app/Sources/Preferences.swift +59 -0
  52. package/app/Sources/ProjectScanner.swift +1 -1
  53. package/app/Sources/ScreenMapState.swift +701 -55
  54. package/app/Sources/ScreenMapView.swift +843 -103
  55. package/app/Sources/ScreenMapWindowController.swift +22 -0
  56. package/app/Sources/SessionLayerStore.swift +285 -0
  57. package/app/Sources/SessionManager.swift +4 -1
  58. package/app/Sources/SettingsView.swift +186 -3
  59. package/app/Sources/Theme.swift +9 -8
  60. package/app/Sources/TmuxModel.swift +7 -0
  61. package/app/Sources/TmuxQuery.swift +27 -3
  62. package/app/Sources/VoiceChatView.swift +192 -0
  63. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  64. package/app/Sources/VoiceIntentResolver.swift +671 -0
  65. package/app/Sources/VoxClient.swift +454 -0
  66. package/app/Sources/WindowTiler.swift +348 -87
  67. package/app/Sources/WorkspaceManager.swift +127 -18
  68. package/bin/client.ts +16 -0
  69. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  70. package/bin/handsoff-infer.ts +280 -0
  71. package/bin/handsoff-worker.ts +731 -0
  72. package/bin/{lattices-app.js → lattices-app.ts} +67 -32
  73. package/bin/lattices-dev +160 -0
  74. package/bin/{lattices.js → lattices.ts} +600 -137
  75. package/bin/project-twin.ts +645 -0
  76. package/docs/agent-execution-plan.md +562 -0
  77. package/docs/agents.md +142 -0
  78. package/docs/api.md +153 -34
  79. package/docs/app.md +29 -1
  80. package/docs/config.md +5 -1
  81. package/docs/handsoff-test-scenarios.md +84 -0
  82. package/docs/layers.md +20 -20
  83. package/docs/ocr.md +14 -5
  84. package/docs/overview.md +5 -1
  85. package/docs/presentation-execution-review.md +491 -0
  86. package/docs/prompts/hands-off-system.md +374 -0
  87. package/docs/prompts/hands-off-turn.md +30 -0
  88. package/docs/prompts/voice-advisor.md +31 -0
  89. package/docs/prompts/voice-fallback.md +23 -0
  90. package/docs/tiling-reference.md +167 -0
  91. package/docs/twins.md +138 -0
  92. package/docs/voice-command-protocol.md +278 -0
  93. package/docs/voice.md +219 -0
  94. package/package.json +21 -10
  95. package/bin/client.js +0 -4
@@ -10,6 +10,85 @@ struct DisplayGeometry {
10
10
  let label: String // e.g. "Built-in Retina Display", "LG UltraFine"
11
11
  }
12
12
 
13
+ // MARK: - Canvas Region
14
+
15
+ struct ScreenMapCanvasRegion: Identifiable {
16
+ enum Kind {
17
+ case overview
18
+ case display
19
+ case layer
20
+ }
21
+
22
+ let id: String
23
+ let kind: Kind
24
+ let title: String
25
+ let subtitle: String
26
+ let rect: CGRect
27
+ let count: Int
28
+ let displayIndex: Int?
29
+ let layer: Int?
30
+ }
31
+
32
+ struct ScreenMapCanvasNavigationTarget {
33
+ let center: CGPoint
34
+ let rect: CGRect?
35
+ let zoomToFit: Bool
36
+ }
37
+
38
+ enum ScreenMapViewportPreset: String, CaseIterable, Identifiable {
39
+ case overview
40
+ case main
41
+ case topRight
42
+ case bottomLeft
43
+ case bottomRight
44
+
45
+ var id: String { rawValue }
46
+
47
+ var title: String {
48
+ switch self {
49
+ case .overview: return "All"
50
+ case .main: return "Main"
51
+ case .topRight: return "Top Right"
52
+ case .bottomLeft: return "Bottom Left"
53
+ case .bottomRight: return "Bottom Right"
54
+ }
55
+ }
56
+
57
+ var shortLabel: String {
58
+ switch self {
59
+ case .overview: return "all"
60
+ case .main: return "main"
61
+ case .topRight: return "tr"
62
+ case .bottomLeft: return "bl"
63
+ case .bottomRight: return "br"
64
+ }
65
+ }
66
+
67
+ var keyHint: String {
68
+ switch self {
69
+ case .overview: return "0"
70
+ case .main: return "1"
71
+ case .topRight: return "2"
72
+ case .bottomLeft: return "3"
73
+ case .bottomRight: return "4"
74
+ }
75
+ }
76
+ }
77
+
78
+ struct ScreenMapWindowSet: Identifiable, Equatable {
79
+ let id: UUID
80
+ var name: String
81
+ var windowIds: Set<UInt32>
82
+
83
+ init(id: UUID = UUID(), name: String, windowIds: Set<UInt32>) {
84
+ self.id = id
85
+ self.name = name
86
+ self.windowIds = windowIds
87
+ }
88
+
89
+ var count: Int { windowIds.count }
90
+ }
91
+
13
92
  // MARK: - Screen Map Window Entry
14
93
 
15
94
  struct ScreenMapWindowEntry: Identifiable {
@@ -19,6 +98,7 @@ struct ScreenMapWindowEntry: Identifiable {
19
98
  let title: String
20
99
  var originalFrame: CGRect // frozen at snapshot time
21
100
  var editedFrame: CGRect // mutated during drag
101
+ var virtualFrame: CGRect // persistent canvas/world position
22
102
  let zIndex: Int // 0 = frontmost
23
103
  var layer: Int // assigned by iterative peeling (per-display)
24
104
  let displayIndex: Int // which monitor this window belongs to
@@ -72,6 +152,7 @@ final class ScreenMapEditorState: ObservableObject {
72
152
  @Published var zoomLevel: CGFloat = 1.0 // 1.0 = fit-all
73
153
  @Published var panOffset: CGPoint = .zero // canvas-local pixels
74
154
  @Published var focusedDisplayIndex: Int? = nil // nil = all-displays view
155
+ @Published var activeViewportPreset: ScreenMapViewportPreset? = .main
75
156
  @Published var windowSearchQuery: String = ""
76
157
  @Published var isTilingMode: Bool = false
77
158
  var isSearching: Bool { !windowSearchQuery.isEmpty }
@@ -179,6 +260,16 @@ final class ScreenMapEditorState: ObservableObject {
179
260
 
180
261
  let actionLog = ScreenMapActionLog()
181
262
 
263
+ func syncLayoutFrame(at index: Int, to frame: CGRect) {
264
+ windows[index].virtualFrame = frame
265
+ windows[index].editedFrame = frame
266
+ }
267
+
268
+ func resetLayoutFrameToOriginal(at index: Int) {
269
+ windows[index].virtualFrame = windows[index].originalFrame
270
+ windows[index].editedFrame = windows[index].originalFrame
271
+ }
272
+
182
273
  /// Backward-compat: single active layer when exactly one is selected
183
274
  var activeLayer: Int? {
184
275
  selectedLayers.count == 1 ? selectedLayers.first : nil
@@ -197,6 +288,9 @@ final class ScreenMapEditorState: ObservableObject {
197
288
  var mapOrigin: CGPoint = .zero
198
289
  var screenSize: CGSize = .zero
199
290
  var bboxOrigin: CGPoint = .zero // top-left of the bounding box in CG coords
291
+ var viewportSize: CGSize = .zero
292
+ var pendingCanvasNavigation: ScreenMapCanvasNavigationTarget?
293
+ var canvasNavigationRevision: Int = 0
200
294
 
201
295
  let displays: [DisplayGeometry]
202
296
 
@@ -263,6 +357,169 @@ final class ScreenMapEditorState: ObservableObject {
263
357
  return windows.filter { selectedLayers.contains($0.layer) }
264
358
  }
265
359
 
360
+ private var worldScopedDisplays: [DisplayGeometry] {
361
+ guard let focusedDisplayIndex else { return displays }
362
+ return displays.filter { $0.index == focusedDisplayIndex }
363
+ }
364
+
365
+ private var worldScopedWindows: [ScreenMapWindowEntry] {
366
+ guard let focusedDisplayIndex else { return windows }
367
+ return windows.filter { $0.displayIndex == focusedDisplayIndex }
368
+ }
369
+
370
+ var canvasWorldBounds: CGRect {
371
+ var rects = worldScopedDisplays.map(\.cgRect)
372
+ rects.append(contentsOf: worldScopedWindows.map(\.virtualFrame))
373
+
374
+ if rects.isEmpty {
375
+ return CGRect(origin: bboxOrigin, size: screenSize)
376
+ }
377
+
378
+ var union = rects[0]
379
+ for rect in rects.dropFirst() {
380
+ union = union.union(rect)
381
+ }
382
+
383
+ let pad: CGFloat = focusedDisplayIndex == nil ? 120 : 80
384
+ return union.insetBy(dx: -pad, dy: -pad)
385
+ }
386
+
387
+ var viewportWorldRect: CGRect {
388
+ guard scale > 0, viewportSize.width > 0, viewportSize.height > 0 else {
389
+ return canvasWorldBounds
390
+ }
391
+
392
+ let raw = CGRect(
393
+ x: bboxOrigin.x - (mapOrigin.x + panOffset.x) / scale,
394
+ y: bboxOrigin.y - (mapOrigin.y + panOffset.y) / scale,
395
+ width: viewportSize.width / scale,
396
+ height: viewportSize.height / scale
397
+ )
398
+
399
+ let world = canvasWorldBounds
400
+ let clipped = raw.intersection(world)
401
+ return clipped.isNull ? raw : clipped
402
+ }
403
+
404
+ func viewportRect(for preset: ScreenMapViewportPreset) -> CGRect {
405
+ let world = canvasWorldBounds
406
+ guard preset != .overview else { return world }
407
+
408
+ let halfWidth = max(world.width / 2, 1)
409
+ let halfHeight = max(world.height / 2, 1)
410
+
411
+ switch preset {
412
+ case .overview:
413
+ return world
414
+ case .main:
415
+ return CGRect(x: world.minX, y: world.minY, width: halfWidth, height: halfHeight)
416
+ case .topRight:
417
+ return CGRect(x: world.midX, y: world.minY, width: halfWidth, height: halfHeight)
418
+ case .bottomLeft:
419
+ return CGRect(x: world.minX, y: world.midY, width: halfWidth, height: halfHeight)
420
+ case .bottomRight:
421
+ return CGRect(x: world.midX, y: world.midY, width: halfWidth, height: halfHeight)
422
+ }
423
+ }
424
+
425
+ var viewportPresetSummary: String {
426
+ activeViewportPreset?.title ?? "Custom"
427
+ }
428
+
429
+ func windows(matching ids: Set<UInt32>) -> [ScreenMapWindowEntry] {
430
+ windows.filter { ids.contains($0.id) }
431
+ }
432
+
433
+ func rectForWindowIDs(_ ids: Set<UInt32>) -> CGRect? {
434
+ let matched = windows(matching: ids)
435
+ guard let first = matched.first else { return nil }
436
+ return matched.dropFirst().reduce(first.virtualFrame) { $0.union($1.virtualFrame) }
437
+ }
438
+
439
+ var canvasExplorerRegions: [ScreenMapCanvasRegion] {
440
+ let world = canvasWorldBounds
441
+ var regions: [ScreenMapCanvasRegion] = [
442
+ ScreenMapCanvasRegion(
443
+ id: "overview",
444
+ kind: .overview,
445
+ title: focusedDisplayIndex == nil ? "All Displays" : "Display Canvas",
446
+ subtitle: "\(worldScopedWindows.count) windows",
447
+ rect: world,
448
+ count: worldScopedWindows.count,
449
+ displayIndex: focusedDisplayIndex,
450
+ layer: nil
451
+ )
452
+ ]
453
+
454
+ if focusedDisplayIndex == nil {
455
+ for display in spatialDisplayOrder {
456
+ let displayWindows = windows.filter { $0.displayIndex == display.index }
457
+ let rect = regionRect(
458
+ for: displayWindows,
459
+ fallback: display.cgRect,
460
+ padding: 60
461
+ )
462
+ regions.append(
463
+ ScreenMapCanvasRegion(
464
+ id: "display-\(display.index)",
465
+ kind: .display,
466
+ title: display.label,
467
+ subtitle: "\(displayWindows.count) windows",
468
+ rect: rect,
469
+ count: displayWindows.count,
470
+ displayIndex: display.index,
471
+ layer: nil
472
+ )
473
+ )
474
+ }
475
+ }
476
+
477
+ let layerScope = effectiveLayers.compactMap { layer -> ScreenMapCanvasRegion? in
478
+ let layerWindows = worldScopedWindows.filter { $0.layer == layer }
479
+ guard !layerWindows.isEmpty else { return nil }
480
+
481
+ let displayLabel: String = {
482
+ guard focusedDisplayIndex == nil,
483
+ let displayIndex = layerWindows.first?.displayIndex,
484
+ let display = displays.first(where: { $0.index == displayIndex }) else {
485
+ return ""
486
+ }
487
+ return display.label
488
+ }()
489
+
490
+ let subtitleBase = "\(layerWindows.count) window\(layerWindows.count == 1 ? "" : "s")"
491
+ let subtitle = displayLabel.isEmpty ? subtitleBase : "\(subtitleBase) · \(displayLabel)"
492
+
493
+ return ScreenMapCanvasRegion(
494
+ id: "layer-\(layer)-\(focusedDisplayIndex.map(String.init) ?? "all")",
495
+ kind: .layer,
496
+ title: layerNames[layer] ?? "Layer \(layer)",
497
+ subtitle: subtitle,
498
+ rect: regionRect(for: layerWindows, fallback: layerWindows[0].virtualFrame, padding: 48),
499
+ count: layerWindows.count,
500
+ displayIndex: layerWindows.first?.displayIndex,
501
+ layer: layer
502
+ )
503
+ }
504
+ .sorted { lhs, rhs in
505
+ if lhs.count != rhs.count { return lhs.count > rhs.count }
506
+ return lhs.title < rhs.title
507
+ }
508
+
509
+ regions.append(contentsOf: layerScope.prefix(6))
510
+ return regions
511
+ }
512
+
513
+ private func regionRect(for windows: [ScreenMapWindowEntry], fallback: CGRect, padding: CGFloat) -> CGRect {
514
+ guard !windows.isEmpty else { return fallback.insetBy(dx: -padding, dy: -padding) }
515
+
516
+ var union = windows[0].virtualFrame
517
+ for win in windows.dropFirst() {
518
+ union = union.union(win.virtualFrame)
519
+ }
520
+ return union.insetBy(dx: -padding, dy: -padding)
521
+ }
522
+
266
523
  /// The focused display geometry (nil when showing all)
267
524
  var focusedDisplay: DisplayGeometry? {
268
525
  guard let idx = focusedDisplayIndex else { return nil }
@@ -300,6 +557,7 @@ final class ScreenMapEditorState: ObservableObject {
300
557
  focusedDisplayIndex = index
301
558
  selectedLayers = [] // reset to "All" for the new display scope
302
559
  resetZoomPan()
560
+ DiagnosticLog.shared.info("[Canvas] scope → \(canvasScopeSummary)")
303
561
  }
304
562
 
305
563
  /// Cycle to the next display in spatial (left-to-right) order
@@ -310,6 +568,7 @@ final class ScreenMapEditorState: ObservableObject {
310
568
  focusedDisplayIndex = order.first!.index
311
569
  selectedLayers = []
312
570
  resetZoomPan()
571
+ DiagnosticLog.shared.info("[Canvas] scope → \(canvasScopeSummary)")
313
572
  return
314
573
  }
315
574
  if let pos = order.firstIndex(where: { $0.index == current }) {
@@ -324,6 +583,7 @@ final class ScreenMapEditorState: ObservableObject {
324
583
  }
325
584
  selectedLayers = []
326
585
  resetZoomPan()
586
+ DiagnosticLog.shared.info("[Canvas] scope → \(canvasScopeSummary)")
327
587
  }
328
588
 
329
589
  /// Cycle to the previous display in spatial (right-to-left) order
@@ -334,6 +594,7 @@ final class ScreenMapEditorState: ObservableObject {
334
594
  focusedDisplayIndex = order.last!.index
335
595
  selectedLayers = []
336
596
  resetZoomPan()
597
+ DiagnosticLog.shared.info("[Canvas] scope → \(canvasScopeSummary)")
337
598
  return
338
599
  }
339
600
  if let pos = order.firstIndex(where: { $0.index == current }) {
@@ -347,6 +608,15 @@ final class ScreenMapEditorState: ObservableObject {
347
608
  }
348
609
  selectedLayers = []
349
610
  resetZoomPan()
611
+ DiagnosticLog.shared.info("[Canvas] scope → \(canvasScopeSummary)")
612
+ }
613
+
614
+ var canvasScopeSummary: String {
615
+ guard let focusedDisplayIndex,
616
+ let display = displays.first(where: { $0.index == focusedDisplayIndex }) else {
617
+ return "all displays"
618
+ }
619
+ return "display \(spatialNumber(for: focusedDisplayIndex)) · \(display.label)"
350
620
  }
351
621
 
352
622
  /// Number of windows with pending edits (position or size)
@@ -429,12 +699,12 @@ final class ScreenMapEditorState: ObservableObject {
429
699
  /// Move a window to a different layer
430
700
  func reassignLayer(windowId: UInt32, toLayer: Int, fitToAvailable: Bool) {
431
701
  guard let idx = windows.firstIndex(where: { $0.id == windowId }) else { return }
432
- let oldFrame = windows[idx].editedFrame
702
+ let oldFrame = windows[idx].virtualFrame
433
703
  windows[idx].layer = toLayer
434
704
  if fitToAvailable {
435
705
  fitWindowIntoLayer(at: idx)
436
706
  }
437
- let newFrame = windows[idx].editedFrame
707
+ let newFrame = windows[idx].virtualFrame
438
708
  if oldFrame != newFrame {
439
709
  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
710
  }
@@ -444,10 +714,10 @@ final class ScreenMapEditorState: ObservableObject {
444
714
  func fitWindowIntoLayer(at idx: Int) {
445
715
  let win = windows[idx]
446
716
  let siblings = windows.enumerated().filter { $0.offset != idx && $0.element.layer == win.layer }
447
- let siblingFrames = siblings.map(\.element.editedFrame)
717
+ let siblingFrames = siblings.map(\.element.virtualFrame)
448
718
  let screenRect = CGRect(origin: .zero, size: screenSize)
449
- if let fitted = fitRect(win.editedFrame, avoiding: siblingFrames, within: screenRect) {
450
- windows[idx].editedFrame = fitted
719
+ if let fitted = fitRect(win.virtualFrame, avoiding: siblingFrames, within: screenRect) {
720
+ syncLayoutFrame(at: idx, to: fitted)
451
721
  }
452
722
  }
453
723
 
@@ -508,7 +778,7 @@ final class ScreenMapEditorState: ObservableObject {
508
778
 
509
779
  if indices.count == 1 {
510
780
  let frame = CGRect(x: visible.origin.x, y: axTop, width: visible.width, height: visible.height)
511
- windows[indices[0]].editedFrame = frame
781
+ syncLayoutFrame(at: indices[0], to: frame)
512
782
  totalTiled += 1
513
783
  continue
514
784
  }
@@ -529,7 +799,7 @@ final class ScreenMapEditorState: ObservableObject {
529
799
  let x0 = baseX + (col * totalW) / cols
530
800
  let x1 = baseX + ((col + 1) * totalW) / cols
531
801
  let frame = CGRect(x: CGFloat(x0), y: CGFloat(y0), width: CGFloat(x1 - x0), height: CGFloat(y1 - y0))
532
- windows[indices[slotIdx]].editedFrame = frame
802
+ syncLayoutFrame(at: indices[slotIdx], to: frame)
533
803
  slotIdx += 1
534
804
  }
535
805
  }
@@ -568,7 +838,7 @@ final class ScreenMapEditorState: ObservableObject {
568
838
  for col in 0..<cols {
569
839
  guard slotIdx < indices.count else { break }
570
840
  let idx = indices[slotIdx]
571
- let orig = windows[idx].originalFrame
841
+ let orig = windows[idx].virtualFrame
572
842
 
573
843
  let cellX = visible.origin.x + CGFloat(col) * colW + padding
574
844
  let cellY = axY + padding
@@ -586,7 +856,7 @@ final class ScreenMapEditorState: ObservableObject {
586
856
  let x = cellX + (cellW - fitW) / 2
587
857
  let y = cellY + (cellH - fitH) / 2
588
858
 
589
- windows[idx].editedFrame = CGRect(x: x, y: y, width: fitW, height: fitH)
859
+ syncLayoutFrame(at: idx, to: CGRect(x: x, y: y, width: fitW, height: fitH))
590
860
  slotIdx += 1
591
861
  }
592
862
  }
@@ -621,8 +891,8 @@ final class ScreenMapEditorState: ObservableObject {
621
891
  for j in (i + 1)..<indices.count {
622
892
  let idxA = indices[i]
623
893
  let idxB = indices[j]
624
- let a = windows[idxA].editedFrame
625
- let b = windows[idxB].editedFrame
894
+ let a = windows[idxA].virtualFrame
895
+ let b = windows[idxB].virtualFrame
626
896
  guard a.intersects(b) else { continue }
627
897
  hadOverlap = true
628
898
 
@@ -631,22 +901,30 @@ final class ScreenMapEditorState: ObservableObject {
631
901
 
632
902
  if overlapW < overlapH {
633
903
  let push = (overlapW / 2).rounded(.up) + 1
904
+ var newA = a
905
+ var newB = b
634
906
  if a.midX <= b.midX {
635
- windows[idxA].editedFrame.origin.x -= push
636
- windows[idxB].editedFrame.origin.x += push
907
+ newA.origin.x -= push
908
+ newB.origin.x += push
637
909
  } else {
638
- windows[idxA].editedFrame.origin.x += push
639
- windows[idxB].editedFrame.origin.x -= push
910
+ newA.origin.x += push
911
+ newB.origin.x -= push
640
912
  }
913
+ syncLayoutFrame(at: idxA, to: newA)
914
+ syncLayoutFrame(at: idxB, to: newB)
641
915
  } else {
642
916
  let push = (overlapH / 2).rounded(.up) + 1
917
+ var newA = a
918
+ var newB = b
643
919
  if a.midY <= b.midY {
644
- windows[idxA].editedFrame.origin.y -= push
645
- windows[idxB].editedFrame.origin.y += push
920
+ newA.origin.y -= push
921
+ newB.origin.y += push
646
922
  } else {
647
- windows[idxA].editedFrame.origin.y += push
648
- windows[idxB].editedFrame.origin.y -= push
923
+ newA.origin.y += push
924
+ newB.origin.y -= push
649
925
  }
926
+ syncLayoutFrame(at: idxA, to: newA)
927
+ syncLayoutFrame(at: idxB, to: newB)
650
928
  }
651
929
  affected.insert(idxA)
652
930
  affected.insert(idxB)
@@ -661,12 +939,12 @@ final class ScreenMapEditorState: ObservableObject {
661
939
  }
662
940
 
663
941
  private func clampToScreen(at idx: Int, bounds: CGRect) {
664
- var f = windows[idx].editedFrame
942
+ var f = windows[idx].virtualFrame
665
943
  if f.minX < bounds.minX { f.origin.x = bounds.minX }
666
944
  if f.minY < bounds.minY { f.origin.y = bounds.minY }
667
945
  if f.maxX > bounds.maxX { f.origin.x = bounds.maxX - f.width }
668
946
  if f.maxY > bounds.maxY { f.origin.y = bounds.maxY - f.height }
669
- windows[idx].editedFrame = f
947
+ syncLayoutFrame(at: idx, to: f)
670
948
  }
671
949
 
672
950
  /// Grow each window outward until it hits a neighbor or screen edge
@@ -691,7 +969,7 @@ final class ScreenMapEditorState: ObservableObject {
691
969
  width: screen.frame.width, height: screen.frame.height)
692
970
 
693
971
  // Snapshot original positions for neighbor detection
694
- let origFrames = indices.map { windows[$0].editedFrame }
972
+ let origFrames = indices.map { windows[$0].virtualFrame }
695
973
 
696
974
  for (i, idx) in indices.enumerated() {
697
975
  let me = origFrames[i]
@@ -726,8 +1004,8 @@ final class ScreenMapEditorState: ObservableObject {
726
1004
  }
727
1005
 
728
1006
  let newFrame = CGRect(x: left, y: top, width: right - left, height: bottom - top)
729
- if newFrame != windows[idx].editedFrame {
730
- windows[idx].editedFrame = newFrame
1007
+ if newFrame != windows[idx].virtualFrame {
1008
+ syncLayoutFrame(at: idx, to: newFrame)
731
1009
  totalAffected += 1
732
1010
  }
733
1011
  }
@@ -767,7 +1045,7 @@ final class ScreenMapEditorState: ObservableObject {
767
1045
  guard slots.count == indices.count else { continue }
768
1046
 
769
1047
  for (i, idx) in indices.enumerated() {
770
- windows[idx].editedFrame = slots[i]
1048
+ syncLayoutFrame(at: idx, to: slots[i])
771
1049
  }
772
1050
  totalDistributed += indices.count
773
1051
  }
@@ -777,7 +1055,7 @@ final class ScreenMapEditorState: ObservableObject {
777
1055
  /// Reset all edited frames back to original
778
1056
  func discardEdits() {
779
1057
  for i in windows.indices {
780
- windows[i].editedFrame = windows[i].originalFrame
1058
+ resetLayoutFrameToOriginal(at: i)
781
1059
  }
782
1060
  }
783
1061
 
@@ -813,15 +1091,15 @@ final class ScreenMapEditorState: ObservableObject {
813
1091
  let siblings = windows.enumerated().filter {
814
1092
  $0.offset != idx && $0.element.layer == targetLayer &&
815
1093
  (dIdx == nil || $0.element.displayIndex == dIdx!)
816
- }.map(\.element.editedFrame)
1094
+ }.map(\.element.virtualFrame)
817
1095
 
818
- let collisions = siblings.filter { $0.intersects(win.editedFrame) }
1096
+ let collisions = siblings.filter { $0.intersects(win.virtualFrame) }
819
1097
  if collisions.isEmpty {
820
1098
  windows[idx].layer = targetLayer
821
1099
  break
822
1100
  }
823
- if let fitted = fitRect(win.editedFrame, avoiding: siblings, within: screenRect) {
824
- windows[idx].editedFrame = fitted
1101
+ if let fitted = fitRect(win.virtualFrame, avoiding: siblings, within: screenRect) {
1102
+ syncLayoutFrame(at: idx, to: fitted)
825
1103
  windows[idx].layer = targetLayer
826
1104
  break
827
1105
  }
@@ -921,8 +1199,8 @@ final class ScreenMapActionLog {
921
1199
  WindowSnapshot(
922
1200
  wid: win.id, app: win.app, title: win.title,
923
1201
  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)
1202
+ x: Int(win.virtualFrame.origin.x), y: Int(win.virtualFrame.origin.y),
1203
+ w: Int(win.virtualFrame.width), h: Int(win.virtualFrame.height)
926
1204
  ),
927
1205
  layer: win.layer
928
1206
  )
@@ -1074,6 +1352,8 @@ final class ScreenMapActionLog {
1074
1352
  final class ScreenMapController: ObservableObject {
1075
1353
  @Published var editor: ScreenMapEditorState?
1076
1354
  @Published var selectedWindowIds: Set<UInt32> = []
1355
+ @Published var windowSets: [ScreenMapWindowSet] = []
1356
+ @Published var activeWindowSetID: UUID? = nil
1077
1357
  @Published var flashMessage: String? = nil
1078
1358
  @Published var previewCaptures: [UInt32: NSImage] = [:]
1079
1359
  @Published var savedPositions: [UInt32: (pid: Int32, frame: WindowFrame)]? = nil
@@ -1095,9 +1375,15 @@ final class ScreenMapController: ObservableObject {
1095
1375
 
1096
1376
  func isSelected(_ id: UInt32) -> Bool { selectedWindowIds.contains(id) }
1097
1377
 
1378
+ var activeWindowSet: ScreenMapWindowSet? {
1379
+ guard let activeWindowSetID else { return nil }
1380
+ return windowSets.first(where: { $0.id == activeWindowSetID })
1381
+ }
1382
+
1098
1383
  func selectSingle(_ id: UInt32) {
1099
1384
  navigateToWindowDisplay(id)
1100
1385
  selectedWindowIds = [id]
1386
+ activeWindowSetID = nil
1101
1387
  }
1102
1388
 
1103
1389
  func toggleSelection(_ id: UInt32) {
@@ -1106,10 +1392,12 @@ final class ScreenMapController: ObservableObject {
1106
1392
  } else {
1107
1393
  selectedWindowIds.insert(id)
1108
1394
  }
1395
+ activeWindowSetID = nil
1109
1396
  }
1110
1397
 
1111
1398
  func clearSelection() {
1112
1399
  selectedWindowIds = []
1400
+ activeWindowSetID = nil
1113
1401
  }
1114
1402
 
1115
1403
  func selectNextWindow() {
@@ -1123,6 +1411,7 @@ final class ScreenMapController: ObservableObject {
1123
1411
  } else {
1124
1412
  selectedWindowIds = [wins[0].id]
1125
1413
  }
1414
+ activeWindowSetID = nil
1126
1415
  objectWillChange.send()
1127
1416
  }
1128
1417
 
@@ -1137,6 +1426,7 @@ final class ScreenMapController: ObservableObject {
1137
1426
  } else {
1138
1427
  selectedWindowIds = [wins[wins.count - 1].id]
1139
1428
  }
1429
+ activeWindowSetID = nil
1140
1430
  objectWillChange.send()
1141
1431
  }
1142
1432
 
@@ -1144,10 +1434,58 @@ final class ScreenMapController: ObservableObject {
1144
1434
  guard let ed = editor else { return }
1145
1435
  let allIds = Set(ed.focusedVisibleWindows.map(\.id))
1146
1436
  selectedWindowIds = allIds
1437
+ activeWindowSetID = nil
1147
1438
  flash("Selected \(allIds.count) windows")
1148
1439
  objectWillChange.send()
1149
1440
  }
1150
1441
 
1442
+ // MARK: - Window Sets
1443
+
1444
+ func createWindowSetFromSelection() {
1445
+ let ids = selectedWindowIds
1446
+ guard !ids.isEmpty else {
1447
+ flash("Select windows first")
1448
+ return
1449
+ }
1450
+ if let existing = windowSets.first(where: { $0.windowIds == ids }) {
1451
+ activeWindowSetID = existing.id
1452
+ flash("\(existing.name) already saved")
1453
+ return
1454
+ }
1455
+
1456
+ let set = ScreenMapWindowSet(name: "Set \(windowSets.count + 1)", windowIds: ids)
1457
+ windowSets.append(set)
1458
+ activeWindowSetID = set.id
1459
+ flash("Saved \(set.name)")
1460
+ objectWillChange.send()
1461
+ }
1462
+
1463
+ func focusWindowSet(_ set: ScreenMapWindowSet) {
1464
+ guard let ed = editor else { return }
1465
+ let liveIds = Set(ed.windows(matching: set.windowIds).map(\.id))
1466
+ guard !liveIds.isEmpty else {
1467
+ flash("\(set.name) is empty")
1468
+ return
1469
+ }
1470
+
1471
+ selectedWindowIds = liveIds
1472
+ activeWindowSetID = set.id
1473
+ if let rect = ed.rectForWindowIDs(liveIds) {
1474
+ focusCanvas(on: rect.insetBy(dx: -80, dy: -80), zoomToFit: true)
1475
+ }
1476
+ flash(set.name)
1477
+ objectWillChange.send()
1478
+ }
1479
+
1480
+ func deleteWindowSet(_ set: ScreenMapWindowSet) {
1481
+ windowSets.removeAll { $0.id == set.id }
1482
+ if activeWindowSetID == set.id {
1483
+ activeWindowSetID = nil
1484
+ }
1485
+ flash("Removed \(set.name)")
1486
+ objectWillChange.send()
1487
+ }
1488
+
1151
1489
  // MARK: - Search
1152
1490
 
1153
1491
  var searchHighlightedWindowId: UInt32? {
@@ -1205,14 +1543,9 @@ final class ScreenMapController: ObservableObject {
1205
1543
  let win = ed.windows.first(where: { $0.id == windowId }) else { return }
1206
1544
  if isSearchActive { closeSearch() }
1207
1545
  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
- }
1546
+ // Raise the target window and let it stay on top (don't re-activate Lattices)
1547
+ WindowTiler.focusWindow(wid: win.id, pid: win.pid)
1548
+ WindowTiler.highlightWindowById(wid: win.id)
1216
1549
  }
1217
1550
 
1218
1551
  func focusSelectedWindowOnScreen() {
@@ -1234,6 +1567,7 @@ final class ScreenMapController: ObservableObject {
1234
1567
  // MARK: - Enter
1235
1568
 
1236
1569
  func enter() {
1570
+ let existingSets = windowSets
1237
1571
  guard let windowList = CGWindowListCopyWindowInfo(
1238
1572
  [.optionAll, .excludeDesktopElements], kCGNullWindowID
1239
1573
  ) as? [[String: Any]] else { return }
@@ -1372,7 +1706,7 @@ final class ScreenMapController: ObservableObject {
1372
1706
 
1373
1707
  mapWindows.append(ScreenMapWindowEntry(
1374
1708
  id: win.wid, pid: win.pid, app: win.app, title: win.title,
1375
- originalFrame: win.frame, editedFrame: win.frame,
1709
+ originalFrame: win.frame, editedFrame: win.frame, virtualFrame: win.frame,
1376
1710
  zIndex: i, layer: assignedLayer, displayIndex: win.displayIndex,
1377
1711
  isOnScreen: win.isOnScreen,
1378
1712
  latticesSession: latticesSession ?? ctx?.session,
@@ -1415,20 +1749,180 @@ final class ScreenMapController: ObservableObject {
1415
1749
  }
1416
1750
 
1417
1751
  editor = newEditor
1752
+ let liveIds = Set(mapWindows.map(\.id))
1753
+ windowSets = existingSets.compactMap { set in
1754
+ let filtered = set.windowIds.intersection(liveIds)
1755
+ guard !filtered.isEmpty else { return nil }
1756
+ return ScreenMapWindowSet(id: set.id, name: set.name, windowIds: filtered)
1757
+ }
1758
+ if let activeWindowSetID, !windowSets.contains(where: { $0.id == activeWindowSetID }) {
1759
+ self.activeWindowSetID = nil
1760
+ }
1418
1761
  selectedWindowIds = []
1762
+ focusViewportPreset(.main, flashView: false)
1419
1763
  }
1420
1764
 
1421
1765
  /// Re-snapshot, preserving display/layer context
1422
1766
  func refresh() {
1423
1767
  let savedDisplay = editor?.focusedDisplayIndex
1424
1768
  let savedLayers = editor?.selectedLayers ?? []
1769
+ let savedViewportPreset = editor?.activeViewportPreset
1425
1770
  enter()
1426
1771
  if let ed = editor {
1427
1772
  ed.focusedDisplayIndex = savedDisplay
1428
1773
  ed.selectedLayers = savedLayers
1774
+ if let savedViewportPreset {
1775
+ focusViewportPreset(savedViewportPreset, flashView: false)
1776
+ }
1777
+ }
1778
+ }
1779
+
1780
+ // MARK: - Canvas Navigation
1781
+
1782
+ func recenterViewport(at worldPoint: CGPoint) {
1783
+ editor?.activeViewportPreset = nil
1784
+ queueCanvasNavigation(centeredOn: worldPoint, rect: nil, zoomToFit: false)
1785
+ }
1786
+
1787
+ func focusCanvas(on rect: CGRect, focusDisplay displayIndex: Int? = nil, resetDisplayFocus: Bool = false, zoomToFit: Bool = true) {
1788
+ guard let ed = editor else { return }
1789
+
1790
+ if let displayIndex {
1791
+ ed.focusDisplay(displayIndex)
1792
+ } else if resetDisplayFocus {
1793
+ ed.focusDisplay(nil)
1794
+ }
1795
+ ed.activeViewportPreset = nil
1796
+
1797
+ queueCanvasNavigation(
1798
+ centeredOn: CGPoint(x: rect.midX, y: rect.midY),
1799
+ rect: rect,
1800
+ zoomToFit: zoomToFit
1801
+ )
1802
+ }
1803
+
1804
+ func focusViewportPreset(_ preset: ScreenMapViewportPreset, flashView: Bool = true) {
1805
+ guard let ed = editor else { return }
1806
+ ed.activeViewportPreset = preset
1807
+ let rect = ed.viewportRect(for: preset)
1808
+ DiagnosticLog.shared.info("[Canvas] preset → \(preset.title)")
1809
+ queueCanvasNavigation(
1810
+ centeredOn: CGPoint(x: rect.midX, y: rect.midY),
1811
+ rect: rect,
1812
+ zoomToFit: true
1813
+ )
1814
+ if flashView {
1815
+ flash(preset.title)
1816
+ }
1817
+ }
1818
+
1819
+ func jumpToCanvasRegion(_ region: ScreenMapCanvasRegion) {
1820
+ DiagnosticLog.shared.info("[Canvas] jump → \(region.title) · \(region.subtitle)")
1821
+ switch region.kind {
1822
+ case .overview:
1823
+ focusViewportPreset(.overview)
1824
+ case .display:
1825
+ focusCanvas(on: region.rect, focusDisplay: region.displayIndex, zoomToFit: true)
1826
+ case .layer:
1827
+ focusCanvas(on: region.rect, zoomToFit: true)
1828
+ }
1829
+ }
1830
+
1831
+ func applyPendingCanvasNavigationIfNeeded() {
1832
+ guard let ed = editor, let target = ed.pendingCanvasNavigation else { return }
1833
+ ed.pendingCanvasNavigation = nil
1834
+ let shouldLogView = target.rect != nil || target.zoomToFit
1835
+
1836
+ var targetZoom: CGFloat? = nil
1837
+ if target.zoomToFit,
1838
+ let rect = target.rect,
1839
+ ed.fitScale > 0,
1840
+ ed.viewportSize.width > 0,
1841
+ ed.viewportSize.height > 0 {
1842
+ let paddedW = max(rect.width, 120)
1843
+ let paddedH = max(rect.height, 80)
1844
+ let desiredScale = min(
1845
+ (ed.viewportSize.width * 0.84) / paddedW,
1846
+ (ed.viewportSize.height * 0.84) / paddedH
1847
+ )
1848
+ targetZoom = max(
1849
+ ScreenMapEditorState.minZoom,
1850
+ min(ScreenMapEditorState.maxZoom, desiredScale / ed.fitScale)
1851
+ )
1852
+ }
1853
+
1854
+ setViewport(centeredOn: target.center, zoomLevel: targetZoom)
1855
+ if shouldLogView {
1856
+ let viewport = ed.viewportWorldRect
1857
+ DiagnosticLog.shared.info(
1858
+ "[Canvas] view → \(ed.canvasScopeSummary) center=(\(Int(viewport.midX)),\(Int(viewport.midY))) viewport=\(Int(viewport.width))×\(Int(viewport.height)) zoom=\(Int(ed.zoomLevel * 100))%"
1859
+ )
1429
1860
  }
1430
1861
  }
1431
1862
 
1863
+ private func queueCanvasNavigation(centeredOn targetCenter: CGPoint, rect: CGRect?, zoomToFit: Bool) {
1864
+ guard let ed = editor else { return }
1865
+ ed.pendingCanvasNavigation = ScreenMapCanvasNavigationTarget(
1866
+ center: targetCenter,
1867
+ rect: rect,
1868
+ zoomToFit: zoomToFit
1869
+ )
1870
+ ed.canvasNavigationRevision &+= 1
1871
+ ed.objectWillChange.send()
1872
+ objectWillChange.send()
1873
+ }
1874
+
1875
+ private func setViewport(centeredOn targetCenter: CGPoint, zoomLevel targetZoom: CGFloat?) {
1876
+ guard let ed = editor else { return }
1877
+
1878
+ if let targetZoom {
1879
+ ed.zoomLevel = targetZoom
1880
+ ed.scale = ed.fitScale * targetZoom
1881
+ }
1882
+
1883
+ let effectiveScale = max(ed.fitScale * ed.zoomLevel, 0.0001)
1884
+ let viewport = CGSize(
1885
+ width: max(ed.viewportSize.width, 1),
1886
+ height: max(ed.viewportSize.height, 1)
1887
+ )
1888
+ let viewportWorld = CGSize(
1889
+ width: viewport.width / effectiveScale,
1890
+ height: viewport.height / effectiveScale
1891
+ )
1892
+ let world = ed.canvasWorldBounds
1893
+
1894
+ let clampedCenter = CGPoint(
1895
+ x: clampedViewportCenter(
1896
+ target: targetCenter.x,
1897
+ minEdge: world.minX,
1898
+ maxEdge: world.maxX,
1899
+ viewportExtent: viewportWorld.width
1900
+ ),
1901
+ y: clampedViewportCenter(
1902
+ target: targetCenter.y,
1903
+ minEdge: world.minY,
1904
+ maxEdge: world.maxY,
1905
+ viewportExtent: viewportWorld.height
1906
+ )
1907
+ )
1908
+
1909
+ ed.panOffset = CGPoint(
1910
+ x: viewport.width / 2 - ed.mapOrigin.x - (clampedCenter.x - ed.bboxOrigin.x) * effectiveScale,
1911
+ y: viewport.height / 2 - ed.mapOrigin.y - (clampedCenter.y - ed.bboxOrigin.y) * effectiveScale
1912
+ )
1913
+ ed.objectWillChange.send()
1914
+ objectWillChange.send()
1915
+ }
1916
+
1917
+ private func clampedViewportCenter(target: CGFloat, minEdge: CGFloat, maxEdge: CGFloat, viewportExtent: CGFloat) -> CGFloat {
1918
+ if maxEdge - minEdge <= viewportExtent {
1919
+ return (minEdge + maxEdge) / 2
1920
+ }
1921
+ let minCenter = minEdge + viewportExtent / 2
1922
+ let maxCenter = maxEdge - viewportExtent / 2
1923
+ return min(max(target, minCenter), maxCenter)
1924
+ }
1925
+
1432
1926
  // MARK: - Key Handler
1433
1927
 
1434
1928
  func handleKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
@@ -1560,6 +2054,7 @@ final class ScreenMapController: ObservableObject {
1560
2054
  case 4: // h → previous display
1561
2055
  if let ed = editor, ed.displays.count > 1 {
1562
2056
  ed.cyclePreviousDisplay()
2057
+ focusViewportPreset(ed.activeViewportPreset ?? .main, flashView: false)
1563
2058
  let label = ed.focusedDisplay?.label ?? "All displays"
1564
2059
  flash(label)
1565
2060
  objectWillChange.send()
@@ -1569,6 +2064,7 @@ final class ScreenMapController: ObservableObject {
1569
2064
  case 37: // l → next display
1570
2065
  if let ed = editor, ed.displays.count > 1 {
1571
2066
  ed.cycleNextDisplay()
2067
+ focusViewportPreset(ed.activeViewportPreset ?? .main, flashView: false)
1572
2068
  let label = ed.focusedDisplay?.label ?? "All displays"
1573
2069
  flash(label)
1574
2070
  objectWillChange.send()
@@ -1603,6 +2099,22 @@ final class ScreenMapController: ObservableObject {
1603
2099
  }
1604
2100
  return true
1605
2101
 
2102
+ case 18: // 1 → main viewport
2103
+ focusViewportPreset(.main)
2104
+ return true
2105
+
2106
+ case 19: // 2 → top-right viewport
2107
+ focusViewportPreset(.topRight)
2108
+ return true
2109
+
2110
+ case 20: // 3 → bottom-left viewport
2111
+ focusViewportPreset(.bottomLeft)
2112
+ return true
2113
+
2114
+ case 21: // 4 → bottom-right viewport
2115
+ focusViewportPreset(.bottomRight)
2116
+ return true
2117
+
1606
2118
  case 33: // [ → move to previous layer
1607
2119
  if let ed = editor {
1608
2120
  for wid in selectedWindowIds {
@@ -1650,15 +2162,22 @@ final class ScreenMapController: ObservableObject {
1650
2162
  distributeVisible()
1651
2163
  return true
1652
2164
 
1653
- case 15: // r → reset zoom/pan
1654
- editor?.resetZoomPan()
1655
- flash("Fit all")
2165
+ case 15: // r → overview
2166
+ focusViewportPreset(.overview)
1656
2167
  return true
1657
2168
 
1658
2169
  case 5: // g → grow to fill
1659
2170
  fitAvailableSpace()
1660
2171
  return true
1661
2172
 
2173
+ case 32: // u → save current selection as set
2174
+ createWindowSetFromSelection()
2175
+ return true
2176
+
2177
+ case 46: // m → materialize current viewport
2178
+ materializeViewport()
2179
+ return true
2180
+
1662
2181
  case 3: // f → flatten
1663
2182
  flattenLayers()
1664
2183
  return true
@@ -1689,14 +2208,14 @@ final class ScreenMapController: ObservableObject {
1689
2208
  }
1690
2209
  return true
1691
2210
 
1692
- case 29: // 0 → fit all (secondary)
1693
- editor?.resetZoomPan()
1694
- flash("Fit all")
2211
+ case 29: // 0 → overview (secondary)
2212
+ focusViewportPreset(.overview)
1695
2213
  return true
1696
2214
 
1697
2215
  case 123: // ← previous display (secondary)
1698
2216
  if let ed = editor, ed.displays.count > 1 {
1699
2217
  ed.cyclePreviousDisplay()
2218
+ focusViewportPreset(ed.activeViewportPreset ?? .main, flashView: false)
1700
2219
  let label = ed.focusedDisplay?.label ?? "All displays"
1701
2220
  flash(label)
1702
2221
  objectWillChange.send()
@@ -1706,6 +2225,7 @@ final class ScreenMapController: ObservableObject {
1706
2225
  case 124: // → next display (secondary)
1707
2226
  if let ed = editor, ed.displays.count > 1 {
1708
2227
  ed.cycleNextDisplay()
2228
+ focusViewportPreset(ed.activeViewportPreset ?? .main, flashView: false)
1709
2229
  let label = ed.focusedDisplay?.label ?? "All displays"
1710
2230
  flash(label)
1711
2231
  objectWillChange.send()
@@ -1729,11 +2249,11 @@ final class ScreenMapController: ObservableObject {
1729
2249
 
1730
2250
  // MARK: - Actions
1731
2251
 
1732
- func applyEdits() {
2252
+ func applyEdits(showFlash: Bool = true) {
1733
2253
  guard let ed = editor else { return }
1734
2254
  let pendingEdits = ed.windows.filter(\.hasEdits)
1735
2255
  guard !pendingEdits.isEmpty else {
1736
- flash("No changes to apply")
2256
+ if showFlash { flash("No changes to apply") }
1737
2257
  return
1738
2258
  }
1739
2259
 
@@ -1772,8 +2292,10 @@ final class ScreenMapController: ObservableObject {
1772
2292
  actionLog.verify()
1773
2293
  }
1774
2294
 
1775
- let noun = pendingEdits.count == 1 ? "edit" : "edits"
1776
- flash("Applied \(pendingEdits.count) \(noun)")
2295
+ if showFlash {
2296
+ let noun = pendingEdits.count == 1 ? "edit" : "edits"
2297
+ flash("Applied \(pendingEdits.count) \(noun)")
2298
+ }
1777
2299
  }
1778
2300
 
1779
2301
  func applyEditsFromButton() {
@@ -1781,6 +2303,129 @@ final class ScreenMapController: ObservableObject {
1781
2303
  applyEdits()
1782
2304
  }
1783
2305
 
2306
+ private func projectionTargetBounds(for editor: ScreenMapEditorState) -> CGRect {
2307
+ if let focused = editor.focusedDisplay {
2308
+ return focused.cgRect
2309
+ }
2310
+ guard let first = editor.displays.first else {
2311
+ return editor.viewportWorldRect
2312
+ }
2313
+ return editor.displays.dropFirst().reduce(first.cgRect) { $0.union($1.cgRect) }
2314
+ }
2315
+
2316
+ private func parkingBounds(for editor: ScreenMapEditorState) -> CGRect {
2317
+ let target = projectionTargetBounds(for: editor)
2318
+ let union = editor.displays.dropFirst().reduce(editor.displays.first?.cgRect ?? target) { $0.union($1.cgRect) }
2319
+ let width: CGFloat = 420
2320
+ return CGRect(
2321
+ x: union.maxX + 120,
2322
+ y: union.minY + 40,
2323
+ width: width,
2324
+ height: max(union.height - 80, 320)
2325
+ )
2326
+ }
2327
+
2328
+ private func projectedViewportFrames(
2329
+ for windows: [ScreenMapWindowEntry],
2330
+ source: CGRect,
2331
+ target: CGRect
2332
+ ) -> [UInt32: CGRect] {
2333
+ guard source.width > 0, source.height > 0, target.width > 0, target.height > 0 else { return [:] }
2334
+
2335
+ let scale = min(target.width / source.width, target.height / source.height)
2336
+ let projectedSize = CGSize(width: source.width * scale, height: source.height * scale)
2337
+ let projectedOrigin = CGPoint(
2338
+ x: target.minX + (target.width - projectedSize.width) / 2,
2339
+ y: target.minY + (target.height - projectedSize.height) / 2
2340
+ )
2341
+
2342
+ var frames: [UInt32: CGRect] = [:]
2343
+ for win in windows {
2344
+ let frame = win.virtualFrame
2345
+ let mapped = CGRect(
2346
+ x: projectedOrigin.x + (frame.minX - source.minX) * scale,
2347
+ y: projectedOrigin.y + (frame.minY - source.minY) * scale,
2348
+ width: max(frame.width * scale, 180),
2349
+ height: max(frame.height * scale, 100)
2350
+ )
2351
+ frames[win.id] = mapped
2352
+ }
2353
+ return frames
2354
+ }
2355
+
2356
+ private func parkedViewportFrames(
2357
+ for windows: [ScreenMapWindowEntry],
2358
+ parkingBounds: CGRect
2359
+ ) -> [UInt32: CGRect] {
2360
+ guard !windows.isEmpty else { return [:] }
2361
+
2362
+ let spacing: CGFloat = 20
2363
+ let maxCardWidth = max(min(parkingBounds.width - spacing * 2, 320), 180)
2364
+ var cursor = CGPoint(x: parkingBounds.minX, y: parkingBounds.minY)
2365
+ var columnX = parkingBounds.minX
2366
+ var frames: [UInt32: CGRect] = [:]
2367
+
2368
+ for win in windows.sorted(by: { $0.zIndex < $1.zIndex }) {
2369
+ let aspect = max(win.virtualFrame.width / max(win.virtualFrame.height, 1), 0.6)
2370
+ let width = min(max(win.virtualFrame.width * 0.55, 180), maxCardWidth)
2371
+ let height = min(max(width / aspect, 100), 220)
2372
+
2373
+ if cursor.y + height > parkingBounds.maxY {
2374
+ columnX += maxCardWidth + spacing
2375
+ cursor = CGPoint(x: columnX, y: parkingBounds.minY)
2376
+ }
2377
+
2378
+ frames[win.id] = CGRect(x: cursor.x, y: cursor.y, width: width, height: height)
2379
+ cursor.y += height + spacing
2380
+ }
2381
+
2382
+ return frames
2383
+ }
2384
+
2385
+ func materializeViewport() {
2386
+ guard let ed = editor else { return }
2387
+
2388
+ let scopedWindows = ed.focusedDisplayIndex != nil ? ed.focusedVisibleWindows : ed.visibleWindows
2389
+ guard !scopedWindows.isEmpty else {
2390
+ flash("No windows in scope")
2391
+ return
2392
+ }
2393
+
2394
+ let viewport = ed.viewportWorldRect
2395
+ let projectable = scopedWindows.filter { win in
2396
+ win.virtualFrame.intersects(viewport) ||
2397
+ viewport.contains(CGPoint(x: win.virtualFrame.midX, y: win.virtualFrame.midY))
2398
+ }
2399
+ guard !projectable.isEmpty else {
2400
+ flash("Viewport is empty")
2401
+ return
2402
+ }
2403
+
2404
+ let targetBounds = projectionTargetBounds(for: ed)
2405
+ let parkingBounds = parkingBounds(for: ed)
2406
+ let projectedFrames = projectedViewportFrames(
2407
+ for: projectable,
2408
+ source: viewport,
2409
+ target: targetBounds
2410
+ )
2411
+ let parkedWindows = scopedWindows.filter { projectedFrames[$0.id] == nil }
2412
+ let parkedFrames = parkedViewportFrames(for: parkedWindows, parkingBounds: parkingBounds)
2413
+
2414
+ for idx in ed.windows.indices {
2415
+ let wid = ed.windows[idx].id
2416
+ if let frame = projectedFrames[wid] ?? parkedFrames[wid] {
2417
+ ed.windows[idx].editedFrame = frame
2418
+ }
2419
+ }
2420
+
2421
+ DiagnosticLog.shared.info(
2422
+ "[Canvas] materialize → projected \(projectedFrames.count), parked \(parkedFrames.count), scope=\(ed.canvasScopeSummary)"
2423
+ )
2424
+ if ed.isPreviewing { endPreview() }
2425
+ applyEdits(showFlash: false)
2426
+ flash("Projected \(projectedFrames.count) windows")
2427
+ }
2428
+
1784
2429
  func exitScreenMap() {
1785
2430
  if editor?.isPreviewing == true { endPreview() }
1786
2431
  WindowBezel.shared.dismiss()
@@ -1906,7 +2551,7 @@ final class ScreenMapController: ObservableObject {
1906
2551
  let idx = ed.windows.firstIndex(where: { $0.id == winId }),
1907
2552
  let display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex })
1908
2553
  else { return }
1909
- ed.windows[idx].editedFrame = WindowTiler.tileFrame(for: position, inDisplay: display.cgRect)
2554
+ ed.syncLayoutFrame(at: idx, to: WindowTiler.tileFrame(for: position, inDisplay: display.cgRect))
1910
2555
  ed.isTilingMode = false
1911
2556
  ed.objectWillChange.send(); objectWillChange.send()
1912
2557
  flash(position.label)
@@ -1918,7 +2563,7 @@ final class ScreenMapController: ObservableObject {
1918
2563
  let idx = ed.windows.firstIndex(where: { $0.id == winId }),
1919
2564
  let display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex })
1920
2565
  else { return }
1921
- ed.windows[idx].editedFrame = WindowTiler.tileFrame(fractions: fractions, inDisplay: display.cgRect)
2566
+ ed.syncLayoutFrame(at: idx, to: WindowTiler.tileFrame(fractions: fractions, inDisplay: display.cgRect))
1922
2567
  ed.isTilingMode = false
1923
2568
  ed.objectWillChange.send(); objectWillChange.send()
1924
2569
  flash(label)
@@ -1962,7 +2607,7 @@ final class ScreenMapController: ObservableObject {
1962
2607
  display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex }) ?? ed.displays[0]
1963
2608
  }
1964
2609
 
1965
- ed.windows[idx].editedFrame = WindowTiler.tileFrame(fractions: fractions, inDisplay: display.cgRect)
2610
+ ed.syncLayoutFrame(at: idx, to: WindowTiler.tileFrame(fractions: fractions, inDisplay: display.cgRect))
1966
2611
  // Update display index if layout spec moves to a different display
1967
2612
  if let spatialNum = spec.display {
1968
2613
  let order = ed.spatialDisplayOrder
@@ -1973,6 +2618,7 @@ final class ScreenMapController: ObservableObject {
1973
2618
  app: moved.app, title: moved.title,
1974
2619
  originalFrame: moved.originalFrame,
1975
2620
  editedFrame: moved.editedFrame,
2621
+ virtualFrame: moved.virtualFrame,
1976
2622
  zIndex: moved.zIndex, layer: moved.layer,
1977
2623
  displayIndex: order[spatialNum - 1].index,
1978
2624
  isOnScreen: moved.isOnScreen,
@@ -2017,7 +2663,7 @@ final class ScreenMapController: ObservableObject {
2017
2663
  [.boundsIgnoreFraming, .bestResolution]
2018
2664
  ) {
2019
2665
  captures[win.id] = NSImage(cgImage: cgImage,
2020
- size: NSSize(width: win.editedFrame.width, height: win.editedFrame.height))
2666
+ size: NSSize(width: win.virtualFrame.width, height: win.virtualFrame.height))
2021
2667
  }
2022
2668
  }
2023
2669
  previewCaptures = captures