@lattices/cli 0.4.9 → 0.4.10

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.
@@ -26,9 +26,9 @@
26
26
  </dict>
27
27
  </array>
28
28
  <key>CFBundleVersion</key>
29
- <string>0.4.9</string>
29
+ <string>0.4.10</string>
30
30
  <key>CFBundleShortVersionString</key>
31
- <string>0.4.9</string>
31
+ <string>0.4.10</string>
32
32
  <key>LSMinimumSystemVersion</key>
33
33
  <string>13.0</string>
34
34
  <key>LSUIElement</key>
@@ -43,6 +43,7 @@ final class DesktopModel: ObservableObject {
43
43
  "storeuid",
44
44
  // Third-party helpers
45
45
  "CursorUIViewService",
46
+ "Codex Computer Use",
46
47
  "Electron Helper",
47
48
  "Google Chrome Helper",
48
49
  ]
@@ -144,7 +144,7 @@ enum CanvasDragMode {
144
144
 
145
145
  final class ScreenMapEditorState: ObservableObject {
146
146
  @Published var windows: [ScreenMapWindowEntry]
147
- @Published var selectedLayers: Set<Int> = [0] // empty = show all
147
+ @Published var selectedLayers: Set<Int> = [] // empty = show all
148
148
  @Published var draggingWindowId: UInt32? = nil
149
149
  var canvasDragMode: CanvasDragMode = .move
150
150
  var currentCursorMode: CanvasDragMode = .move
@@ -153,7 +153,7 @@ final class ScreenMapEditorState: ObservableObject {
153
153
  @Published var zoomLevel: CGFloat = 1.0 // 1.0 = fit-all
154
154
  @Published var panOffset: CGPoint = .zero // canvas-local pixels
155
155
  @Published var focusedDisplayIndex: Int? = nil // nil = all-displays view
156
- @Published var activeViewportPreset: ScreenMapViewportPreset? = .main
156
+ @Published var activeViewportPreset: ScreenMapViewportPreset? = .overview
157
157
  @Published var windowSearchQuery: String = ""
158
158
  @Published var isTilingMode: Bool = false
159
159
  var isSearching: Bool { !windowSearchQuery.isEmpty }
@@ -380,10 +380,11 @@ final class ScreenMapEditorState: ObservableObject {
380
380
  return "L\(layer)"
381
381
  }
382
382
 
383
- /// Windows visible for the active layer filter
383
+ /// Windows visible on the current desktop for the active layer filter.
384
384
  var visibleWindows: [ScreenMapWindowEntry] {
385
- guard !selectedLayers.isEmpty else { return windows }
386
- return windows.filter { selectedLayers.contains($0.layer) }
385
+ let onscreen = windows.filter(\.isOnScreen)
386
+ guard !selectedLayers.isEmpty else { return onscreen }
387
+ return onscreen.filter { selectedLayers.contains($0.layer) }
387
388
  }
388
389
 
389
390
  private var worldScopedDisplays: [DisplayGeometry] {
@@ -398,7 +399,7 @@ final class ScreenMapEditorState: ObservableObject {
398
399
 
399
400
  var canvasWorldBounds: CGRect {
400
401
  var rects = worldScopedDisplays.map(\.cgRect)
401
- rects.append(contentsOf: worldScopedWindows.map(\.virtualFrame))
402
+ rects.append(contentsOf: worldScopedWindows.filter(\.hasEdits).map(\.virtualFrame))
402
403
 
403
404
  if rects.isEmpty {
404
405
  return CGRect(origin: bboxOrigin, size: screenSize)
@@ -409,7 +410,7 @@ final class ScreenMapEditorState: ObservableObject {
409
410
  union = union.union(rect)
410
411
  }
411
412
 
412
- let pad: CGFloat = focusedDisplayIndex == nil ? 120 : 80
413
+ let pad: CGFloat = focusedDisplayIndex == nil ? 180 : 120
413
414
  return union.insetBy(dx: -pad, dy: -pad)
414
415
  }
415
416
 
@@ -1424,7 +1425,7 @@ final class ScreenMapController: ObservableObject {
1424
1425
 
1425
1426
  private func finalizeDisplayFocusChange(flashLabel: Bool) {
1426
1427
  guard let ed = editor else { return }
1427
- focusViewportPreset(ed.activeViewportPreset ?? .main, flashView: false)
1428
+ focusViewportPreset(ed.activeViewportPreset ?? .overview, flashView: false)
1428
1429
  if flashLabel {
1429
1430
  flash(ed.focusedDisplay?.label ?? "All displays")
1430
1431
  }
@@ -1454,6 +1455,7 @@ final class ScreenMapController: ObservableObject {
1454
1455
  guard newZoom != ed.zoomLevel else { return }
1455
1456
  ed.activeViewportPreset = nil
1456
1457
  ed.zoomLevel = newZoom
1458
+ ed.scale = ed.fitScale * newZoom
1457
1459
  objectWillChange.send()
1458
1460
  }
1459
1461
 
@@ -1677,7 +1679,7 @@ final class ScreenMapController: ObservableObject {
1677
1679
  func enter() {
1678
1680
  let existingSets = windowSets
1679
1681
  guard let windowList = CGWindowListCopyWindowInfo(
1680
- [.optionAll, .excludeDesktopElements], kCGNullWindowID
1682
+ [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
1681
1683
  ) as? [[String: Any]] else { return }
1682
1684
 
1683
1685
  struct CGWin {
@@ -1724,7 +1726,7 @@ final class ScreenMapController: ObservableObject {
1724
1726
  guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { continue }
1725
1727
  guard rect.width >= 100 && rect.height >= 50 else { continue }
1726
1728
  let app = info[kCGWindowOwnerName as String] as? String ?? ""
1727
- if app == "Lattices" || app == "lattices" || app == "AutoFill" { continue }
1729
+ if app == "Lattices" || app == "lattices" || app == "AutoFill" || app == "Codex Computer Use" { continue }
1728
1730
  let pid = info[kCGWindowOwnerPID as String] as? Int32 ?? 0
1729
1731
  let title = info[kCGWindowName as String] as? String ?? ""
1730
1732
  let dIdx = displayIndex(for: rect)
@@ -1844,15 +1846,14 @@ final class ScreenMapController: ObservableObject {
1844
1846
  }
1845
1847
  }
1846
1848
 
1847
- // Auto-focus the display where the mouse cursor is
1848
- if screens.count > 1 {
1849
+ // Start monitor-first: focus the display under the cursor, or the first display.
1850
+ if !displayGeometries.isEmpty {
1849
1851
  let mouseLocation = NSEvent.mouseLocation
1850
1852
  let mouseCG = CGPoint(x: mouseLocation.x, y: primaryHeight - mouseLocation.y)
1851
- for disp in displayGeometries {
1852
- if disp.cgRect.contains(mouseCG) {
1853
- newEditor.focusedDisplayIndex = disp.index
1854
- break
1855
- }
1853
+ if let display = displayGeometries.first(where: { $0.cgRect.contains(mouseCG) }) {
1854
+ newEditor.focusedDisplayIndex = display.index
1855
+ } else {
1856
+ newEditor.focusedDisplayIndex = displayGeometries[0].index
1856
1857
  }
1857
1858
  }
1858
1859
 
@@ -1867,7 +1868,12 @@ final class ScreenMapController: ObservableObject {
1867
1868
  self.activeWindowSetID = nil
1868
1869
  }
1869
1870
  selectedWindowIds = []
1870
- focusViewportPreset(.main, flashView: false)
1871
+ focusViewportPreset(.overview, flashView: false)
1872
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in
1873
+ guard let self,
1874
+ self.editor?.activeViewportPreset == .overview else { return }
1875
+ self.focusViewportPreset(.overview, flashView: false)
1876
+ }
1871
1877
  }
1872
1878
 
1873
1879
  /// Re-snapshot, preserving display/layer context
@@ -1914,10 +1920,15 @@ final class ScreenMapController: ObservableObject {
1914
1920
  ed.activeViewportPreset = preset
1915
1921
  let rect = ed.viewportRect(for: preset)
1916
1922
  DiagnosticLog.shared.info("[Canvas] preset → \(preset.title)")
1923
+ if preset == .overview {
1924
+ ed.zoomLevel = 1
1925
+ ed.scale = ed.fitScale
1926
+ ed.panOffset = .zero
1927
+ }
1917
1928
  queueCanvasNavigation(
1918
1929
  centeredOn: CGPoint(x: rect.midX, y: rect.midY),
1919
1930
  rect: rect,
1920
- zoomToFit: true
1931
+ zoomToFit: preset != .overview
1921
1932
  )
1922
1933
  if flashView {
1923
1934
  flash(preset.title)
@@ -1960,6 +1971,14 @@ final class ScreenMapController: ObservableObject {
1960
1971
  }
1961
1972
 
1962
1973
  setViewport(centeredOn: target.center, zoomLevel: targetZoom)
1974
+ if target.zoomToFit {
1975
+ ed.pendingCanvasNavigation = ScreenMapCanvasNavigationTarget(
1976
+ center: target.center,
1977
+ rect: nil,
1978
+ zoomToFit: false
1979
+ )
1980
+ ed.canvasNavigationRevision &+= 1
1981
+ }
1963
1982
  if shouldLogView {
1964
1983
  let viewport = ed.viewportWorldRect
1965
1984
  DiagnosticLog.shared.info(
@@ -5,8 +5,16 @@ import AppKit
5
5
 
6
6
  struct ScreenMapView: View {
7
7
  private static let canvasPadding: CGFloat = 8
8
- private static let canvasFitInsets = CGSize(width: 24, height: 16)
8
+ private static let canvasFitInsets = CGSize(width: 132, height: 112)
9
9
  private static let canvasViewportInsets = CGSize(width: 16, height: 16)
10
+ private static let canvasPanMinVisiblePixels: CGFloat = 1
11
+ private static let canvasFitScaleMultiplier: CGFloat = 0.66
12
+ private static let canvasStageMaxWidth: CGFloat = 980
13
+ private static let canvasStageMaxHeight: CGFloat = 560
14
+ private static let canvasStageMinAspect: CGFloat = 1.25
15
+ private static let canvasStageMaxAspect: CGFloat = 2.45
16
+ private static let sidebarWindowRowHeight: CGFloat = 28
17
+ private static let sidebarWindowRowStride: CGFloat = 30
10
18
 
11
19
  private struct CanvasMetrics: Equatable {
12
20
  let worldBounds: CGRect
@@ -39,7 +47,7 @@ struct ScreenMapView: View {
39
47
  fitScale = min(
40
48
  fitArea.width / max(worldBounds.width, 1),
41
49
  fitArea.height / max(worldBounds.height, 1)
42
- )
50
+ ) * ScreenMapView.canvasFitScaleMultiplier
43
51
  effectiveScale = fitScale * (editor?.zoomLevel ?? 1)
44
52
  mapSize = CGSize(width: worldBounds.width * effectiveScale, height: worldBounds.height * effectiveScale)
45
53
  centerOffset = CGPoint(
@@ -65,7 +73,7 @@ struct ScreenMapView: View {
65
73
  let panOffset: CGPoint
66
74
 
67
75
  init(editor: ScreenMapEditorState) {
68
- scale = editor.effectiveScale
76
+ scale = max(editor.fitScale * editor.zoomLevel, editor.effectiveScale)
69
77
  bboxOrigin = editor.bboxOrigin
70
78
  mapOrigin = editor.mapOrigin
71
79
  panOffset = editor.panOffset
@@ -174,8 +182,8 @@ struct ScreenMapView: View {
174
182
  @State private var canvasTransitionOffset: CGFloat = 0
175
183
  @State private var canvasTransitionOpacity: Double = 1.0
176
184
  @State private var isSpaceHeld: Bool = false
177
- @State private var spaceDragStart: NSPoint? = nil
178
- @State private var spaceDragPanStart: CGPoint = .zero
185
+ @State private var canvasPanStart: NSPoint? = nil
186
+ @State private var canvasPanStartOffset: CGPoint = .zero
179
187
  @State private var searchOverlayFrame: CGRect = .zero
180
188
 
181
189
  var body: some View {
@@ -416,13 +424,10 @@ struct ScreenMapView: View {
416
424
  let viewport = editor.viewportWorldRect
417
425
  let world = editor.canvasWorldBounds
418
426
  let scope = editor.focusedDisplay.map { "\(editor.spatialNumber(for: $0.index)). \($0.label)" } ?? "All Displays"
419
- let layers = editor.selectedLayers.isEmpty
420
- ? "All Layers"
421
- : editor.selectedLayers.sorted().map { editor.layerDisplayName(for: $0) }.joined(separator: ", ")
422
427
 
423
428
  return VStack(alignment: .leading, spacing: 4) {
424
429
  inspectorRow(label: "Scope", value: scope)
425
- inspectorRow(label: "Layers", value: layers)
430
+ inspectorRow(label: "Mode", value: "Desktop")
426
431
  inspectorRow(label: "View", value: "\(Int(viewport.midX)), \(Int(viewport.midY)) · \(Int(viewport.width))×\(Int(viewport.height))")
427
432
  inspectorRow(label: "World", value: "\(Int(world.width))×\(Int(world.height))")
428
433
  inspectorRow(label: "Set", value: controller.activeWindowSet?.name ?? "None")
@@ -444,13 +449,12 @@ struct ScreenMapView: View {
444
449
  private func inspectorWindowCard(win: ScreenMapWindowEntry, editor: ScreenMapEditorState) -> some View {
445
450
  let desktopEntry = DesktopModel.shared.windows[UInt32(win.id)]
446
451
  let ocrText = OcrModel.shared.results[UInt32(win.id)]?.fullText
447
- let layerTag = DesktopModel.shared.windowLayerTags[UInt32(win.id)]
448
452
 
449
453
  return VStack(alignment: .leading, spacing: 8) {
450
454
  // Header: app + visibility
451
455
  HStack(spacing: 5) {
452
456
  Circle()
453
- .fill(Self.layerColor(for: win.layer))
457
+ .fill(Palette.running.opacity(0.8))
454
458
  .frame(width: 6, height: 6)
455
459
  Text(win.app)
456
460
  .font(Typo.monoBold(11))
@@ -489,10 +493,6 @@ struct ScreenMapView: View {
489
493
 
490
494
  // Layout info
491
495
  VStack(alignment: .leading, spacing: 3) {
492
- inspectorRow(label: "Layer", value: editor.layerDisplayName(for: win.layer))
493
- if let tag = layerTag {
494
- inspectorRow(label: "Tag", value: tag)
495
- }
496
496
  inspectorRow(label: "Display", value: {
497
497
  if let disp = editor.displays.first(where: { $0.index == win.displayIndex }) {
498
498
  return "\(editor.spatialNumber(for: disp.index)). \(disp.label)"
@@ -603,7 +603,6 @@ struct ScreenMapView: View {
603
603
  "pid: \(entry.pid)",
604
604
  "frame: \(Int(entry.frame.x)),\(Int(entry.frame.y)) \(Int(entry.frame.w))×\(Int(entry.frame.h))",
605
605
  entry.latticesSession.map { "session: \($0)" },
606
- DesktopModel.shared.windowLayerTags[wid].map { "layer: \($0)" },
607
606
  ].compactMap { $0 }.joined(separator: "\n")
608
607
  NSPasteboard.general.clearContents()
609
608
  NSPasteboard.general.setString(info, forType: .string)
@@ -998,8 +997,6 @@ struct ScreenMapView: View {
998
997
  ("g", "grow", { [controller] in controller.fitAvailableSpace() }),
999
998
  ("u", "set", { [controller] in controller.createWindowSetFromSelection() }),
1000
999
  ("m", "project", { [controller] in controller.materializeViewport() }),
1001
- ("c", "merge", { [controller] in controller.consolidateLayers() }),
1002
- ("f", "flatten", { [controller] in controller.flattenLayers() }),
1003
1000
  ("v", "preview", { [controller] in controller.previewLayer() }),
1004
1001
  ]
1005
1002
 
@@ -1434,11 +1431,11 @@ struct ScreenMapView: View {
1434
1431
  return VStack(spacing: 0) {
1435
1432
  // Header
1436
1433
  HStack {
1437
- Text("LAYERS")
1434
+ Text("VIEW")
1438
1435
  .font(Typo.monoBold(9))
1439
1436
  .foregroundColor(Palette.textMuted)
1440
1437
  Spacer()
1441
- if editor.effectiveLayerCount > 1 {
1438
+ if editor.effectiveLayerCount > 1 && !editor.isShowingAll {
1442
1439
  Button(action: { controller.consolidateLayers() }) {
1443
1440
  Image(systemName: "arrow.triangle.merge")
1444
1441
  .font(.system(size: 8, weight: .semibold))
@@ -1452,49 +1449,88 @@ struct ScreenMapView: View {
1452
1449
 
1453
1450
  // Layer list
1454
1451
  ScrollView(.vertical, showsIndicators: false) {
1455
- let namedLayers = editor.namedEffectiveLayers
1456
- let unnamedLayers = editor.unnamedEffectiveLayers
1457
-
1458
- VStack(spacing: 2) {
1459
- layerTreeHeader(
1460
- label: "All",
1461
- count: editor.scopedWindowCount,
1462
- isActive: editor.isShowingAll,
1463
- color: Palette.running
1464
- ) {
1465
- editor.selectLayer(nil)
1466
- }
1452
+ let visibleWindows = editor.renderedCanvasWindows.sorted { $0.zIndex < $1.zIndex }
1453
+ let rowWidth = max(sidebarWidth - 16, 1)
1467
1454
 
1468
- ForEach(namedLayers, id: \.self) { layer in
1469
- layerRow(layer: layer, editor: editor)
1470
- }
1455
+ ZStack(alignment: .topLeading) {
1456
+ VStack(spacing: 2) {
1457
+ layerTreeHeader(
1458
+ label: "Desktop",
1459
+ count: visibleWindows.count,
1460
+ isActive: editor.isShowingAll,
1461
+ color: Palette.running
1462
+ ) {
1463
+ editor.selectLayer(nil)
1464
+ }
1465
+ .frame(width: rowWidth, height: Self.sidebarWindowRowHeight, alignment: .leading)
1471
1466
 
1472
- if !unnamedLayers.isEmpty {
1473
- HStack(spacing: 4) {
1474
- let totalWindows = unnamedLayers.reduce(0) { $0 + editor.layerTreeWindows(for: $1).count }
1475
- Image(systemName: showUnnamedLayers ? "chevron.down" : "chevron.right")
1476
- .font(.system(size: 6, weight: .bold))
1477
- .foregroundColor(Palette.textMuted)
1478
- Text("\(unnamedLayers.count) more")
1479
- .font(Typo.mono(8))
1480
- .foregroundColor(Palette.textMuted)
1481
- Text("· \(totalWindows)w")
1482
- .font(Typo.mono(7))
1483
- .foregroundColor(Palette.textDim)
1484
- Spacer()
1467
+ ForEach(visibleWindows) { win in
1468
+ visibleWindowRow(win: win)
1485
1469
  }
1486
- .padding(.vertical, 4)
1487
- .padding(.horizontal, 4)
1488
- .contentShape(Rectangle())
1489
- .simultaneousGesture(TapGesture().onEnded { showUnnamedLayers.toggle() })
1490
1470
 
1491
- if showUnnamedLayers {
1492
- ForEach(unnamedLayers, id: \.self) { layer in
1471
+ /*
1472
+ Depth controls intentionally stay out of the default flow.
1473
+ Keep the old layer model available internally, but make the
1474
+ normal sidebar answer: "which windows are visible right now?"
1475
+ */
1476
+ if !editor.isShowingAll {
1477
+ let namedLayers = editor.namedEffectiveLayers
1478
+ let unnamedLayers = editor.unnamedEffectiveLayers
1479
+ ForEach(namedLayers, id: \.self) { layer in
1493
1480
  layerRow(layer: layer, editor: editor)
1494
1481
  }
1482
+
1483
+ if !unnamedLayers.isEmpty {
1484
+ HStack(spacing: 4) {
1485
+ let totalWindows = unnamedLayers.reduce(0) { $0 + editor.layerTreeWindows(for: $1).count }
1486
+ Image(systemName: showUnnamedLayers ? "chevron.down" : "chevron.right")
1487
+ .font(.system(size: 6, weight: .bold))
1488
+ .foregroundColor(Palette.textMuted)
1489
+ Text(showUnnamedLayers ? "hide depth" : "show depth")
1490
+ .font(Typo.mono(8))
1491
+ .foregroundColor(Palette.textMuted)
1492
+ Text("· \(totalWindows)w")
1493
+ .font(Typo.mono(7))
1494
+ .foregroundColor(Palette.textDim)
1495
+ Spacer()
1496
+ }
1497
+ .padding(.vertical, 4)
1498
+ .padding(.horizontal, 4)
1499
+ .contentShape(Rectangle())
1500
+ .simultaneousGesture(TapGesture().onEnded { showUnnamedLayers.toggle() })
1501
+ }
1502
+
1503
+ if showUnnamedLayers {
1504
+ ForEach(unnamedLayers, id: \.self) { layer in
1505
+ layerRow(layer: layer, editor: editor)
1506
+ }
1507
+ }
1495
1508
  }
1496
1509
  }
1510
+ .frame(width: rowWidth, alignment: .topLeading)
1511
+ .allowsHitTesting(!editor.isShowingAll)
1512
+
1513
+ if editor.isShowingAll {
1514
+ SidebarWindowHitCatcher(rowHeight: Self.sidebarWindowRowStride) { row in
1515
+ if row == 0 {
1516
+ editor.selectLayer(nil)
1517
+ } else {
1518
+ let index = row - 1
1519
+ guard visibleWindows.indices.contains(index) else { return }
1520
+ controller.selectSingle(visibleWindows[index].id)
1521
+ }
1522
+ }
1523
+ .frame(
1524
+ width: rowWidth,
1525
+ height: CGFloat(visibleWindows.count + 1) * Self.sidebarWindowRowStride
1526
+ )
1527
+ }
1497
1528
  }
1529
+ .frame(
1530
+ width: rowWidth,
1531
+ height: CGFloat(visibleWindows.count + 1) * Self.sidebarWindowRowStride,
1532
+ alignment: .topLeading
1533
+ )
1498
1534
  }
1499
1535
  .coordinateSpace(name: "layerSidebar")
1500
1536
 
@@ -1514,6 +1550,50 @@ struct ScreenMapView: View {
1514
1550
  .onPreferenceChange(LayerRowFrameKey.self) { layerRowFrames = $0 }
1515
1551
  }
1516
1552
 
1553
+ private func visibleWindowRow(win: ScreenMapWindowEntry) -> some View {
1554
+ let isSelected = controller.selectedWindowIds.contains(win.id)
1555
+ let rowWidth = max(sidebarWidth - 16, 1)
1556
+ return HStack(spacing: 6) {
1557
+ Circle()
1558
+ .fill(isSelected ? Palette.running : Palette.textMuted.opacity(0.55))
1559
+ .frame(width: 4, height: 4)
1560
+ VStack(alignment: .leading, spacing: 1) {
1561
+ Text(win.app)
1562
+ .font(Typo.monoBold(8))
1563
+ .foregroundColor(isSelected ? Palette.running : Palette.textDim)
1564
+ .lineLimit(1)
1565
+ if !win.title.isEmpty {
1566
+ Text(win.title)
1567
+ .font(Typo.mono(7))
1568
+ .foregroundColor(Palette.textMuted)
1569
+ .lineLimit(1)
1570
+ }
1571
+ }
1572
+ Spacer(minLength: 4)
1573
+ if win.hasEdits {
1574
+ Circle()
1575
+ .fill(Color.orange.opacity(0.85))
1576
+ .frame(width: 4, height: 4)
1577
+ }
1578
+ }
1579
+ .padding(.vertical, 3)
1580
+ .padding(.leading, 20)
1581
+ .padding(.trailing, 6)
1582
+ .frame(width: rowWidth, height: Self.sidebarWindowRowHeight, alignment: .leading)
1583
+ .clipped()
1584
+ .background(
1585
+ RoundedRectangle(cornerRadius: 4)
1586
+ .fill(isSelected ? Palette.running.opacity(0.08) : Color.clear)
1587
+ )
1588
+ .contentShape(Rectangle())
1589
+ .highPriorityGesture(TapGesture().onEnded {
1590
+ controller.selectSingle(win.id)
1591
+ })
1592
+ .accessibilityElement(children: .combine)
1593
+ .accessibilityLabel(win.title.isEmpty ? win.app : "\(win.app), \(win.title)")
1594
+ .accessibilityAddTraits(.isButton)
1595
+ }
1596
+
1517
1597
  @ViewBuilder
1518
1598
  private func layerRow(layer: Int, editor: ScreenMapEditorState) -> some View {
1519
1599
  let displayName = editor.layerDisplayName(for: layer)
@@ -1799,6 +1879,22 @@ struct ScreenMapView: View {
1799
1879
 
1800
1880
  // MARK: - Canvas
1801
1881
 
1882
+ private func canvasStageAspectRatio(editor: ScreenMapEditorState?, displays: [DisplayGeometry]) -> CGFloat {
1883
+ let scopedDisplays: [DisplayGeometry]
1884
+ if let focusedDisplayIndex = editor?.focusedDisplayIndex {
1885
+ scopedDisplays = displays.filter { $0.index == focusedDisplayIndex }
1886
+ } else {
1887
+ scopedDisplays = displays
1888
+ }
1889
+
1890
+ let displayBounds = scopedDisplays.map(\.cgRect).reduce(nil as CGRect?) { partial, rect in
1891
+ partial.map { $0.union(rect) } ?? rect
1892
+ }
1893
+ let bounds = displayBounds ?? editor?.canvasWorldBounds ?? CGRect(x: 0, y: 0, width: 16, height: 10)
1894
+ let rawAspect = bounds.width / max(bounds.height, 1)
1895
+ return min(max(rawAspect, Self.canvasStageMinAspect), Self.canvasStageMaxAspect)
1896
+ }
1897
+
1802
1898
  private func screenMapCanvas(editor: ScreenMapEditorState?) -> some View {
1803
1899
  let isFocused = editor?.focusedDisplayIndex != nil
1804
1900
  let canvasWindows = editor?.renderedCanvasWindows ?? []
@@ -1806,6 +1902,8 @@ struct ScreenMapView: View {
1806
1902
  let zoomLevel = editor?.zoomLevel ?? 1.0
1807
1903
  let panOffset = editor?.panOffset ?? .zero
1808
1904
  let canvasShape = RoundedRectangle(cornerRadius: 6, style: .continuous)
1905
+ let stageAspectRatio = canvasStageAspectRatio(editor: editor, displays: displays)
1906
+ let usesProjectionWindows = editor?.isShowingAll ?? true
1809
1907
 
1810
1908
  return GeometryReader { geo in
1811
1909
  let metrics = CanvasMetrics(editor: editor, displays: displays, viewportSize: geo.size)
@@ -1815,12 +1913,11 @@ struct ScreenMapView: View {
1815
1913
  zoomLevel: zoomLevel,
1816
1914
  navigationRevision: editor?.canvasNavigationRevision ?? 0
1817
1915
  )
1818
-
1819
1916
  ZStack(alignment: .topLeading) {
1820
1917
  // Per-display background rectangles
1821
1918
  if isFocused, editor?.focusedDisplay != nil {
1822
1919
  focusedDisplayBackground(mapSize: metrics.mapSize)
1823
- } else if displays.count > 1 {
1920
+ } else if !displays.isEmpty {
1824
1921
  multiDisplayBackgrounds(displays: displays, editor: editor, metrics: metrics)
1825
1922
  } else {
1826
1923
  singleDisplayBackground(mapSize: metrics.mapSize)
@@ -1837,9 +1934,11 @@ struct ScreenMapView: View {
1837
1934
  .offset(x: rect.minX, y: rect.minY)
1838
1935
  }
1839
1936
 
1840
- // Live windows back-to-front
1841
- ForEach(Array(canvasWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
1842
- windowTile(win: win, editor: editor, metrics: metrics)
1937
+ if !usesProjectionWindows {
1938
+ // Live windows back-to-front
1939
+ ForEach(Array(canvasWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
1940
+ windowTile(win: win, editor: editor, metrics: metrics)
1941
+ }
1843
1942
  }
1844
1943
  }
1845
1944
  .frame(width: metrics.mapSize.width, height: metrics.mapSize.height)
@@ -1852,7 +1951,8 @@ struct ScreenMapView: View {
1852
1951
  }
1853
1952
  }
1854
1953
  .padding(8)
1855
- .frame(maxWidth: .infinity, maxHeight: .infinity)
1954
+ .frame(maxWidth: Self.canvasStageMaxWidth, maxHeight: Self.canvasStageMaxHeight)
1955
+ .aspectRatio(stageAspectRatio, contentMode: .fit)
1856
1956
  .contentShape(canvasShape)
1857
1957
  .clipShape(canvasShape)
1858
1958
  .clipped()
@@ -1876,6 +1976,85 @@ struct ScreenMapView: View {
1876
1976
  }
1877
1977
  }
1878
1978
  )
1979
+ .overlay(alignment: .topLeading) {
1980
+ Group {
1981
+ if let editor = editor {
1982
+ let projection = CanvasProjection(editor: editor)
1983
+ let projectionLabelIds = projectionLabelWindowIds(
1984
+ windows: canvasWindows,
1985
+ projection: projection,
1986
+ selectedIds: controller.selectedWindowIds
1987
+ )
1988
+
1989
+ ZStack(alignment: .topLeading) {
1990
+ if usesProjectionWindows {
1991
+ ForEach(Array(canvasWindows.sorted(by: { $0.zIndex > $1.zIndex }).enumerated()), id: \.element.id) { _, win in
1992
+ let rect = projection.mapRect(for: win.virtualFrame)
1993
+ let radius = min(max(rect.width, rect.height) * 0.012, 3)
1994
+ let isSelected = controller.selectedWindowIds.contains(win.id)
1995
+ let fill = isSelected
1996
+ ? Palette.running.opacity(0.10)
1997
+ : win.hasEdits ? Color.orange.opacity(0.10) : Color.white.opacity(0.018)
1998
+ let stroke = isSelected
1999
+ ? Palette.running.opacity(0.55)
2000
+ : win.hasEdits ? Color.orange.opacity(0.5) : Color.white.opacity(0.10)
2001
+
2002
+ RoundedRectangle(cornerRadius: radius)
2003
+ .fill(fill)
2004
+ .overlay(
2005
+ RoundedRectangle(cornerRadius: radius)
2006
+ .strokeBorder(stroke, lineWidth: isSelected ? 0.9 : 0.7)
2007
+ )
2008
+ .overlay {
2009
+ if projectionLabelIds.contains(win.id) {
2010
+ projectionWindowLabel(win: win, rect: rect, isSelected: isSelected)
2011
+ }
2012
+ }
2013
+ .frame(width: rect.width, height: rect.height)
2014
+ .contentShape(Rectangle())
2015
+ .offset(
2016
+ x: Self.canvasPadding + projection.mapOrigin.x + projection.panOffset.x + rect.minX,
2017
+ y: Self.canvasPadding + projection.mapOrigin.y + projection.panOffset.y + rect.minY
2018
+ )
2019
+ .zIndex(isSelected ? 2 : 0)
2020
+ }
2021
+ }
2022
+
2023
+ ForEach(editor.displays, id: \.index) { display in
2024
+ let rect = projection.mapRect(for: display.cgRect, minimumSize: 12)
2025
+ let isFocused = editor.focusedDisplayIndex == display.index
2026
+ RoundedRectangle(cornerRadius: 5)
2027
+ .strokeBorder(
2028
+ isFocused ? Palette.running.opacity(0.36) : Color.white.opacity(0.22),
2029
+ lineWidth: isFocused ? 1 : 0.8
2030
+ )
2031
+ .overlay(alignment: .topLeading) {
2032
+ Text("\(editor.spatialNumber(for: display.index))")
2033
+ .font(Typo.monoBold(8))
2034
+ .foregroundColor(isFocused ? Palette.running : Palette.textMuted)
2035
+ .padding(.horizontal, 4)
2036
+ .padding(.vertical, 2)
2037
+ .background(
2038
+ RoundedRectangle(cornerRadius: 3)
2039
+ .fill(Color.black.opacity(0.42))
2040
+ )
2041
+ .padding(5)
2042
+ }
2043
+ .frame(width: rect.width, height: rect.height)
2044
+ .offset(
2045
+ x: Self.canvasPadding + projection.mapOrigin.x + projection.panOffset.x + rect.minX,
2046
+ y: Self.canvasPadding + projection.mapOrigin.y + projection.panOffset.y + rect.minY
2047
+ )
2048
+ .allowsHitTesting(false)
2049
+ .zIndex(-1)
2050
+ }
2051
+ }
2052
+ .allowsHitTesting(false)
2053
+ }
2054
+ }
2055
+ }
2056
+ .clipShape(canvasShape)
2057
+ .clipped()
1879
2058
  .overlay(alignment: .top) {
1880
2059
  if let editor = controller.editor, editor.displays.count > 1 {
1881
2060
  displayToolbar(editor: editor)
@@ -1980,6 +2159,108 @@ struct ScreenMapView: View {
1980
2159
 
1981
2160
  // MARK: - Window Tile
1982
2161
 
2162
+ private func projectionLabelWindowIds(
2163
+ windows: [ScreenMapWindowEntry],
2164
+ projection: CanvasProjection,
2165
+ selectedIds: Set<UInt32>
2166
+ ) -> Set<UInt32> {
2167
+ if !selectedIds.isEmpty {
2168
+ return Set(windows.compactMap { win in
2169
+ guard selectedIds.contains(win.id) else { return nil }
2170
+ let rect = projection.mapRect(for: win.virtualFrame)
2171
+ return rect.width > 54 && rect.height > 24 ? win.id : nil
2172
+ })
2173
+ }
2174
+
2175
+ var acceptedIds = Set<UInt32>()
2176
+ var occupiedLabelRects: [CGRect] = []
2177
+
2178
+ for (frontOrder, win) in windows.sorted(by: { $0.zIndex < $1.zIndex }).enumerated() {
2179
+ let rect = projection.mapRect(for: win.virtualFrame)
2180
+ let isSelected = selectedIds.contains(win.id)
2181
+ guard isSelected || frontOrder < 14 else { continue }
2182
+ guard rect.width > 54, rect.height > 24 else { continue }
2183
+
2184
+ let labelRect = projectionLabelCollisionRect(for: rect, includesTitle: rect.width > 130 && rect.height > 38 && !win.title.isEmpty)
2185
+ let collides = occupiedLabelRects.contains { occupied in
2186
+ occupied.insetBy(dx: -8, dy: -5).intersects(labelRect)
2187
+ }
2188
+
2189
+ if isSelected || !collides {
2190
+ acceptedIds.insert(win.id)
2191
+ occupiedLabelRects.append(labelRect)
2192
+ }
2193
+ }
2194
+
2195
+ return acceptedIds
2196
+ }
2197
+
2198
+ private func projectionLabelCollisionRect(for rect: CGRect, includesTitle: Bool) -> CGRect {
2199
+ let width = min(max(48, rect.width - 10), includesTitle ? 170 : 96)
2200
+ let height: CGFloat = includesTitle ? 27 : 15
2201
+ return CGRect(
2202
+ x: rect.midX - width / 2,
2203
+ y: rect.midY - height / 2,
2204
+ width: width,
2205
+ height: height
2206
+ )
2207
+ }
2208
+
2209
+ @ViewBuilder
2210
+ private func projectionWindowLabel(win: ScreenMapWindowEntry, rect: CGRect, isSelected: Bool) -> some View {
2211
+ if rect.width > 54, rect.height > 24 {
2212
+ Group {
2213
+ if isSelected {
2214
+ VStack(alignment: .leading, spacing: 2) {
2215
+ HStack(spacing: 4) {
2216
+ Circle()
2217
+ .fill(Palette.running)
2218
+ .frame(width: 4, height: 4)
2219
+ Text(win.app)
2220
+ .font(Typo.monoBold(9))
2221
+ .foregroundColor(Palette.running)
2222
+ .lineLimit(1)
2223
+ }
2224
+ if rect.width > 120, rect.height > 42, !win.title.isEmpty {
2225
+ Text(win.title)
2226
+ .font(Typo.mono(7))
2227
+ .foregroundColor(Palette.textMuted.opacity(0.86))
2228
+ .lineLimit(1)
2229
+ }
2230
+ }
2231
+ .padding(.horizontal, 6)
2232
+ .padding(.vertical, 4)
2233
+ .background(
2234
+ RoundedRectangle(cornerRadius: 4)
2235
+ .fill(Color.black.opacity(0.48))
2236
+ )
2237
+ .overlay(
2238
+ RoundedRectangle(cornerRadius: 4)
2239
+ .strokeBorder(Palette.running.opacity(0.42), lineWidth: 0.75)
2240
+ )
2241
+ .frame(maxWidth: rect.width, maxHeight: rect.height)
2242
+ } else {
2243
+ VStack(spacing: 1) {
2244
+ Text(win.app)
2245
+ .font(Typo.monoBold(max(7, min(10, rect.height * 0.13))))
2246
+ .foregroundColor(Palette.text.opacity(0.78))
2247
+ .lineLimit(1)
2248
+ if rect.width > 130, rect.height > 38, !win.title.isEmpty {
2249
+ Text(win.title)
2250
+ .font(Typo.mono(max(6, min(8, rect.height * 0.09))))
2251
+ .foregroundColor(Palette.textMuted.opacity(0.75))
2252
+ .lineLimit(1)
2253
+ }
2254
+ }
2255
+ .padding(.horizontal, 5)
2256
+ .padding(.vertical, 3)
2257
+ .frame(maxWidth: rect.width, maxHeight: rect.height)
2258
+ }
2259
+ }
2260
+ .allowsHitTesting(false)
2261
+ }
2262
+ }
2263
+
1983
2264
  @ViewBuilder
1984
2265
  private func windowTile(win: ScreenMapWindowEntry, editor: ScreenMapEditorState?, metrics: CanvasMetrics) -> some View {
1985
2266
  let rect = metrics.mapRect(for: win.virtualFrame)
@@ -1990,91 +2271,93 @@ struct ScreenMapView: View {
1990
2271
  let isInActiveLayer = editor?.isLayerSelected(win.layer) ?? true
1991
2272
  let winLayerColor = Self.layerColor(for: win.layer)
1992
2273
  let isSearchHighlighted = controller.searchHighlightedWindowId == win.id
2274
+ let usesFlatStyle = editor?.isShowingAll ?? true
1993
2275
 
1994
2276
  let fillColor = isSearchHighlighted
1995
2277
  ? Self.shelfGreen.opacity(0.2)
1996
2278
  : isSelected
1997
2279
  ? Palette.running.opacity(0.18)
1998
- : win.hasEdits ? Color.orange.opacity(0.12) : Palette.surface.opacity(0.7)
2280
+ : win.hasEdits ? Color.orange.opacity(0.14) : usesFlatStyle ? Palette.surface.opacity(0.68) : winLayerColor.opacity(0.16)
1999
2281
  let borderColor = isSearchHighlighted
2000
2282
  ? Self.shelfGreen.opacity(0.8)
2001
2283
  : isSelected
2002
2284
  ? Palette.running.opacity(0.8)
2003
- : win.hasEdits ? Color.orange.opacity(0.6) : Palette.border.opacity(0.6)
2285
+ : win.hasEdits ? Color.orange.opacity(0.65) : usesFlatStyle ? Palette.border.opacity(0.55) : winLayerColor.opacity(0.42)
2004
2286
 
2005
- Button {
2006
- if NSEvent.modifierFlags.contains(.command) {
2007
- controller.toggleSelection(win.id)
2008
- } else {
2009
- controller.selectSingle(win.id)
2010
- }
2011
- } label: {
2012
- RoundedRectangle(cornerRadius: 2)
2013
- .fill(fillColor)
2014
- .overlay(
2015
- RoundedRectangle(cornerRadius: 2)
2016
- .strokeBorder(borderColor, lineWidth: isSearchHighlighted ? 2 : isSelected ? 1.5 : 0.5)
2017
- )
2018
- .overlay(alignment: .leading) {
2287
+ RoundedRectangle(cornerRadius: 2)
2288
+ .fill(fillColor)
2289
+ .overlay(
2290
+ RoundedRectangle(cornerRadius: 2)
2291
+ .strokeBorder(borderColor, lineWidth: isSearchHighlighted ? 2 : isSelected ? 1.5 : 0.5)
2292
+ )
2293
+ .overlay(alignment: .leading) {
2294
+ if !usesFlatStyle {
2019
2295
  Rectangle()
2020
2296
  .fill(winLayerColor)
2021
2297
  .frame(width: 2)
2022
2298
  }
2023
- .clipShape(RoundedRectangle(cornerRadius: 2))
2024
- .overlay {
2025
- ZStack {
2026
- VStack(spacing: 1) {
2027
- Text(win.app)
2028
- .font(Typo.monoBold(max(7, min(10, h * 0.15))))
2029
- .foregroundColor(isSelected ? Palette.running : Palette.text)
2299
+ }
2300
+ .clipShape(RoundedRectangle(cornerRadius: 2))
2301
+ .overlay {
2302
+ ZStack {
2303
+ VStack(spacing: 1) {
2304
+ Text(win.app)
2305
+ .font(Typo.monoBold(max(7, min(10, h * 0.15))))
2306
+ .foregroundColor(isSelected ? Palette.running : Palette.text)
2307
+ .lineLimit(1)
2308
+ if h > 30 {
2309
+ Text(win.title)
2310
+ .font(Typo.mono(max(6, min(8, h * 0.1))))
2311
+ .foregroundColor(Palette.textDim)
2030
2312
  .lineLimit(1)
2031
- if h > 30 {
2032
- Text(win.title)
2033
- .font(Typo.mono(max(6, min(8, h * 0.1))))
2034
- .foregroundColor(Palette.textDim)
2035
- .lineLimit(1)
2036
- }
2037
- if h > 50 {
2038
- Text("\(Int(win.virtualFrame.width))x\(Int(win.virtualFrame.height))")
2039
- .font(Typo.mono(6))
2040
- .foregroundColor(Palette.textMuted)
2041
- }
2042
2313
  }
2043
- .padding(.leading, 4)
2044
- .padding(2)
2314
+ if h > 50 {
2315
+ Text("\(Int(win.virtualFrame.width))x\(Int(win.virtualFrame.height))")
2316
+ .font(Typo.mono(6))
2317
+ .foregroundColor(Palette.textMuted)
2318
+ }
2319
+ }
2320
+ .padding(.leading, 4)
2321
+ .padding(2)
2045
2322
 
2046
- if h > 40, let tileIcon = Self.inferTileIcon(for: win, displays: editor?.displays ?? []) {
2047
- VStack {
2048
- HStack {
2049
- Spacer()
2050
- Image(systemName: tileIcon)
2051
- .font(.system(size: 6))
2052
- .foregroundColor(Color.white.opacity(0.3))
2053
- .padding(2)
2054
- }
2323
+ if h > 40, let tileIcon = Self.inferTileIcon(for: win, displays: editor?.displays ?? []) {
2324
+ VStack {
2325
+ HStack {
2055
2326
  Spacer()
2327
+ Image(systemName: tileIcon)
2328
+ .font(.system(size: 6))
2329
+ .foregroundColor(Color.white.opacity(0.3))
2330
+ .padding(2)
2056
2331
  }
2332
+ Spacer()
2057
2333
  }
2334
+ }
2058
2335
 
2059
- if h > 50, let session = Self.extractLatticesSession(from: win.title) {
2060
- VStack {
2336
+ if h > 50, let session = Self.extractLatticesSession(from: win.title) {
2337
+ VStack {
2338
+ Spacer()
2339
+ HStack {
2340
+ Text("[\(session)]")
2341
+ .font(Typo.mono(6))
2342
+ .foregroundColor(Palette.running.opacity(0.7))
2343
+ .lineLimit(1)
2344
+ .padding(.leading, 4)
2345
+ .padding(.bottom, 2)
2061
2346
  Spacer()
2062
- HStack {
2063
- Text("[\(session)]")
2064
- .font(Typo.mono(6))
2065
- .foregroundColor(Palette.running.opacity(0.7))
2066
- .lineLimit(1)
2067
- .padding(.leading, 4)
2068
- .padding(.bottom, 2)
2069
- Spacer()
2070
- }
2071
2347
  }
2072
2348
  }
2073
2349
  }
2074
2350
  }
2075
- }
2076
- .buttonStyle(.plain)
2351
+ }
2077
2352
  .frame(width: w, height: h)
2353
+ .contentShape(Rectangle())
2354
+ .onTapGesture {
2355
+ if NSEvent.modifierFlags.contains(.command) {
2356
+ controller.toggleSelection(win.id)
2357
+ } else {
2358
+ controller.selectSingle(win.id)
2359
+ }
2360
+ }
2078
2361
  .overlay {
2079
2362
  if isSelected && w > 30 && h > 20 {
2080
2363
  resizeHandles(width: w, height: h)
@@ -2191,6 +2474,7 @@ struct ScreenMapView: View {
2191
2474
  Rectangle().fill(Palette.border).frame(width: 0.5, height: 12)
2192
2475
 
2193
2476
  Button {
2477
+ controller.clearSelection()
2194
2478
  controller.focusViewportPreset(.overview)
2195
2479
  } label: {
2196
2480
  Text("\(pct)%")
@@ -2232,15 +2516,12 @@ struct ScreenMapView: View {
2232
2516
  Rectangle().fill(Color.white.opacity(0.04)).frame(height: 0.5)
2233
2517
  HStack(spacing: 6) {
2234
2518
  if let editor = controller.editor {
2235
- let layerColor = editor.activeLayer != nil
2236
- ? Self.layerColor(for: editor.activeLayer!)
2237
- : Palette.running
2238
- Circle().fill(layerColor).frame(width: 5, height: 5)
2239
- Text(editor.layerLabel)
2519
+ Circle().fill(Palette.running).frame(width: 5, height: 5)
2520
+ Text("DESKTOP")
2240
2521
  .font(Typo.monoBold(8))
2241
- .foregroundColor(layerColor)
2522
+ .foregroundColor(Palette.running)
2242
2523
  Text("·").foregroundColor(Palette.textMuted).font(Typo.mono(7))
2243
- Text("\(editor.focusedVisibleWindows.count) windows")
2524
+ Text("\(editor.renderedCanvasWindows.count) windows")
2244
2525
  .font(Typo.mono(8))
2245
2526
  .foregroundColor(Palette.textDim)
2246
2527
  if let focused = editor.focusedDisplay {
@@ -2493,7 +2774,8 @@ struct ScreenMapView: View {
2493
2774
 
2494
2775
  HStack(spacing: 6) {
2495
2776
  mapScopePill("ALL", isActive: editor.focusedDisplayIndex == nil) {
2496
- controller.focusCanvas(on: editor.canvasWorldBounds, focusDisplay: nil, zoomToFit: true)
2777
+ controller.clearSelection()
2778
+ controller.focusViewportPreset(.overview)
2497
2779
  }
2498
2780
  ForEach(editor.spatialDisplayOrder, id: \.index) { disp in
2499
2781
  mapScopePill("\(editor.spatialNumber(for: disp.index))", isActive: editor.focusedDisplayIndex == disp.index) {
@@ -2640,6 +2922,16 @@ struct ScreenMapView: View {
2640
2922
  }
2641
2923
 
2642
2924
  private func syncCanvasGeometry(editor: ScreenMapEditorState?, metrics: CanvasMetrics) {
2925
+ let previous = editor.map {
2926
+ (
2927
+ fitScale: $0.fitScale,
2928
+ scale: $0.scale,
2929
+ mapOrigin: $0.mapOrigin,
2930
+ viewportSize: $0.viewportSize,
2931
+ screenSize: $0.screenSize,
2932
+ bboxOrigin: $0.bboxOrigin
2933
+ )
2934
+ }
2643
2935
  editor?.fitScale = metrics.fitScale
2644
2936
  editor?.scale = metrics.effectiveScale
2645
2937
  editor?.mapOrigin = metrics.centerOffset
@@ -2647,6 +2939,55 @@ struct ScreenMapView: View {
2647
2939
  editor?.screenSize = metrics.worldBounds.size
2648
2940
  editor?.bboxOrigin = metrics.worldBounds.origin
2649
2941
  controller.applyPendingCanvasNavigationIfNeeded()
2942
+ if let editor {
2943
+ let boundedPan = boundedPanOffset(editor.panOffset, editor: editor)
2944
+ if boundedPan != editor.panOffset {
2945
+ editor.panOffset = boundedPan
2946
+ }
2947
+ }
2948
+ if let editor,
2949
+ let previous,
2950
+ previous.fitScale != editor.fitScale ||
2951
+ previous.scale != editor.scale ||
2952
+ previous.mapOrigin != editor.mapOrigin ||
2953
+ previous.viewportSize != editor.viewportSize ||
2954
+ previous.screenSize != editor.screenSize ||
2955
+ previous.bboxOrigin != editor.bboxOrigin {
2956
+ DispatchQueue.main.async {
2957
+ editor.objectWillChange.send()
2958
+ controller.objectWillChange.send()
2959
+ }
2960
+ }
2961
+ }
2962
+
2963
+ private func boundedPanOffset(_ proposed: CGPoint, editor: ScreenMapEditorState) -> CGPoint {
2964
+ guard editor.scale > 0,
2965
+ editor.viewportSize.width > 0,
2966
+ editor.viewportSize.height > 0,
2967
+ editor.screenSize.width > 0,
2968
+ editor.screenSize.height > 0 else {
2969
+ return proposed
2970
+ }
2971
+
2972
+ func clampAxis(_ value: CGFloat, mapOrigin: CGFloat, content: CGFloat, viewport: CGFloat) -> CGFloat {
2973
+ let effectiveScale = max(editor.fitScale * editor.zoomLevel, editor.scale)
2974
+ let mapSize = content * effectiveScale
2975
+ let minVisible = min(Self.canvasPanMinVisiblePixels, max(viewport / 2, 0))
2976
+ let minValue = minVisible - mapOrigin - mapSize
2977
+ let maxValue = viewport - minVisible - mapOrigin
2978
+ return min(max(value, minValue), maxValue)
2979
+ }
2980
+
2981
+ return CGPoint(
2982
+ x: clampAxis(proposed.x,
2983
+ mapOrigin: editor.mapOrigin.x,
2984
+ content: editor.screenSize.width,
2985
+ viewport: editor.viewportSize.width),
2986
+ y: clampAxis(proposed.y,
2987
+ mapOrigin: editor.mapOrigin.y,
2988
+ content: editor.screenSize.height,
2989
+ viewport: editor.viewportSize.height)
2990
+ )
2650
2991
  }
2651
2992
 
2652
2993
  // MARK: - Layer Colors
@@ -2754,7 +3095,7 @@ struct ScreenMapView: View {
2754
3095
  } else if event.type == .keyUp {
2755
3096
  guard isSpaceHeld else { return nil }
2756
3097
  isSpaceHeld = false
2757
- spaceDragStart = nil
3098
+ canvasPanStart = nil
2758
3099
  NSCursor.pop()
2759
3100
  return nil
2760
3101
  }
@@ -2795,36 +3136,54 @@ struct ScreenMapView: View {
2795
3136
  guard let eventWindow = event.window,
2796
3137
  eventWindow === ScreenMapWindowController.shared.nsWindow else { return event }
2797
3138
  let flippedPt = flippedScreenPoint(event)
3139
+ guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else { return event }
2798
3140
 
2799
3141
  // Space+click → begin canvas pan
2800
3142
  if isSpaceHeld,
2801
- isCanvasPoint(flippedPt),
2802
3143
  let editor = controller.editor {
2803
- spaceDragStart = event.locationInWindow
2804
- spaceDragPanStart = editor.panOffset
3144
+ canvasPanStart = event.locationInWindow
3145
+ canvasPanStartOffset = editor.panOffset
2805
3146
  NSCursor.closedHand.push()
2806
3147
  return nil
2807
3148
  }
2808
3149
 
2809
3150
  if let editor = controller.editor,
2810
- let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor),
2811
- hoveredWindowId == hit.id {
2812
- screenMapClickWindowId = hit.id
2813
- screenMapClickPoint = event.locationInWindow
2814
- editor.canvasDragMode = detectDragMode(mapPoint: hit.mapPoint, windowMapRect: hit.mapRect)
3151
+ let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor) {
3152
+ screenMapClickWindowId = nil
3153
+ if NSEvent.modifierFlags.contains(.command) {
3154
+ controller.toggleSelection(hit.id)
3155
+ } else if !controller.isSelected(hit.id) {
3156
+ controller.selectSingle(hit.id)
3157
+ }
3158
+ canvasPanStart = event.locationInWindow
3159
+ canvasPanStartOffset = editor.panOffset
3160
+ return nil
2815
3161
  } else {
2816
3162
  screenMapClickWindowId = nil
2817
3163
  }
3164
+
3165
+ if let editor = controller.editor {
3166
+ canvasPanStart = event.locationInWindow
3167
+ canvasPanStartOffset = editor.panOffset
3168
+ return nil
3169
+ }
3170
+
2818
3171
  return event
2819
3172
  }
2820
3173
 
2821
3174
  mouseDragMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
2822
- // Space+drag pan canvas
2823
- if isSpaceHeld, let start = spaceDragStart, let editor = controller.editor {
3175
+ // Empty-canvas drag, or Space+drag, pans the viewport.
3176
+ if let start = canvasPanStart, let editor = controller.editor {
2824
3177
  let dx = event.locationInWindow.x - start.x
2825
3178
  let dy = event.locationInWindow.y - start.y
2826
- editor.activeViewportPreset = nil
2827
- editor.panOffset = CGPoint(x: spaceDragPanStart.x + dx, y: spaceDragPanStart.y - dy)
3179
+ let bounded = boundedPanOffset(
3180
+ CGPoint(x: canvasPanStartOffset.x + dx, y: canvasPanStartOffset.y - dy),
3181
+ editor: editor
3182
+ )
3183
+ if bounded != editor.panOffset {
3184
+ editor.activeViewportPreset = nil
3185
+ editor.panOffset = bounded
3186
+ }
2828
3187
  return nil
2829
3188
  }
2830
3189
 
@@ -2899,10 +3258,12 @@ struct ScreenMapView: View {
2899
3258
  }
2900
3259
 
2901
3260
  mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
2902
- // End space+drag pan
2903
- if spaceDragStart != nil {
2904
- spaceDragStart = nil
2905
- NSCursor.pop() // pop closedHand, openHand remains
3261
+ // End canvas pan.
3262
+ if canvasPanStart != nil {
3263
+ canvasPanStart = nil
3264
+ if isSpaceHeld {
3265
+ NSCursor.pop() // pop closedHand, openHand remains
3266
+ }
2906
3267
  return event
2907
3268
  }
2908
3269
  if screenMapClickWindowId != nil {
@@ -2922,8 +3283,7 @@ struct ScreenMapView: View {
2922
3283
  let editor = controller.editor else { return event }
2923
3284
 
2924
3285
  let flippedPt = flippedScreenPoint(event)
2925
- let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
2926
- guard canvasRect.contains(flippedPt) else { return event }
3286
+ guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else { return event }
2927
3287
 
2928
3288
  if let hit = canvasHit(flippedScreenPt: flippedPt, editor: editor) {
2929
3289
  if !controller.isSelected(hit.id) {
@@ -2952,10 +3312,9 @@ struct ScreenMapView: View {
2952
3312
  }
2953
3313
 
2954
3314
  let flippedPt = flippedScreenPoint(event)
2955
- let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
2956
- guard canvasRect.contains(flippedPt) else { return event }
3315
+ guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else { return event }
2957
3316
 
2958
- let isZoom = event.modifierFlags.contains(.command) || !event.hasPreciseScrollingDeltas
3317
+ let isZoom = event.modifierFlags.contains(.command)
2959
3318
 
2960
3319
  if isZoom {
2961
3320
  let zoomDelta: CGFloat = event.hasPreciseScrollingDeltas ? event.scrollingDeltaY * 0.01 : event.scrollingDeltaY * 0.05
@@ -2980,13 +3339,30 @@ struct ScreenMapView: View {
2980
3339
 
2981
3340
  editor.activeViewportPreset = nil
2982
3341
  editor.zoomLevel = newZoom
2983
- editor.panOffset = CGPoint(x: newPanX, y: newPanY)
3342
+ editor.scale = editor.fitScale * newZoom
3343
+ let bounded = boundedPanOffset(CGPoint(x: newPanX, y: newPanY), editor: editor)
3344
+ if bounded != editor.panOffset {
3345
+ editor.activeViewportPreset = nil
3346
+ editor.panOffset = bounded
3347
+ }
2984
3348
  } else {
2985
- editor.activeViewportPreset = nil
2986
- editor.panOffset = CGPoint(
2987
- x: editor.panOffset.x + event.scrollingDeltaX,
2988
- y: editor.panOffset.y - event.scrollingDeltaY
3349
+ guard editor.zoomLevel > 1.0001 else {
3350
+ if editor.panOffset != .zero {
3351
+ editor.panOffset = .zero
3352
+ }
3353
+ return nil
3354
+ }
3355
+ let bounded = boundedPanOffset(
3356
+ CGPoint(
3357
+ x: editor.panOffset.x + event.scrollingDeltaX,
3358
+ y: editor.panOffset.y - event.scrollingDeltaY
3359
+ ),
3360
+ editor: editor
2989
3361
  )
3362
+ if bounded != editor.panOffset {
3363
+ editor.activeViewportPreset = nil
3364
+ editor.panOffset = bounded
3365
+ }
2990
3366
  }
2991
3367
  return nil
2992
3368
  }
@@ -3000,8 +3376,7 @@ struct ScreenMapView: View {
3000
3376
  }
3001
3377
 
3002
3378
  let flippedPt = flippedScreenPoint(event)
3003
- let canvasRect = CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize)
3004
- guard canvasRect.contains(flippedPt) else {
3379
+ guard isCanvasInteractionEvent(event, flippedPoint: flippedPt) else {
3005
3380
  resetCursorIfNeeded()
3006
3381
  return event
3007
3382
  }
@@ -3072,6 +3447,25 @@ struct ScreenMapView: View {
3072
3447
  CGRect(origin: screenMapCanvasOrigin, size: screenMapCanvasSize).contains(point)
3073
3448
  }
3074
3449
 
3450
+ private func isCanvasInteractionEvent(_ event: NSEvent, flippedPoint: CGPoint) -> Bool {
3451
+ guard isCanvasPoint(flippedPoint),
3452
+ let contentWidth = event.window?.contentView?.bounds.width else {
3453
+ return false
3454
+ }
3455
+
3456
+ let x = event.locationInWindow.x
3457
+ let leftBoundary = sidebarWidth + 8
3458
+ let rightBoundary = max(leftBoundary, contentWidth - inspectorWidth - 8)
3459
+ return x >= leftBoundary && x <= rightBoundary
3460
+ }
3461
+
3462
+ private func flippedScreenPoint(_ event: NSEvent) -> CGPoint {
3463
+ guard let nsWindow = event.window else { return .zero }
3464
+ let loc = event.locationInWindow
3465
+ let windowHeight = nsWindow.contentView?.frame.height ?? nsWindow.frame.height
3466
+ return CGPoint(x: loc.x, y: windowHeight - loc.y)
3467
+ }
3468
+
3075
3469
  private func detectDragMode(mapPoint: CGPoint, windowMapRect: CGRect) -> CanvasDragMode {
3076
3470
  let w = windowMapRect.width
3077
3471
  let h = windowMapRect.height
@@ -3097,13 +3491,6 @@ struct ScreenMapView: View {
3097
3491
  return .move
3098
3492
  }
3099
3493
 
3100
- private func flippedScreenPoint(_ event: NSEvent) -> CGPoint {
3101
- guard let nsWindow = event.window else { return .zero }
3102
- let loc = event.locationInWindow
3103
- let windowHeight = nsWindow.contentView?.frame.height ?? nsWindow.frame.height
3104
- return CGPoint(x: loc.x, y: windowHeight - loc.y)
3105
- }
3106
-
3107
3494
  // MARK: - Context Menu
3108
3495
 
3109
3496
  private func showLayerContextMenu(for windowId: UInt32, at point: NSPoint, in window: NSWindow, editor: ScreenMapEditorState) {
@@ -3549,6 +3936,37 @@ struct ShowOnScreenBezelView: View {
3549
3936
  }
3550
3937
  }
3551
3938
 
3939
+ private struct SidebarWindowHitCatcher: NSViewRepresentable {
3940
+ let rowHeight: CGFloat
3941
+ let onClick: (Int) -> Void
3942
+
3943
+ func makeNSView(context: Context) -> HitView {
3944
+ let view = HitView()
3945
+ view.rowHeight = rowHeight
3946
+ view.onClick = onClick
3947
+ return view
3948
+ }
3949
+
3950
+ func updateNSView(_ nsView: HitView, context: Context) {
3951
+ nsView.rowHeight = rowHeight
3952
+ nsView.onClick = onClick
3953
+ }
3954
+
3955
+ final class HitView: NSView {
3956
+ var rowHeight: CGFloat = 30
3957
+ var onClick: ((Int) -> Void)?
3958
+
3959
+ override var isFlipped: Bool { true }
3960
+
3961
+ override func mouseDown(with event: NSEvent) {
3962
+ let point = convert(event.locationInWindow, from: nil)
3963
+ guard point.x >= 0, point.x <= bounds.width, point.y >= 0, point.y <= bounds.height else { return }
3964
+ let row = max(0, Int(point.y / max(rowHeight, 1)))
3965
+ onClick?(row)
3966
+ }
3967
+ }
3968
+ }
3969
+
3552
3970
  // MARK: - Preference Keys
3553
3971
 
3554
3972
  private struct SearchOverlayFrameKey: PreferenceKey {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lattices/cli",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "Agentic window manager for macOS — programmable workspace, smart layouts, managed tmux sessions, and a 35+-method agent API",
5
5
  "bin": {
6
6
  "lattices": "./bin/lattices.ts",