@lattices/cli 0.4.14 → 0.6.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 (181) hide show
  1. package/README.md +5 -7
  2. package/apps/mac/Info.plist +4 -4
  3. package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
  4. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/bin/lattices-app.ts +110 -17
  6. package/bin/lattices-build +125 -0
  7. package/bin/lattices-dev +89 -16
  8. package/bin/lattices.ts +977 -16
  9. package/docs/agents.md +81 -4
  10. package/docs/ai-chat-ux-review.md +416 -0
  11. package/docs/api.md +135 -3
  12. package/docs/app.md +30 -8
  13. package/docs/config.md +4 -0
  14. package/docs/mouse-gestures.md +60 -1
  15. package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
  16. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  17. package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
  18. package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
  19. package/docs/reference/dewey.config.ts +2 -2
  20. package/docs/release.md +171 -0
  21. package/docs/repo-structure.md +5 -5
  22. package/docs/voice.md +11 -27
  23. package/package.json +11 -10
  24. package/apps/mac/Package.swift +0 -27
  25. package/apps/mac/Sources/AppShell/App.swift +0 -26
  26. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
  27. package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
  28. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
  29. package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
  30. package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
  31. package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
  32. package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
  33. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
  34. package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
  35. package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
  36. package/apps/mac/Sources/AppShell/MainView.swift +0 -847
  37. package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
  38. package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
  39. package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
  40. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
  41. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
  42. package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
  43. package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
  44. package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
  45. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
  46. package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
  47. package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
  48. package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
  49. package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
  50. package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
  51. package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
  52. package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
  53. package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
  54. package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
  55. package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
  56. package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
  57. package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
  58. package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
  59. package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
  60. package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
  61. package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
  62. package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
  63. package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
  64. package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
  65. package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
  66. package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
  67. package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
  68. package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
  69. package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
  70. package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
  71. package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
  72. package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
  73. package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
  74. package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
  75. package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
  76. package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
  77. package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
  78. package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
  79. package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
  80. package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
  81. package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
  82. package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
  83. package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
  84. package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
  85. package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
  86. package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
  87. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
  88. package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
  89. package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
  90. package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
  91. package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
  92. package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
  93. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
  94. package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
  95. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
  96. package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
  97. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
  98. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
  99. package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
  100. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2583
  101. package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
  102. package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
  103. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
  104. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
  105. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
  106. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
  107. package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
  108. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
  109. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
  110. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
  111. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
  112. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
  113. package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
  114. package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
  115. package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
  116. package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
  117. package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
  118. package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
  119. package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
  120. package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
  121. package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
  122. package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
  123. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
  124. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
  125. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
  126. package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
  127. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
  128. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
  129. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
  130. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
  131. package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
  132. package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
  133. package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
  134. package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
  135. package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
  136. package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
  137. package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
  138. package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
  139. package/apps/mac/Sources/Core/System/Capability.swift +0 -79
  140. package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
  141. package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
  142. package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
  143. package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
  144. package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
  145. package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
  146. package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
  147. package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
  148. package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
  149. package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
  150. package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
  151. package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
  152. package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
  153. package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
  154. package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
  155. package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
  156. package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
  157. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
  158. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
  159. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
  160. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
  161. package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
  162. package/apps/mac/Sources/UI/ActionRow.swift +0 -78
  163. package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
  164. package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
  165. package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
  166. package/apps/mac/Sources/UI/Theme.swift +0 -164
  167. package/apps/mac/Tests/StageDragTests.swift +0 -333
  168. package/apps/mac/Tests/StageJoinTests.swift +0 -313
  169. package/apps/mac/Tests/StageManagerTests.swift +0 -280
  170. package/apps/mac/Tests/StageTileTests.swift +0 -353
  171. package/swift/Package.swift +0 -20
  172. package/swift/Sources/DeckKit/DeckAction.swift +0 -51
  173. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
  174. package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
  175. package/swift/Sources/DeckKit/DeckHost.swift +0 -7
  176. package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
  177. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
  178. package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
  179. package/swift/Sources/DeckKit/DeckValue.swift +0 -93
  180. package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
  181. package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
@@ -1,3135 +0,0 @@
1
- import AppKit
2
- import Combine
3
- import Foundation
4
- import SwiftUI
5
-
6
- // MARK: - Display Geometry
7
-
8
- struct DisplayGeometry {
9
- let index: Int
10
- let cgRect: CGRect // in unified CG coords (top-left origin)
11
- let label: String // e.g. "Built-in Retina Display", "LG UltraFine"
12
- }
13
-
14
- // MARK: - Canvas Region
15
-
16
- struct ScreenMapCanvasRegion: Identifiable {
17
- enum Kind {
18
- case overview
19
- case display
20
- case layer
21
- }
22
-
23
- let id: String
24
- let kind: Kind
25
- let title: String
26
- let subtitle: String
27
- let rect: CGRect
28
- let count: Int
29
- let displayIndex: Int?
30
- let layer: Int?
31
- }
32
-
33
- struct ScreenMapCanvasNavigationTarget {
34
- let center: CGPoint
35
- let rect: CGRect?
36
- let zoomToFit: Bool
37
- }
38
-
39
- enum ScreenMapViewportPreset: String, CaseIterable, Identifiable {
40
- case overview
41
- case main
42
- case topRight
43
- case bottomLeft
44
- case bottomRight
45
-
46
- var id: String { rawValue }
47
-
48
- var title: String {
49
- switch self {
50
- case .overview: return "All"
51
- case .main: return "Main"
52
- case .topRight: return "Top Right"
53
- case .bottomLeft: return "Bottom Left"
54
- case .bottomRight: return "Bottom Right"
55
- }
56
- }
57
-
58
- var shortLabel: String {
59
- switch self {
60
- case .overview: return "all"
61
- case .main: return "main"
62
- case .topRight: return "tr"
63
- case .bottomLeft: return "bl"
64
- case .bottomRight: return "br"
65
- }
66
- }
67
-
68
- var keyHint: String {
69
- switch self {
70
- case .overview: return "0"
71
- case .main: return "1"
72
- case .topRight: return "2"
73
- case .bottomLeft: return "3"
74
- case .bottomRight: return "4"
75
- }
76
- }
77
- }
78
-
79
- struct ScreenMapWindowSet: Identifiable, Equatable {
80
- let id: UUID
81
- var name: String
82
- var windowIds: Set<UInt32>
83
-
84
- init(id: UUID = UUID(), name: String, windowIds: Set<UInt32>) {
85
- self.id = id
86
- self.name = name
87
- self.windowIds = windowIds
88
- }
89
-
90
- var count: Int { windowIds.count }
91
- }
92
-
93
- // MARK: - Screen Map Window Entry
94
-
95
- struct ScreenMapWindowEntry: Identifiable {
96
- let id: UInt32 // CGWindowID
97
- let pid: Int32 // for AX API
98
- let app: String
99
- let title: String
100
- var originalFrame: CGRect // frozen at snapshot time
101
- var editedFrame: CGRect // mutated during drag
102
- var virtualFrame: CGRect // persistent canvas/world position
103
- let zIndex: Int // 0 = frontmost
104
- var layer: Int // assigned by iterative peeling (per-display)
105
- let displayIndex: Int // which monitor this window belongs to
106
- let isOnScreen: Bool // visible on current Space
107
- var latticesSession: String? // parsed from [lattices:name] in title
108
- var tmuxCommand: String? // running command from tmux pane (e.g. "vim", "node")
109
- var tmuxPaneTitle: String? // tmux pane title (often cwd or custom label)
110
- var hasEdits: Bool { originalFrame != editedFrame }
111
-
112
- /// Rich search key combining all available metadata.
113
- /// Format: m{spatial}.L{layer}.{layerName}.{app}.{title}.{session}.{command}.{paneTitle}.{state}
114
- /// Example: m1.L0.primary.terminal.~/dev/lattices.session:myproject.cmd:vim.visible
115
- func searchKey(spatialNumber: Int, layerName: String?) -> String {
116
- var parts: [String] = []
117
- parts.append("m\(spatialNumber)")
118
- parts.append(layerName.map { "L\(layer).\($0)" } ?? "L\(layer)")
119
- parts.append(app)
120
- parts.append(title.isEmpty ? "_" : title)
121
- if let session = latticesSession {
122
- parts.append("session:\(session)")
123
- }
124
- if let cmd = tmuxCommand, !cmd.isEmpty {
125
- parts.append("cmd:\(cmd)")
126
- }
127
- if let pTitle = tmuxPaneTitle, !pTitle.isEmpty, pTitle != title {
128
- parts.append(pTitle)
129
- }
130
- parts.append(isOnScreen ? "visible" : "hidden")
131
- return parts.joined(separator: ".").lowercased()
132
- }
133
- }
134
-
135
- // MARK: - Canvas Drag Mode
136
-
137
- enum CanvasDragMode {
138
- case move
139
- case resizeLeft, resizeRight, resizeTop, resizeBottom
140
- case resizeTopLeft, resizeTopRight, resizeBottomLeft, resizeBottomRight
141
- }
142
-
143
- // MARK: - Screen Map Editor State
144
-
145
- final class ScreenMapEditorState: ObservableObject {
146
- @Published var windows: [ScreenMapWindowEntry]
147
- @Published var selectedLayers: Set<Int> = [] // empty = show all
148
- @Published var draggingWindowId: UInt32? = nil
149
- var canvasDragMode: CanvasDragMode = .move
150
- var currentCursorMode: CanvasDragMode = .move
151
- @Published var isPreviewing: Bool = false
152
- @Published var lastActionRef: String? = nil
153
- @Published var zoomLevel: CGFloat = 1.0 // 1.0 = fit-all
154
- @Published var panOffset: CGPoint = .zero // canvas-local pixels
155
- @Published var focusedDisplayIndex: Int? = nil // nil = all-displays view
156
- @Published var activeViewportPreset: ScreenMapViewportPreset? = .overview
157
- @Published var windowSearchQuery: String = ""
158
- @Published var isTilingMode: Bool = false
159
- var isSearching: Bool { !windowSearchQuery.isEmpty }
160
-
161
- var searchFilteredWindows: [ScreenMapWindowEntry] {
162
- guard !windowSearchQuery.isEmpty else { return [] }
163
- let terms = windowSearchQuery.lowercased()
164
- .split(separator: " ")
165
- .map(String.init)
166
- .filter { !$0.isEmpty }
167
- guard !terms.isEmpty else { return [] }
168
-
169
- // Pre-compile glob patterns into matchers
170
- let matchers: [(String) -> Bool] = terms.map { term in
171
- if term.hasPrefix("/"), term.count > 1 {
172
- // Raw regex: /pattern/
173
- let raw = String(term.dropFirst().hasSuffix("/") ? term.dropFirst().dropLast() : term.dropFirst())
174
- return Self.regexMatcher(raw)
175
- } else if term.contains("*") || term.contains("?") {
176
- return Self.globMatcher(term)
177
- } else {
178
- return { key in key.contains(term) }
179
- }
180
- }
181
-
182
- return windows
183
- .filter { win in
184
- let key = win.searchKey(
185
- spatialNumber: spatialNumber(for: win.displayIndex),
186
- layerName: layerNames[win.layer]
187
- )
188
- return matchers.allSatisfy { $0(key) }
189
- }
190
- .sorted { $0.zIndex < $1.zIndex }
191
- }
192
-
193
- /// Convert a glob pattern (with * and ?) into a substring matcher closure.
194
- /// `*` matches any sequence of characters, `?` matches exactly one character.
195
- /// Pattern is matched as a substring unless anchored with `*` on both ends.
196
- private static func globMatcher(_ pattern: String) -> (String) -> Bool {
197
- // Convert glob to regex: escape regex-special chars, then * → .* and ? → .
198
- var regex = ""
199
- for ch in pattern {
200
- switch ch {
201
- case "*": regex += ".*"
202
- case "?": regex += "."
203
- case ".", "(", ")", "[", "]", "{", "}", "^", "$", "|", "+", "\\": regex += "\\\(ch)"
204
- default: regex += String(ch)
205
- }
206
- }
207
- guard let re = try? NSRegularExpression(pattern: regex, options: []) else {
208
- return { key in key.contains(pattern) }
209
- }
210
- return { key in
211
- re.firstMatch(in: key, range: NSRange(key.startIndex..., in: key)) != nil
212
- }
213
- }
214
-
215
- /// Raw regex matcher — term is an unescaped regex pattern.
216
- /// Case-insensitive. Falls back to literal contains on invalid regex.
217
- private static func regexMatcher(_ pattern: String) -> (String) -> Bool {
218
- guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
219
- return { key in key.contains(pattern) }
220
- }
221
- return { key in
222
- re.firstMatch(in: key, range: NSRange(key.startIndex..., in: key)) != nil
223
- }
224
- }
225
-
226
- var searchTerms: [String] {
227
- windowSearchQuery.lowercased()
228
- .split(separator: " ")
229
- .map(String.init)
230
- .filter { !$0.isEmpty }
231
- }
232
-
233
- var searchHasDirectHit: Bool {
234
- searchFilteredWindows.count == 1
235
- }
236
-
237
- var searchResultsByDisplay: [(displayIndex: Int, spatialNumber: Int, label: String, windows: [ScreenMapWindowEntry])] {
238
- let filtered = searchFilteredWindows
239
- guard !filtered.isEmpty else { return [] }
240
- let grouped = Dictionary(grouping: filtered) { $0.displayIndex }
241
- return grouped.keys.sorted { spatialNumber(for: $0) < spatialNumber(for: $1) }
242
- .map { idx in
243
- let label = displays.first(where: { $0.index == idx })?.label ?? "Display \(idx)"
244
- let wins = grouped[idx]!.sorted { $0.zIndex < $1.zIndex }
245
- return (idx, spatialNumber(for: idx), label, wins)
246
- }
247
- }
248
-
249
- /// Workspace layer names from workspace.json (layer index → label)
250
- var layerNames: [Int: String] = [:]
251
-
252
- static let minZoom: CGFloat = 0.3
253
- static let maxZoom: CGFloat = 5.0
254
-
255
- /// `scale` is the synced effective canvas scale (fit scale × zoom).
256
- var effectiveScale: CGFloat { scale }
257
-
258
- func resetZoomPan() {
259
- zoomLevel = 1.0
260
- panOffset = .zero
261
- }
262
-
263
- let actionLog = ScreenMapActionLog()
264
-
265
- func syncLayoutFrame(at index: Int, to frame: CGRect) {
266
- windows[index].virtualFrame = frame
267
- windows[index].editedFrame = frame
268
- }
269
-
270
- func resetLayoutFrameToOriginal(at index: Int) {
271
- windows[index].virtualFrame = windows[index].originalFrame
272
- windows[index].editedFrame = windows[index].originalFrame
273
- }
274
-
275
- /// Backward-compat: single active layer when exactly one is selected
276
- var activeLayer: Int? {
277
- selectedLayers.count == 1 ? selectedLayers.first : nil
278
- }
279
-
280
- func isLayerSelected(_ layer: Int) -> Bool {
281
- selectedLayers.isEmpty || selectedLayers.contains(layer)
282
- }
283
-
284
- var isShowingAll: Bool { selectedLayers.isEmpty }
285
- var dragStartFrame: CGRect? = nil
286
-
287
- // Cached geometry for coordinate conversion (set by the view)
288
- var fitScale: CGFloat = 1 // base fit-all scale (before zoom)
289
- var scale: CGFloat = 1 // effective scale (fitScale * zoomLevel)
290
- var mapOrigin: CGPoint = .zero
291
- var screenSize: CGSize = .zero
292
- var bboxOrigin: CGPoint = .zero // top-left of the bounding box in CG coords
293
- var viewportSize: CGSize = .zero
294
- var pendingCanvasNavigation: ScreenMapCanvasNavigationTarget?
295
- var canvasNavigationRevision: Int = 0
296
-
297
- let displays: [DisplayGeometry]
298
-
299
- init(windows: [ScreenMapWindowEntry], displays: [DisplayGeometry] = []) {
300
- self.windows = windows
301
- self.displays = displays
302
- }
303
-
304
- /// Number of distinct layers (global, all displays)
305
- var layerCount: Int {
306
- (windows.map(\.layer).max() ?? 0) + 1
307
- }
308
-
309
- /// Window count for a specific layer (for sidebar badges)
310
- func windowCount(for layer: Int) -> Int {
311
- windows.filter { $0.layer == layer }.count
312
- }
313
-
314
- // MARK: - Per-Display Layer Scoping
315
-
316
- /// Sorted unique layers present on a given display
317
- func layersForDisplay(_ displayIndex: Int) -> [Int] {
318
- let displayWindows = windows.filter { $0.displayIndex == displayIndex }
319
- return Array(Set(displayWindows.map(\.layer))).sorted()
320
- }
321
-
322
- /// Layers scoped to the focused display, or all layers when showing all displays
323
- var effectiveLayers: [Int] {
324
- guard let dIdx = focusedDisplayIndex else {
325
- return Array(0..<layerCount)
326
- }
327
- return layersForDisplay(dIdx)
328
- }
329
-
330
- /// Count of layers on the focused display (or global count)
331
- var effectiveLayerCount: Int {
332
- effectiveLayers.count
333
- }
334
-
335
- /// Total windows in the current display scope before any layer filter is applied.
336
- var scopedWindowCount: Int {
337
- guard let dIdx = focusedDisplayIndex else { return windows.count }
338
- return windows.filter { $0.displayIndex == dIdx }.count
339
- }
340
-
341
- /// Window count for a layer, scoped to the focused display
342
- func effectiveWindowCount(for layer: Int) -> Int {
343
- guard let dIdx = focusedDisplayIndex else {
344
- return windowCount(for: layer)
345
- }
346
- return windows.filter { $0.layer == layer && $0.displayIndex == dIdx }.count
347
- }
348
-
349
- /// Windows currently rendered in the main canvas.
350
- var renderedCanvasWindows: [ScreenMapWindowEntry] {
351
- focusedDisplayIndex != nil ? focusedVisibleWindows : visibleWindows
352
- }
353
-
354
- var namedEffectiveLayers: [Int] {
355
- effectiveLayers.filter { layerNames[$0] != nil }
356
- }
357
-
358
- var unnamedEffectiveLayers: [Int] {
359
- effectiveLayers.filter { layerNames[$0] == nil }
360
- }
361
-
362
- func layerTreeWindows(for layer: Int) -> [ScreenMapWindowEntry] {
363
- var scoped = windows.filter { $0.layer == layer }
364
- if let dIdx = focusedDisplayIndex {
365
- scoped = scoped.filter { $0.displayIndex == dIdx }
366
- }
367
- return scoped.sorted { $0.zIndex < $1.zIndex }
368
- }
369
-
370
- /// Visible window count per display index
371
- func visibleWindowCount(for displayIndex: Int) -> Int {
372
- visibleWindows.filter { $0.displayIndex == displayIndex }.count
373
- }
374
-
375
- /// Display name for a layer (from workspace config or fallback)
376
- func layerDisplayName(for layer: Int) -> String {
377
- if let name = layerNames[layer] {
378
- return String(name.prefix(8))
379
- }
380
- return "L\(layer)"
381
- }
382
-
383
- /// Windows visible on the current desktop for the active layer filter.
384
- var visibleWindows: [ScreenMapWindowEntry] {
385
- let onscreen = windows.filter(\.isOnScreen)
386
- guard !selectedLayers.isEmpty else { return onscreen }
387
- return onscreen.filter { selectedLayers.contains($0.layer) }
388
- }
389
-
390
- private var worldScopedDisplays: [DisplayGeometry] {
391
- guard let focusedDisplayIndex else { return displays }
392
- return displays.filter { $0.index == focusedDisplayIndex }
393
- }
394
-
395
- private var worldScopedWindows: [ScreenMapWindowEntry] {
396
- guard let focusedDisplayIndex else { return windows }
397
- return windows.filter { $0.displayIndex == focusedDisplayIndex }
398
- }
399
-
400
- var canvasWorldBounds: CGRect {
401
- var rects = worldScopedDisplays.map(\.cgRect)
402
- rects.append(contentsOf: worldScopedWindows.filter(\.hasEdits).map(\.virtualFrame))
403
-
404
- if rects.isEmpty {
405
- return CGRect(origin: bboxOrigin, size: screenSize)
406
- }
407
-
408
- var union = rects[0]
409
- for rect in rects.dropFirst() {
410
- union = union.union(rect)
411
- }
412
-
413
- let pad: CGFloat = focusedDisplayIndex == nil ? 180 : 120
414
- return union.insetBy(dx: -pad, dy: -pad)
415
- }
416
-
417
- var viewportWorldRect: CGRect {
418
- guard scale > 0, viewportSize.width > 0, viewportSize.height > 0 else {
419
- return canvasWorldBounds
420
- }
421
-
422
- let raw = CGRect(
423
- x: bboxOrigin.x - (mapOrigin.x + panOffset.x) / scale,
424
- y: bboxOrigin.y - (mapOrigin.y + panOffset.y) / scale,
425
- width: viewportSize.width / scale,
426
- height: viewportSize.height / scale
427
- )
428
-
429
- let world = canvasWorldBounds
430
- let clipped = raw.intersection(world)
431
- return clipped.isNull ? raw : clipped
432
- }
433
-
434
- func viewportRect(for preset: ScreenMapViewportPreset) -> CGRect {
435
- let world = canvasWorldBounds
436
- guard preset != .overview else { return world }
437
-
438
- let halfWidth = max(world.width / 2, 1)
439
- let halfHeight = max(world.height / 2, 1)
440
-
441
- switch preset {
442
- case .overview:
443
- return world
444
- case .main:
445
- return CGRect(x: world.minX, y: world.minY, width: halfWidth, height: halfHeight)
446
- case .topRight:
447
- return CGRect(x: world.midX, y: world.minY, width: halfWidth, height: halfHeight)
448
- case .bottomLeft:
449
- return CGRect(x: world.minX, y: world.midY, width: halfWidth, height: halfHeight)
450
- case .bottomRight:
451
- return CGRect(x: world.midX, y: world.midY, width: halfWidth, height: halfHeight)
452
- }
453
- }
454
-
455
- var viewportPresetSummary: String {
456
- activeViewportPreset?.title ?? "Custom"
457
- }
458
-
459
- func windows(matching ids: Set<UInt32>) -> [ScreenMapWindowEntry] {
460
- windows.filter { ids.contains($0.id) }
461
- }
462
-
463
- func rectForWindowIDs(_ ids: Set<UInt32>) -> CGRect? {
464
- let matched = windows(matching: ids)
465
- guard let first = matched.first else { return nil }
466
- return matched.dropFirst().reduce(first.virtualFrame) { $0.union($1.virtualFrame) }
467
- }
468
-
469
- var canvasExplorerRegions: [ScreenMapCanvasRegion] {
470
- let world = canvasWorldBounds
471
- var regions: [ScreenMapCanvasRegion] = [
472
- ScreenMapCanvasRegion(
473
- id: "overview",
474
- kind: .overview,
475
- title: focusedDisplayIndex == nil ? "All Displays" : "Display Canvas",
476
- subtitle: "\(worldScopedWindows.count) windows",
477
- rect: world,
478
- count: worldScopedWindows.count,
479
- displayIndex: focusedDisplayIndex,
480
- layer: nil
481
- )
482
- ]
483
-
484
- if focusedDisplayIndex == nil {
485
- for display in spatialDisplayOrder {
486
- let displayWindows = windows.filter { $0.displayIndex == display.index }
487
- let rect = regionRect(
488
- for: displayWindows,
489
- fallback: display.cgRect,
490
- padding: 60
491
- )
492
- regions.append(
493
- ScreenMapCanvasRegion(
494
- id: "display-\(display.index)",
495
- kind: .display,
496
- title: display.label,
497
- subtitle: "\(displayWindows.count) windows",
498
- rect: rect,
499
- count: displayWindows.count,
500
- displayIndex: display.index,
501
- layer: nil
502
- )
503
- )
504
- }
505
- }
506
-
507
- let layerScope = effectiveLayers.compactMap { layer -> ScreenMapCanvasRegion? in
508
- let layerWindows = worldScopedWindows.filter { $0.layer == layer }
509
- guard !layerWindows.isEmpty else { return nil }
510
-
511
- let displayLabel: String = {
512
- guard focusedDisplayIndex == nil,
513
- let displayIndex = layerWindows.first?.displayIndex,
514
- let display = displays.first(where: { $0.index == displayIndex }) else {
515
- return ""
516
- }
517
- return display.label
518
- }()
519
-
520
- let subtitleBase = "\(layerWindows.count) window\(layerWindows.count == 1 ? "" : "s")"
521
- let subtitle = displayLabel.isEmpty ? subtitleBase : "\(subtitleBase) · \(displayLabel)"
522
-
523
- return ScreenMapCanvasRegion(
524
- id: "layer-\(layer)-\(focusedDisplayIndex.map(String.init) ?? "all")",
525
- kind: .layer,
526
- title: layerNames[layer] ?? "Layer \(layer)",
527
- subtitle: subtitle,
528
- rect: regionRect(for: layerWindows, fallback: layerWindows[0].virtualFrame, padding: 48),
529
- count: layerWindows.count,
530
- displayIndex: layerWindows.first?.displayIndex,
531
- layer: layer
532
- )
533
- }
534
- .sorted { lhs, rhs in
535
- if lhs.count != rhs.count { return lhs.count > rhs.count }
536
- return lhs.title < rhs.title
537
- }
538
-
539
- regions.append(contentsOf: layerScope.prefix(6))
540
- return regions
541
- }
542
-
543
- func displayRegion(for displayIndex: Int) -> ScreenMapCanvasRegion? {
544
- canvasExplorerRegions.first {
545
- $0.kind == .display && $0.displayIndex == displayIndex
546
- }
547
- }
548
-
549
- private func regionRect(for windows: [ScreenMapWindowEntry], fallback: CGRect, padding: CGFloat) -> CGRect {
550
- guard !windows.isEmpty else { return fallback.insetBy(dx: -padding, dy: -padding) }
551
-
552
- var union = windows[0].virtualFrame
553
- for win in windows.dropFirst() {
554
- union = union.union(win.virtualFrame)
555
- }
556
- return union.insetBy(dx: -padding, dy: -padding)
557
- }
558
-
559
- /// The focused display geometry (nil when showing all)
560
- var focusedDisplay: DisplayGeometry? {
561
- guard let idx = focusedDisplayIndex else { return nil }
562
- return displays.first(where: { $0.index == idx })
563
- }
564
-
565
- /// Windows filtered by both layer AND focused display
566
- var focusedVisibleWindows: [ScreenMapWindowEntry] {
567
- let layerFiltered = visibleWindows
568
- guard let dIdx = focusedDisplayIndex else { return layerFiltered }
569
- return layerFiltered.filter { $0.displayIndex == dIdx }
570
- }
571
-
572
- /// Displays sorted by physical position (left-to-right, then top-to-bottom)
573
- var spatialDisplayOrder: [DisplayGeometry] {
574
- displays.sorted { a, b in
575
- if abs(a.cgRect.origin.x - b.cgRect.origin.x) > 10 {
576
- return a.cgRect.origin.x < b.cgRect.origin.x
577
- }
578
- return a.cgRect.origin.y < b.cgRect.origin.y
579
- }
580
- }
581
-
582
- /// 1-based spatial position for a display (left-to-right numbering)
583
- func spatialNumber(for displayIndex: Int) -> Int {
584
- let order = spatialDisplayOrder
585
- if let pos = order.firstIndex(where: { $0.index == displayIndex }) {
586
- return pos + 1
587
- }
588
- return displayIndex + 1
589
- }
590
-
591
- /// Set focus to a specific display (nil = all-displays view)
592
- func focusDisplay(_ index: Int?) {
593
- focusedDisplayIndex = index
594
- selectedLayers = [] // reset to "All" for the new display scope
595
- resetZoomPan()
596
- DiagnosticLog.shared.info("[Canvas] scope → \(canvasScopeSummary)")
597
- }
598
-
599
- /// Cycle to the next display in spatial (left-to-right) order
600
- func cycleNextDisplay() {
601
- let order = spatialDisplayOrder
602
- guard order.count > 1 else { return }
603
- guard let current = focusedDisplayIndex else {
604
- focusedDisplayIndex = order.first!.index
605
- selectedLayers = []
606
- resetZoomPan()
607
- DiagnosticLog.shared.info("[Canvas] scope → \(canvasScopeSummary)")
608
- return
609
- }
610
- if let pos = order.firstIndex(where: { $0.index == current }) {
611
- let next = pos + 1
612
- if next >= order.count {
613
- focusedDisplayIndex = nil // all-displays view
614
- } else {
615
- focusedDisplayIndex = order[next].index
616
- }
617
- } else {
618
- focusedDisplayIndex = nil
619
- }
620
- selectedLayers = []
621
- resetZoomPan()
622
- DiagnosticLog.shared.info("[Canvas] scope → \(canvasScopeSummary)")
623
- }
624
-
625
- /// Cycle to the previous display in spatial (right-to-left) order
626
- func cyclePreviousDisplay() {
627
- let order = spatialDisplayOrder
628
- guard order.count > 1 else { return }
629
- guard let current = focusedDisplayIndex else {
630
- focusedDisplayIndex = order.last!.index
631
- selectedLayers = []
632
- resetZoomPan()
633
- DiagnosticLog.shared.info("[Canvas] scope → \(canvasScopeSummary)")
634
- return
635
- }
636
- if let pos = order.firstIndex(where: { $0.index == current }) {
637
- if pos == 0 {
638
- focusedDisplayIndex = nil // all-displays view
639
- } else {
640
- focusedDisplayIndex = order[pos - 1].index
641
- }
642
- } else {
643
- focusedDisplayIndex = nil
644
- }
645
- selectedLayers = []
646
- resetZoomPan()
647
- DiagnosticLog.shared.info("[Canvas] scope → \(canvasScopeSummary)")
648
- }
649
-
650
- var canvasScopeSummary: String {
651
- guard let focusedDisplayIndex,
652
- let display = displays.first(where: { $0.index == focusedDisplayIndex }) else {
653
- return "all displays"
654
- }
655
- return "display \(spatialNumber(for: focusedDisplayIndex)) · \(display.label)"
656
- }
657
-
658
- /// Number of windows with pending edits (position or size)
659
- var pendingEditCount: Int {
660
- windows.filter(\.hasEdits).count
661
- }
662
-
663
- /// Cycle layer: first → … → last → empty(all) → first
664
- func cycleLayer() {
665
- let layers = effectiveLayers
666
- guard !layers.isEmpty else { return }
667
-
668
- if selectedLayers.count > 1 {
669
- selectedLayers = [layers[0]]
670
- return
671
- }
672
- guard let current = activeLayer else {
673
- selectedLayers = [layers[0]]
674
- return
675
- }
676
- guard let idx = layers.firstIndex(of: current) else {
677
- selectedLayers = [layers[0]]
678
- return
679
- }
680
- let nextIdx = idx + 1
681
- if nextIdx >= layers.count {
682
- selectedLayers = [] // all
683
- } else {
684
- selectedLayers = [layers[nextIdx]]
685
- }
686
- }
687
-
688
- /// Cycle layer backward
689
- func cyclePreviousLayer() {
690
- let layers = effectiveLayers
691
- guard !layers.isEmpty else { return }
692
-
693
- if selectedLayers.count > 1 {
694
- selectedLayers = [layers.last!]
695
- return
696
- }
697
- guard let current = activeLayer else {
698
- selectedLayers = [layers.last!]
699
- return
700
- }
701
- guard let idx = layers.firstIndex(of: current) else {
702
- selectedLayers = [layers.last!]
703
- return
704
- }
705
- if idx == 0 {
706
- selectedLayers = [] // all
707
- } else {
708
- selectedLayers = [layers[idx - 1]]
709
- }
710
- }
711
-
712
- /// Direct layer selection (from sidebar clicks)
713
- func selectLayer(_ layer: Int?) {
714
- DiagnosticLog.shared.info("[ScreenMap] selectLayer: \(layer.map { "\($0)" } ?? "all")")
715
- if let layer {
716
- selectedLayers = [layer]
717
- } else {
718
- selectedLayers = []
719
- }
720
- }
721
-
722
- /// Toggle a layer in multi-select (Cmd+click)
723
- func toggleLayerSelection(_ layer: Int) {
724
- if selectedLayers.contains(layer) {
725
- selectedLayers.remove(layer)
726
- } else {
727
- selectedLayers.insert(layer)
728
- }
729
- if selectedLayers.count >= effectiveLayerCount {
730
- selectedLayers = []
731
- }
732
- DiagnosticLog.shared.info("[ScreenMap] toggleLayer \(layer) → \(selectedLayers.sorted())")
733
- }
734
-
735
- /// Move a window to a different layer
736
- func reassignLayer(windowId: UInt32, toLayer: Int, fitToAvailable: Bool) {
737
- guard let idx = windows.firstIndex(where: { $0.id == windowId }) else { return }
738
- let oldFrame = windows[idx].virtualFrame
739
- windows[idx].layer = toLayer
740
- if fitToAvailable {
741
- fitWindowIntoLayer(at: idx)
742
- }
743
- let newFrame = windows[idx].virtualFrame
744
- if oldFrame != newFrame {
745
- DiagnosticLog.shared.info("[ScreenMap] reassign wid=\(windowId): fitted \(Int(oldFrame.origin.x)),\(Int(oldFrame.origin.y)) → \(Int(newFrame.origin.x)),\(Int(newFrame.origin.y))")
746
- }
747
- }
748
-
749
- /// Auto-resize a window to fit among siblings in its layer
750
- func fitWindowIntoLayer(at idx: Int) {
751
- let win = windows[idx]
752
- let siblings = windows.enumerated().filter { $0.offset != idx && $0.element.layer == win.layer }
753
- let siblingFrames = siblings.map(\.element.virtualFrame)
754
- let screenRect = CGRect(origin: .zero, size: screenSize)
755
- if let fitted = fitRect(win.virtualFrame, avoiding: siblingFrames, within: screenRect) {
756
- syncLayoutFrame(at: idx, to: fitted)
757
- }
758
- }
759
-
760
- /// Try to fit a rect avoiding collisions
761
- func fitRect(_ rect: CGRect, avoiding others: [CGRect], within bounds: CGRect) -> CGRect? {
762
- let collisions = others.filter { $0.intersects(rect) }
763
- if collisions.isEmpty { return nil }
764
-
765
- let minW: CGFloat = 100, minH: CGFloat = 50
766
- var candidates: [CGRect] = []
767
-
768
- for blocker in collisions {
769
- let rightClip = CGRect(x: rect.minX, y: rect.minY,
770
- width: blocker.minX - rect.minX, height: rect.height)
771
- if rightClip.width >= minW && rightClip.height >= minH { candidates.append(rightClip) }
772
-
773
- let bottomClip = CGRect(x: rect.minX, y: rect.minY,
774
- width: rect.width, height: blocker.minY - rect.minY)
775
- if bottomClip.width >= minW && bottomClip.height >= minH { candidates.append(bottomClip) }
776
-
777
- let pushRight = CGRect(x: blocker.maxX, y: rect.minY,
778
- width: rect.width, height: rect.height)
779
- if pushRight.maxX <= bounds.maxX { candidates.append(pushRight) }
780
-
781
- let pushDown = CGRect(x: rect.minX, y: blocker.maxY,
782
- width: rect.width, height: rect.height)
783
- if pushDown.maxY <= bounds.maxY { candidates.append(pushDown) }
784
- }
785
-
786
- let valid = candidates.filter { cand in
787
- cand.width >= minW && cand.height >= minH &&
788
- bounds.contains(cand) &&
789
- !others.contains(where: { $0.intersects(cand) })
790
- }
791
- return valid.max(by: { $0.width * $0.height < $1.width * $1.height })
792
- }
793
-
794
- /// Auto-tile the active layer's windows into a grid
795
- func autoTileLayer() -> Int {
796
- guard let layer = activeLayer else { return 0 }
797
-
798
- let screens = NSScreen.screens
799
- let primaryHeight = screens.first?.frame.height ?? 0
800
- var totalTiled = 0
801
- let diag = DiagnosticLog.shared
802
-
803
- diag.info("[Tile] autoTileLayer layer=\(layer) screens=\(screens.count)")
804
- var displayIndices = Set(windows.filter { $0.layer == layer }.map(\.displayIndex))
805
- if let focused = focusedDisplayIndex { displayIndices = displayIndices.intersection([focused]) }
806
- for dIdx in displayIndices.sorted() {
807
- var indices = windows.indices.filter { windows[$0].layer == layer && windows[$0].displayIndex == dIdx }
808
- guard indices.count >= 1 else { continue }
809
- indices.sort { windows[$0].zIndex < windows[$1].zIndex }
810
-
811
- let screen = dIdx < screens.count ? screens[dIdx] : screens.first!
812
- let visible = screen.visibleFrame
813
- let axTop = primaryHeight - visible.maxY
814
-
815
- if indices.count == 1 {
816
- let frame = CGRect(x: visible.origin.x, y: axTop, width: visible.width, height: visible.height)
817
- syncLayoutFrame(at: indices[0], to: frame)
818
- totalTiled += 1
819
- continue
820
- }
821
-
822
- let shape = WindowTiler.gridShape(for: indices.count)
823
- let rowCount = shape.count
824
- let totalW = Int(visible.width)
825
- let totalH = Int(visible.height)
826
- let baseX = Int(visible.origin.x)
827
- let baseY = Int(axTop)
828
-
829
- var slotIdx = 0
830
- for (row, cols) in shape.enumerated() {
831
- let y0 = baseY + (row * totalH) / rowCount
832
- let y1 = baseY + ((row + 1) * totalH) / rowCount
833
- for col in 0..<cols {
834
- guard slotIdx < indices.count else { break }
835
- let x0 = baseX + (col * totalW) / cols
836
- let x1 = baseX + ((col + 1) * totalW) / cols
837
- let frame = CGRect(x: CGFloat(x0), y: CGFloat(y0), width: CGFloat(x1 - x0), height: CGFloat(y1 - y0))
838
- syncLayoutFrame(at: indices[slotIdx], to: frame)
839
- slotIdx += 1
840
- }
841
- }
842
- totalTiled += indices.count
843
- }
844
- return totalTiled
845
- }
846
-
847
- /// Expose the active layer's windows with gaps
848
- func exposeLayer() -> Int {
849
- guard let layer = activeLayer else { return 0 }
850
-
851
- let screens = NSScreen.screens
852
- let primaryHeight = screens.first?.frame.height ?? 0
853
- var totalExposed = 0
854
-
855
- var displayIndices = Set(windows.filter { $0.layer == layer }.map(\.displayIndex))
856
- if let focused = focusedDisplayIndex { displayIndices = displayIndices.intersection([focused]) }
857
- for dIdx in displayIndices {
858
- var indices = windows.indices.filter { windows[$0].layer == layer && windows[$0].displayIndex == dIdx }
859
- guard indices.count >= 2 else { totalExposed += indices.count; continue }
860
- indices.sort { windows[$0].zIndex < windows[$1].zIndex }
861
-
862
- let screen = dIdx < screens.count ? screens[dIdx] : screens.first!
863
- let visible = screen.visibleFrame
864
- let axTop = primaryHeight - visible.maxY
865
- let padding: CGFloat = 20
866
- let shape = WindowTiler.gridShape(for: indices.count)
867
- let rowCount = shape.count
868
- let rowH = visible.height / CGFloat(rowCount)
869
-
870
- var slotIdx = 0
871
- for (row, cols) in shape.enumerated() {
872
- let colW = visible.width / CGFloat(cols)
873
- let axY = axTop + CGFloat(row) * rowH
874
- for col in 0..<cols {
875
- guard slotIdx < indices.count else { break }
876
- let idx = indices[slotIdx]
877
- let orig = windows[idx].virtualFrame
878
-
879
- let cellX = visible.origin.x + CGFloat(col) * colW + padding
880
- let cellY = axY + padding
881
- let cellW = colW - padding * 2
882
- let cellH = rowH - padding * 2
883
-
884
- let aspect = orig.width / max(orig.height, 1)
885
- var fitW = cellW
886
- var fitH = fitW / aspect
887
- if fitH > cellH {
888
- fitH = cellH
889
- fitW = fitH * aspect
890
- }
891
-
892
- let x = cellX + (cellW - fitW) / 2
893
- let y = cellY + (cellH - fitH) / 2
894
-
895
- syncLayoutFrame(at: idx, to: CGRect(x: x, y: y, width: fitW, height: fitH))
896
- slotIdx += 1
897
- }
898
- }
899
- totalExposed += indices.count
900
- }
901
- return totalExposed
902
- }
903
-
904
- /// Push overlapping windows apart with minimal movement
905
- func smartSpreadLayer() -> Int {
906
- guard let layer = activeLayer else { return 0 }
907
-
908
- let screens = NSScreen.screens
909
- let primaryHeight = screens.first?.frame.height ?? 0
910
- var totalAffected = 0
911
-
912
- var displayIndices = Set(windows.filter { $0.layer == layer }.map(\.displayIndex))
913
- if let focused = focusedDisplayIndex { displayIndices = displayIndices.intersection([focused]) }
914
- for dIdx in displayIndices {
915
- let indices = windows.indices.filter { windows[$0].layer == layer && windows[$0].displayIndex == dIdx }
916
- guard indices.count >= 2 else { continue }
917
-
918
- let screen = dIdx < screens.count ? screens[dIdx] : screens.first!
919
- let axTop = primaryHeight - screen.frame.maxY
920
- let screenRect = CGRect(x: screen.frame.origin.x, y: axTop,
921
- width: screen.frame.width, height: screen.frame.height)
922
- var affected: Set<Int> = []
923
-
924
- for _ in 0..<15 {
925
- var hadOverlap = false
926
- for i in 0..<indices.count {
927
- for j in (i + 1)..<indices.count {
928
- let idxA = indices[i]
929
- let idxB = indices[j]
930
- let a = windows[idxA].virtualFrame
931
- let b = windows[idxB].virtualFrame
932
- guard a.intersects(b) else { continue }
933
- hadOverlap = true
934
-
935
- let overlapW = min(a.maxX, b.maxX) - max(a.minX, b.minX)
936
- let overlapH = min(a.maxY, b.maxY) - max(a.minY, b.minY)
937
-
938
- if overlapW < overlapH {
939
- let push = (overlapW / 2).rounded(.up) + 1
940
- var newA = a
941
- var newB = b
942
- if a.midX <= b.midX {
943
- newA.origin.x -= push
944
- newB.origin.x += push
945
- } else {
946
- newA.origin.x += push
947
- newB.origin.x -= push
948
- }
949
- syncLayoutFrame(at: idxA, to: newA)
950
- syncLayoutFrame(at: idxB, to: newB)
951
- } else {
952
- let push = (overlapH / 2).rounded(.up) + 1
953
- var newA = a
954
- var newB = b
955
- if a.midY <= b.midY {
956
- newA.origin.y -= push
957
- newB.origin.y += push
958
- } else {
959
- newA.origin.y += push
960
- newB.origin.y -= push
961
- }
962
- syncLayoutFrame(at: idxA, to: newA)
963
- syncLayoutFrame(at: idxB, to: newB)
964
- }
965
- affected.insert(idxA)
966
- affected.insert(idxB)
967
- }
968
- }
969
- for idx in indices { clampToScreen(at: idx, bounds: screenRect) }
970
- if !hadOverlap { break }
971
- }
972
- totalAffected += affected.count
973
- }
974
- return totalAffected
975
- }
976
-
977
- private func clampToScreen(at idx: Int, bounds: CGRect) {
978
- var f = windows[idx].virtualFrame
979
- if f.minX < bounds.minX { f.origin.x = bounds.minX }
980
- if f.minY < bounds.minY { f.origin.y = bounds.minY }
981
- if f.maxX > bounds.maxX { f.origin.x = bounds.maxX - f.width }
982
- if f.maxY > bounds.maxY { f.origin.y = bounds.maxY - f.height }
983
- syncLayoutFrame(at: idx, to: f)
984
- }
985
-
986
- /// Grow each window outward until it hits a neighbor or screen edge
987
- func fitAvailableSpace() -> Int {
988
- guard let layer = activeLayer else { return 0 }
989
-
990
- let screens = NSScreen.screens
991
- let primaryHeight = screens.first?.frame.height ?? 0
992
- var totalAffected = 0
993
-
994
- var displayIndices = Set(windows.filter { $0.layer == layer }.map(\.displayIndex))
995
- if let focused = focusedDisplayIndex { displayIndices = displayIndices.intersection([focused]) }
996
-
997
- for dIdx in displayIndices {
998
- var indices = windows.indices.filter { windows[$0].layer == layer && windows[$0].displayIndex == dIdx }
999
- guard !indices.isEmpty else { continue }
1000
- indices.sort { windows[$0].zIndex < windows[$1].zIndex }
1001
-
1002
- let screen = dIdx < screens.count ? screens[dIdx] : screens.first!
1003
- let axTop = primaryHeight - screen.frame.maxY
1004
- let bounds = CGRect(x: screen.frame.origin.x, y: axTop,
1005
- width: screen.frame.width, height: screen.frame.height)
1006
-
1007
- // Snapshot original positions for neighbor detection
1008
- let origFrames = indices.map { windows[$0].virtualFrame }
1009
-
1010
- for (i, idx) in indices.enumerated() {
1011
- let me = origFrames[i]
1012
-
1013
- // Find nearest obstacle in each direction (only neighbors that overlap on the perpendicular axis)
1014
- var left = bounds.minX
1015
- var right = bounds.maxX
1016
- var top = bounds.minY
1017
- var bottom = bounds.maxY
1018
-
1019
- for (j, otherFrame) in origFrames.enumerated() where j != i {
1020
- // Left: other window whose right edge is to my left, overlapping vertically
1021
- if otherFrame.maxX <= me.minX + 1 &&
1022
- otherFrame.maxY > me.minY && otherFrame.minY < me.maxY {
1023
- left = max(left, otherFrame.maxX)
1024
- }
1025
- // Right: other window whose left edge is to my right, overlapping vertically
1026
- if otherFrame.minX >= me.maxX - 1 &&
1027
- otherFrame.maxY > me.minY && otherFrame.minY < me.maxY {
1028
- right = min(right, otherFrame.minX)
1029
- }
1030
- // Top: other window whose bottom edge is above me, overlapping horizontally
1031
- if otherFrame.maxY <= me.minY + 1 &&
1032
- otherFrame.maxX > me.minX && otherFrame.minX < me.maxX {
1033
- top = max(top, otherFrame.maxY)
1034
- }
1035
- // Bottom: other window whose top edge is below me, overlapping horizontally
1036
- if otherFrame.minY >= me.maxY - 1 &&
1037
- otherFrame.maxX > me.minX && otherFrame.minX < me.maxX {
1038
- bottom = min(bottom, otherFrame.minY)
1039
- }
1040
- }
1041
-
1042
- let newFrame = CGRect(x: left, y: top, width: right - left, height: bottom - top)
1043
- if newFrame != windows[idx].virtualFrame {
1044
- syncLayoutFrame(at: idx, to: newFrame)
1045
- totalAffected += 1
1046
- }
1047
- }
1048
- }
1049
- return totalAffected
1050
- }
1051
-
1052
- /// Distribute visible windows into a grid (staged — edits frames only)
1053
- func distributeLayer() -> Int {
1054
- let screens = NSScreen.screens
1055
- guard !screens.isEmpty else { return 0 }
1056
- var totalDistributed = 0
1057
-
1058
- // Group by display
1059
- var displayIndices: Set<Int>
1060
- if let focused = focusedDisplayIndex {
1061
- displayIndices = [focused]
1062
- } else {
1063
- displayIndices = Set(focusedVisibleWindows.map(\.displayIndex))
1064
- if displayIndices.isEmpty {
1065
- displayIndices = Set(visibleWindows.map(\.displayIndex))
1066
- }
1067
- }
1068
-
1069
- for dIdx in displayIndices.sorted() {
1070
- // Get windows to distribute on this display
1071
- var indices = windows.indices.filter { idx in
1072
- let win = windows[idx]
1073
- let layerMatch = selectedLayers.isEmpty || selectedLayers.contains(win.layer)
1074
- return win.displayIndex == dIdx && layerMatch
1075
- }
1076
- guard !indices.isEmpty else { continue }
1077
- indices.sort { windows[$0].zIndex < windows[$1].zIndex }
1078
-
1079
- let screen = dIdx < screens.count ? screens[dIdx] : screens.first!
1080
- let slots = WindowTiler.computeGridSlots(count: indices.count, screen: screen)
1081
- guard slots.count == indices.count else { continue }
1082
-
1083
- for (i, idx) in indices.enumerated() {
1084
- syncLayoutFrame(at: idx, to: slots[i])
1085
- }
1086
- totalDistributed += indices.count
1087
- }
1088
- return totalDistributed
1089
- }
1090
-
1091
- /// Reset all edited frames back to original
1092
- func discardEdits() {
1093
- for i in windows.indices {
1094
- resetLayoutFrameToOriginal(at: i)
1095
- }
1096
- }
1097
-
1098
- /// Remap sparse layer numbers to contiguous
1099
- func renumberLayersContiguous() {
1100
- let usedLayers = Set(windows.map(\.layer)).sorted()
1101
- guard usedLayers != Array(0..<usedLayers.count) else { return }
1102
- let mapping = Dictionary(uniqueKeysWithValues: usedLayers.enumerated().map { ($1, $0) })
1103
- for i in windows.indices {
1104
- windows[i].layer = mapping[windows[i].layer] ?? windows[i].layer
1105
- }
1106
- selectedLayers = Set(selectedLayers.compactMap { mapping[$0] })
1107
- }
1108
-
1109
- /// Consolidate windows into fewer layers
1110
- func consolidateLayers() -> (old: Int, new: Int) {
1111
- let oldCount = effectiveLayerCount
1112
- let scopedLayers = effectiveLayers
1113
- guard let maxLayer = scopedLayers.last, maxLayer >= 1 else { return (oldCount, oldCount) }
1114
-
1115
- let screenRect = CGRect(origin: .zero, size: screenSize)
1116
- let dIdx = focusedDisplayIndex
1117
-
1118
- for sourceLayer in stride(from: maxLayer, through: 1, by: -1) {
1119
- guard scopedLayers.contains(sourceLayer) else { continue }
1120
- let windowIndices = windows.indices.filter {
1121
- windows[$0].layer == sourceLayer &&
1122
- (dIdx == nil || windows[$0].displayIndex == dIdx!)
1123
- }
1124
- for idx in windowIndices {
1125
- let win = windows[idx]
1126
- for targetLayer in scopedLayers where targetLayer < sourceLayer {
1127
- let siblings = windows.enumerated().filter {
1128
- $0.offset != idx && $0.element.layer == targetLayer &&
1129
- (dIdx == nil || $0.element.displayIndex == dIdx!)
1130
- }.map(\.element.virtualFrame)
1131
-
1132
- let collisions = siblings.filter { $0.intersects(win.virtualFrame) }
1133
- if collisions.isEmpty {
1134
- windows[idx].layer = targetLayer
1135
- break
1136
- }
1137
- if let fitted = fitRect(win.virtualFrame, avoiding: siblings, within: screenRect) {
1138
- syncLayoutFrame(at: idx, to: fitted)
1139
- windows[idx].layer = targetLayer
1140
- break
1141
- }
1142
- }
1143
- }
1144
- }
1145
-
1146
- renumberLayersContiguous()
1147
- let newLayers = effectiveLayers
1148
- selectedLayers = newLayers.isEmpty ? [] : [newLayers[0]]
1149
- return (old: oldCount, new: effectiveLayerCount)
1150
- }
1151
-
1152
- var layerLabel: String {
1153
- if selectedLayers.isEmpty { return "ALL" }
1154
- if selectedLayers.count == 1 { return "LAYER \(selectedLayers.first!)" }
1155
- return selectedLayers.sorted().map { "L\($0)" }.joined(separator: "+")
1156
- }
1157
-
1158
- /// Merge all windows from selected layers into the lowest one
1159
- func flattenSelectedLayers() -> (count: Int, target: Int)? {
1160
- guard selectedLayers.count >= 2 else { return nil }
1161
- let sorted = selectedLayers.sorted()
1162
- let target = sorted[0]
1163
- let higherLayers = Set(sorted.dropFirst())
1164
- let dIdx = focusedDisplayIndex
1165
-
1166
- var moveCount = 0
1167
- for idx in windows.indices where higherLayers.contains(windows[idx].layer) {
1168
- if let dIdx, windows[idx].displayIndex != dIdx { continue }
1169
- windows[idx].layer = target
1170
- fitWindowIntoLayer(at: idx)
1171
- moveCount += 1
1172
- }
1173
-
1174
- renumberLayersContiguous()
1175
- selectedLayers = target < layerCount ? [target] : []
1176
- return (count: moveCount, target: target)
1177
- }
1178
- }
1179
-
1180
- // MARK: - Screen Map Action Log
1181
-
1182
- final class ScreenMapActionLog {
1183
- struct WindowSnapshot: Codable {
1184
- let wid: UInt32
1185
- let app: String
1186
- let title: String
1187
- let frame: FrameSnapshot
1188
- let layer: Int
1189
-
1190
- struct FrameSnapshot: Codable {
1191
- let x: Int
1192
- let y: Int
1193
- let w: Int
1194
- let h: Int
1195
- }
1196
- }
1197
-
1198
- struct MovedWindow: Codable {
1199
- let wid: UInt32
1200
- let app: String
1201
- let title: String
1202
- let fromFrame: WindowSnapshot.FrameSnapshot
1203
- let toFrame: WindowSnapshot.FrameSnapshot
1204
- let fromLayer: Int
1205
- let toLayer: Int
1206
- }
1207
-
1208
- struct Entry: Codable {
1209
- let ref: String
1210
- let action: String
1211
- let timestamp: String
1212
- let summary: String
1213
- let before: [WindowSnapshot]
1214
- let after: [WindowSnapshot]
1215
- let moved: [MovedWindow]
1216
- }
1217
-
1218
- private(set) var lastEntry: Entry? = nil
1219
-
1220
- private static var logFileURL: URL = {
1221
- let dir = FileManager.default.homeDirectoryForCurrentUser
1222
- .appendingPathComponent(".lattices", isDirectory: true)
1223
- .appendingPathComponent("logs", isDirectory: true)
1224
- try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1225
- return dir.appendingPathComponent("actions.jsonl")
1226
- }()
1227
-
1228
- private static func shortUUID() -> String {
1229
- let uuid = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
1230
- return String(uuid.suffix(8))
1231
- }
1232
-
1233
- func snapshot(_ windows: [ScreenMapWindowEntry]) -> [WindowSnapshot] {
1234
- windows.map { win in
1235
- WindowSnapshot(
1236
- wid: win.id, app: win.app, title: win.title,
1237
- frame: .init(
1238
- x: Int(win.virtualFrame.origin.x), y: Int(win.virtualFrame.origin.y),
1239
- w: Int(win.virtualFrame.width), h: Int(win.virtualFrame.height)
1240
- ),
1241
- layer: win.layer
1242
- )
1243
- }
1244
- }
1245
-
1246
- func record(action: String, summary: String,
1247
- before: [WindowSnapshot], after: [WindowSnapshot]) -> Entry {
1248
- let ref = Self.shortUUID()
1249
-
1250
- var afterByWid: [UInt32: WindowSnapshot] = [:]
1251
- for snap in after { afterByWid[snap.wid] = snap }
1252
-
1253
- var moved: [MovedWindow] = []
1254
- for b in before {
1255
- guard let a = afterByWid[b.wid] else { continue }
1256
- let frameChanged = b.frame.x != a.frame.x || b.frame.y != a.frame.y
1257
- || b.frame.w != a.frame.w || b.frame.h != a.frame.h
1258
- let layerChanged = b.layer != a.layer
1259
- if frameChanged || layerChanged {
1260
- moved.append(MovedWindow(
1261
- wid: b.wid, app: b.app, title: b.title,
1262
- fromFrame: b.frame, toFrame: a.frame,
1263
- fromLayer: b.layer, toLayer: a.layer
1264
- ))
1265
- }
1266
- }
1267
-
1268
- let iso = ISO8601DateFormatter()
1269
- iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
1270
-
1271
- let entry = Entry(
1272
- ref: ref, action: action,
1273
- timestamp: iso.string(from: Date()),
1274
- summary: summary,
1275
- before: before, after: after, moved: moved
1276
- )
1277
-
1278
- let compactEncoder = JSONEncoder()
1279
- compactEncoder.outputFormatting = [.sortedKeys]
1280
- if let data = try? compactEncoder.encode(entry),
1281
- var line = String(data: data, encoding: .utf8) {
1282
- line += "\n"
1283
- if let lineData = line.data(using: .utf8) {
1284
- if FileManager.default.fileExists(atPath: Self.logFileURL.path) {
1285
- if let fh = try? FileHandle(forWritingTo: Self.logFileURL) {
1286
- fh.seekToEndOfFile()
1287
- fh.write(lineData)
1288
- fh.closeFile()
1289
- }
1290
- } else {
1291
- try? lineData.write(to: Self.logFileURL)
1292
- }
1293
- }
1294
- }
1295
-
1296
- DiagnosticLog.shared.info("[ScreenMapAction] \(ref) \(action): \(summary) (\(moved.count) moved)")
1297
- lastEntry = entry
1298
- return entry
1299
- }
1300
-
1301
- func lastEntryJSON() -> String? {
1302
- guard let entry = lastEntry else { return nil }
1303
- let encoder = JSONEncoder()
1304
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
1305
- guard let data = try? encoder.encode(entry) else { return nil }
1306
- return String(data: data, encoding: .utf8)
1307
- }
1308
-
1309
- func verify() {
1310
- guard let last = lastEntry else { return }
1311
- let intendedByWid: [UInt32: WindowSnapshot] = Dictionary(
1312
- last.after.map { ($0.wid, $0) }, uniquingKeysWith: { _, b in b }
1313
- )
1314
- guard !intendedByWid.isEmpty else { return }
1315
-
1316
- guard let rawList = CGWindowListCopyWindowInfo(
1317
- [.optionAll, .excludeDesktopElements], kCGNullWindowID
1318
- ) as? [[String: Any]] else { return }
1319
-
1320
- var actual: [WindowSnapshot] = []
1321
- var drifted: [MovedWindow] = []
1322
-
1323
- for info in rawList {
1324
- guard let wid = info[kCGWindowNumber as String] as? UInt32,
1325
- let intended = intendedByWid[wid],
1326
- let bounds = info[kCGWindowBounds as String] as? [String: Any],
1327
- let cgX = bounds["X"] as? CGFloat,
1328
- let cgY = bounds["Y"] as? CGFloat,
1329
- let cgW = bounds["Width"] as? CGFloat,
1330
- let cgH = bounds["Height"] as? CGFloat else { continue }
1331
-
1332
- let snap = WindowSnapshot(
1333
- wid: wid, app: intended.app, title: intended.title,
1334
- frame: .init(x: Int(cgX), y: Int(cgY), w: Int(cgW), h: Int(cgH)),
1335
- layer: intended.layer
1336
- )
1337
- actual.append(snap)
1338
-
1339
- let i = intended.frame
1340
- let a = snap.frame
1341
- if i.x != a.x || i.y != a.y || i.w != a.w || i.h != a.h {
1342
- drifted.append(MovedWindow(
1343
- wid: wid, app: intended.app, title: intended.title,
1344
- fromFrame: intended.frame, toFrame: a,
1345
- fromLayer: intended.layer, toLayer: intended.layer
1346
- ))
1347
- }
1348
- }
1349
-
1350
- let iso = ISO8601DateFormatter()
1351
- iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
1352
-
1353
- let summary = drifted.isEmpty
1354
- ? "Verified \(actual.count) windows — all match"
1355
- : "Verified \(actual.count) windows — \(drifted.count) drifted"
1356
-
1357
- let entry = Entry(
1358
- ref: last.ref, action: "verify",
1359
- timestamp: iso.string(from: Date()),
1360
- summary: summary,
1361
- before: last.after, after: actual, moved: drifted
1362
- )
1363
-
1364
- let compactEncoder = JSONEncoder()
1365
- compactEncoder.outputFormatting = [.sortedKeys]
1366
- if let data = try? compactEncoder.encode(entry),
1367
- var line = String(data: data, encoding: .utf8) {
1368
- line += "\n"
1369
- if let lineData = line.data(using: .utf8) {
1370
- if FileManager.default.fileExists(atPath: Self.logFileURL.path) {
1371
- if let fh = try? FileHandle(forWritingTo: Self.logFileURL) {
1372
- fh.seekToEndOfFile()
1373
- fh.write(lineData)
1374
- fh.closeFile()
1375
- }
1376
- } else {
1377
- try? lineData.write(to: Self.logFileURL)
1378
- }
1379
- }
1380
- }
1381
-
1382
- DiagnosticLog.shared.info("[ScreenMapAction] verify \(last.ref): \(summary)")
1383
- }
1384
- }
1385
-
1386
- // MARK: - Screen Map Controller
1387
-
1388
- final class ScreenMapController: ObservableObject {
1389
- @Published var editor: ScreenMapEditorState? {
1390
- didSet { bindEditor() }
1391
- }
1392
- @Published var selectedWindowIds: Set<UInt32> = [] {
1393
- didSet { syncSharedSelection() }
1394
- }
1395
- @Published var windowSets: [ScreenMapWindowSet] = []
1396
- @Published var activeWindowSetID: UUID? = nil
1397
- @Published var flashMessage: String? = nil
1398
- @Published var previewCaptures: [UInt32: NSImage] = [:]
1399
- @Published var savedPositions: [UInt32: (pid: Int32, frame: WindowFrame)]? = nil
1400
- @Published var isSearchActive: Bool = false
1401
- @Published var searchHighlightIndex: Int = 0
1402
-
1403
- enum DisplayTransitionDirection {
1404
- case left, right, none
1405
- }
1406
- @Published var displayTransition: DisplayTransitionDirection = .none
1407
- private var editorObserver: AnyCancellable?
1408
-
1409
- var previewWindow: NSWindow? = nil
1410
- private var previewGlobalMonitor: Any? = nil
1411
- private var previewLocalMonitor: Any? = nil
1412
-
1413
- var onDismiss: (() -> Void)?
1414
-
1415
- enum DisplayFocusDirection {
1416
- case previous
1417
- case next
1418
- }
1419
-
1420
- private func bindEditor() {
1421
- editorObserver = editor?.objectWillChange.sink { [weak self] _ in
1422
- self?.objectWillChange.send()
1423
- }
1424
- }
1425
-
1426
- private func finalizeDisplayFocusChange(flashLabel: Bool) {
1427
- guard let ed = editor else { return }
1428
- focusViewportPreset(ed.activeViewportPreset ?? .overview, flashView: false)
1429
- if flashLabel {
1430
- flash(ed.focusedDisplay?.label ?? "All displays")
1431
- }
1432
- objectWillChange.send()
1433
- }
1434
-
1435
- func setDisplayFocus(_ index: Int?, flashLabel: Bool = false) {
1436
- guard let ed = editor else { return }
1437
- ed.focusDisplay(index)
1438
- finalizeDisplayFocusChange(flashLabel: flashLabel)
1439
- }
1440
-
1441
- func stepDisplayFocus(_ direction: DisplayFocusDirection, flashLabel: Bool = true) {
1442
- guard let ed = editor, ed.displays.count > 1 else { return }
1443
- switch direction {
1444
- case .previous:
1445
- ed.cyclePreviousDisplay()
1446
- case .next:
1447
- ed.cycleNextDisplay()
1448
- }
1449
- finalizeDisplayFocusChange(flashLabel: flashLabel)
1450
- }
1451
-
1452
- func adjustZoom(by delta: CGFloat) {
1453
- guard let ed = editor else { return }
1454
- let newZoom = max(ScreenMapEditorState.minZoom, min(ScreenMapEditorState.maxZoom, ed.zoomLevel + delta))
1455
- guard newZoom != ed.zoomLevel else { return }
1456
- ed.activeViewportPreset = nil
1457
- ed.zoomLevel = newZoom
1458
- ed.scale = ed.fitScale * newZoom
1459
- objectWillChange.send()
1460
- }
1461
-
1462
- // MARK: - Selection
1463
-
1464
- func isSelected(_ id: UInt32) -> Bool { selectedWindowIds.contains(id) }
1465
-
1466
- var activeWindowSet: ScreenMapWindowSet? {
1467
- guard let activeWindowSetID else { return nil }
1468
- return windowSets.first(where: { $0.id == activeWindowSetID })
1469
- }
1470
-
1471
- func selectSingle(_ id: UInt32) {
1472
- navigateToWindowDisplay(id)
1473
- selectedWindowIds = [id]
1474
- activeWindowSetID = nil
1475
- }
1476
-
1477
- func toggleSelection(_ id: UInt32) {
1478
- if selectedWindowIds.contains(id) {
1479
- selectedWindowIds.remove(id)
1480
- } else {
1481
- selectedWindowIds.insert(id)
1482
- }
1483
- activeWindowSetID = nil
1484
- }
1485
-
1486
- func clearSelection() {
1487
- selectedWindowIds = []
1488
- activeWindowSetID = nil
1489
- }
1490
-
1491
- private func syncSharedSelection() {
1492
- guard !selectedWindowIds.isEmpty else {
1493
- WindowSelectionStore.shared.clear(source: "screen-map")
1494
- return
1495
- }
1496
-
1497
- guard let editor else { return }
1498
- let summaries = editor.windows
1499
- .filter { selectedWindowIds.contains($0.id) }
1500
- .map {
1501
- SelectedWindowSummary(
1502
- wid: $0.id,
1503
- app: $0.app,
1504
- title: $0.title,
1505
- latticesSession: $0.latticesSession
1506
- )
1507
- }
1508
-
1509
- guard !summaries.isEmpty else { return }
1510
- WindowSelectionStore.shared.setSelection(summaries, source: "screen-map")
1511
- }
1512
-
1513
- func selectNextWindow() {
1514
- guard let ed = editor else { return }
1515
- let wins = ed.focusedVisibleWindows.sorted(by: { $0.zIndex < $1.zIndex })
1516
- guard !wins.isEmpty else { return }
1517
- if selectedWindowIds.count == 1, let current = selectedWindowIds.first,
1518
- let idx = wins.firstIndex(where: { $0.id == current }) {
1519
- let next = wins[(idx + 1) % wins.count]
1520
- selectedWindowIds = [next.id]
1521
- } else {
1522
- selectedWindowIds = [wins[0].id]
1523
- }
1524
- activeWindowSetID = nil
1525
- objectWillChange.send()
1526
- }
1527
-
1528
- func selectPreviousWindow() {
1529
- guard let ed = editor else { return }
1530
- let wins = ed.focusedVisibleWindows.sorted(by: { $0.zIndex < $1.zIndex })
1531
- guard !wins.isEmpty else { return }
1532
- if selectedWindowIds.count == 1, let current = selectedWindowIds.first,
1533
- let idx = wins.firstIndex(where: { $0.id == current }) {
1534
- let prev = wins[(idx - 1 + wins.count) % wins.count]
1535
- selectedWindowIds = [prev.id]
1536
- } else {
1537
- selectedWindowIds = [wins[wins.count - 1].id]
1538
- }
1539
- activeWindowSetID = nil
1540
- objectWillChange.send()
1541
- }
1542
-
1543
- func selectAll() {
1544
- guard let ed = editor else { return }
1545
- let allIds = Set(ed.focusedVisibleWindows.map(\.id))
1546
- selectedWindowIds = allIds
1547
- activeWindowSetID = nil
1548
- flash("Selected \(allIds.count) windows")
1549
- objectWillChange.send()
1550
- }
1551
-
1552
- // MARK: - Window Sets
1553
-
1554
- func createWindowSetFromSelection() {
1555
- let ids = selectedWindowIds
1556
- guard !ids.isEmpty else {
1557
- flash("Select windows first")
1558
- return
1559
- }
1560
- if let existing = windowSets.first(where: { $0.windowIds == ids }) {
1561
- activeWindowSetID = existing.id
1562
- flash("\(existing.name) already saved")
1563
- return
1564
- }
1565
-
1566
- let set = ScreenMapWindowSet(name: "Set \(windowSets.count + 1)", windowIds: ids)
1567
- windowSets.append(set)
1568
- activeWindowSetID = set.id
1569
- flash("Saved \(set.name)")
1570
- objectWillChange.send()
1571
- }
1572
-
1573
- func focusWindowSet(_ set: ScreenMapWindowSet) {
1574
- guard let ed = editor else { return }
1575
- let liveIds = Set(ed.windows(matching: set.windowIds).map(\.id))
1576
- guard !liveIds.isEmpty else {
1577
- flash("\(set.name) is empty")
1578
- return
1579
- }
1580
-
1581
- selectedWindowIds = liveIds
1582
- activeWindowSetID = set.id
1583
- if let rect = ed.rectForWindowIDs(liveIds) {
1584
- focusCanvas(on: rect.insetBy(dx: -80, dy: -80), zoomToFit: true)
1585
- }
1586
- flash(set.name)
1587
- objectWillChange.send()
1588
- }
1589
-
1590
- func deleteWindowSet(_ set: ScreenMapWindowSet) {
1591
- windowSets.removeAll { $0.id == set.id }
1592
- if activeWindowSetID == set.id {
1593
- activeWindowSetID = nil
1594
- }
1595
- flash("Removed \(set.name)")
1596
- objectWillChange.send()
1597
- }
1598
-
1599
- // MARK: - Search
1600
-
1601
- var searchHighlightedWindowId: UInt32? {
1602
- guard isSearchActive, let ed = editor else { return nil }
1603
- let results = ed.searchFilteredWindows
1604
- guard !results.isEmpty else { return nil }
1605
- let idx = max(0, min(searchHighlightIndex, results.count - 1))
1606
- return results[idx].id
1607
- }
1608
-
1609
- func openSearch() {
1610
- isSearchActive = true
1611
- searchHighlightIndex = 0
1612
- }
1613
-
1614
- func closeSearch() {
1615
- isSearchActive = false
1616
- searchHighlightIndex = 0
1617
- editor?.windowSearchQuery = ""
1618
- }
1619
-
1620
- func searchSelectHighlighted() {
1621
- guard let wid = searchHighlightedWindowId else { return }
1622
- selectSingle(wid)
1623
- // Direct hit (single result) → close search immediately
1624
- if editor?.searchHasDirectHit == true {
1625
- closeSearch()
1626
- }
1627
- // Multiple results → stay open, just select
1628
- }
1629
-
1630
- /// Switch display focus to match a window's display, with directional animation
1631
- func navigateToWindowDisplay(_ windowId: UInt32) {
1632
- guard let ed = editor,
1633
- let win = ed.windows.first(where: { $0.id == windowId }) else { return }
1634
- let targetDisplay = win.displayIndex
1635
- guard ed.focusedDisplayIndex != nil,
1636
- ed.focusedDisplayIndex != targetDisplay else { return }
1637
-
1638
- let fromSpatial = ed.spatialNumber(for: ed.focusedDisplayIndex!)
1639
- let toSpatial = ed.spatialNumber(for: targetDisplay)
1640
- displayTransition = toSpatial > fromSpatial ? .right : .left
1641
-
1642
- ed.focusDisplay(targetDisplay)
1643
- objectWillChange.send()
1644
-
1645
- // Clear transition after animation completes
1646
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
1647
- self?.displayTransition = .none
1648
- }
1649
- }
1650
-
1651
- func focusWindowOnScreen(_ windowId: UInt32) {
1652
- guard let ed = editor,
1653
- let win = ed.windows.first(where: { $0.id == windowId }) else { return }
1654
- if isSearchActive { closeSearch() }
1655
- selectSingle(windowId)
1656
- // Raise the target window and let it stay on top (don't re-activate Lattices)
1657
- WindowTiler.focusWindow(wid: win.id, pid: win.pid)
1658
- WindowTiler.highlightWindowById(wid: win.id)
1659
- }
1660
-
1661
- func focusSelectedWindowOnScreen() {
1662
- if isSearchActive, let wid = searchHighlightedWindowId {
1663
- focusWindowOnScreen(wid)
1664
- } else if selectedWindowIds.count == 1, let wid = selectedWindowIds.first {
1665
- focusWindowOnScreen(wid)
1666
- }
1667
- }
1668
-
1669
- func searchNavigate(delta: Int) {
1670
- guard let ed = editor else { return }
1671
- let count = ed.searchFilteredWindows.count
1672
- guard count > 0 else { return }
1673
- searchHighlightIndex = (searchHighlightIndex + delta + count) % count
1674
- objectWillChange.send()
1675
- }
1676
-
1677
- // MARK: - Enter
1678
-
1679
- func enter() {
1680
- let existingSets = windowSets
1681
- guard let windowList = CGWindowListCopyWindowInfo(
1682
- [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
1683
- ) as? [[String: Any]] else { return }
1684
-
1685
- struct CGWin {
1686
- let wid: UInt32; let pid: Int32; let app: String; let title: String
1687
- let frame: CGRect; let layer: Int; let displayIndex: Int
1688
- let isOnScreen: Bool
1689
- }
1690
-
1691
- let screens = NSScreen.screens
1692
- let primaryHeight = screens.first?.frame.height ?? 0
1693
-
1694
- func displayIndex(for frame: CGRect) -> Int {
1695
- let centerX = frame.midX
1696
- let centerY = frame.midY
1697
- for (i, screen) in screens.enumerated() {
1698
- let cgOriginY = primaryHeight - screen.frame.maxY
1699
- let cgRect = CGRect(x: screen.frame.origin.x, y: cgOriginY,
1700
- width: screen.frame.width, height: screen.frame.height)
1701
- if cgRect.contains(CGPoint(x: centerX, y: centerY)) {
1702
- return i
1703
- }
1704
- }
1705
- var bestIdx = 0
1706
- var bestDist = CGFloat.infinity
1707
- for (i, screen) in screens.enumerated() {
1708
- let cgOriginY = primaryHeight - screen.frame.maxY
1709
- let cgRect = CGRect(x: screen.frame.origin.x, y: cgOriginY,
1710
- width: screen.frame.width, height: screen.frame.height)
1711
- let dx = centerX - cgRect.midX
1712
- let dy = centerY - cgRect.midY
1713
- let dist = dx * dx + dy * dy
1714
- if dist < bestDist { bestDist = dist; bestIdx = i }
1715
- }
1716
- return bestIdx
1717
- }
1718
-
1719
- var ordered: [CGWin] = []
1720
- for info in windowList {
1721
- guard let wid = info[kCGWindowNumber as String] as? UInt32,
1722
- let layer = info[kCGWindowLayer as String] as? Int,
1723
- layer == 0,
1724
- let boundsDict = info[kCGWindowBounds as String] as? NSDictionary else { continue }
1725
- var rect = CGRect.zero
1726
- guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { continue }
1727
- guard rect.width >= 100 && rect.height >= 50 else { continue }
1728
- let app = info[kCGWindowOwnerName as String] as? String ?? ""
1729
- if app == "Lattices" || app == "lattices" || app == "AutoFill" || app == "Codex Computer Use" { continue }
1730
- let pid = info[kCGWindowOwnerPID as String] as? Int32 ?? 0
1731
- let title = info[kCGWindowName as String] as? String ?? ""
1732
- let dIdx = displayIndex(for: rect)
1733
- let onScreen = (info[kCGWindowIsOnscreen as String] as? Bool) ?? false
1734
- ordered.append(CGWin(wid: wid, pid: pid, app: app, title: title, frame: rect, layer: layer, displayIndex: dIdx, isOnScreen: onScreen))
1735
- }
1736
-
1737
- NSLog("[ScreenMap] enter: %d windows after filtering", ordered.count)
1738
-
1739
- // Iterative peeling PER DISPLAY
1740
- func significantOverlap(_ a: CGRect, _ b: CGRect) -> Bool {
1741
- let inter = a.intersection(b)
1742
- guard !inter.isNull && inter.width > 0 && inter.height > 0 else { return false }
1743
- let interArea = inter.width * inter.height
1744
- let smallerArea = min(a.width * a.height, b.width * b.height)
1745
- guard smallerArea > 0 else { return false }
1746
- return interArea / smallerArea >= 0.15
1747
- }
1748
-
1749
- var byDisplay: [Int: [Int]] = [:]
1750
- for i in ordered.indices {
1751
- byDisplay[ordered[i].displayIndex, default: []].append(i)
1752
- }
1753
-
1754
- var layerAssignment = [Int: Int]()
1755
- for (_, displayIndices) in byDisplay {
1756
- var remaining = Set(displayIndices)
1757
- var layer = 0
1758
- while !remaining.isEmpty {
1759
- var unoccluded: [Int] = []
1760
- for i in remaining {
1761
- let frame = ordered[i].frame
1762
- let isOccluded = remaining.contains(where: { j in
1763
- j < i && significantOverlap(ordered[j].frame, frame)
1764
- })
1765
- if !isOccluded { unoccluded.append(i) }
1766
- }
1767
- if unoccluded.isEmpty {
1768
- for i in remaining { layerAssignment[i] = layer }
1769
- remaining.removeAll()
1770
- break
1771
- }
1772
- for i in unoccluded {
1773
- layerAssignment[i] = layer
1774
- remaining.remove(i)
1775
- }
1776
- layer += 1
1777
- }
1778
- }
1779
-
1780
- // Build tmux PID → context lookup from TmuxModel
1781
- let latticesSessionRegex = try? NSRegularExpression(pattern: "\\[lattices:([^\\]]+)\\]")
1782
- var tmuxPidLookup: [Int32: (command: String, paneTitle: String, session: String)] = [:]
1783
- for session in TmuxModel.shared.sessions {
1784
- for pane in session.panes {
1785
- // Map pane PID and all child PIDs to this context
1786
- tmuxPidLookup[Int32(pane.pid)] = (pane.currentCommand, pane.title, session.name)
1787
- }
1788
- }
1789
-
1790
- var mapWindows: [ScreenMapWindowEntry] = []
1791
- for (i, win) in ordered.enumerated() {
1792
- let assignedLayer = layerAssignment[i] ?? 0
1793
-
1794
- // Parse [lattices:session] from title
1795
- var latticesSession: String?
1796
- if let regex = latticesSessionRegex,
1797
- let match = regex.firstMatch(in: win.title, range: NSRange(win.title.startIndex..., in: win.title)),
1798
- let range = Range(match.range(at: 1), in: win.title) {
1799
- latticesSession = String(win.title[range])
1800
- }
1801
-
1802
- // Cross-reference with tmux — match by PID (window owner PID or child)
1803
- let tmuxCtx = tmuxPidLookup[win.pid]
1804
- // If no direct PID match, try looking up by lattices session name
1805
- let tmuxBySession: (command: String, paneTitle: String, session: String)? = {
1806
- guard let session = latticesSession else { return nil }
1807
- guard tmuxCtx == nil else { return nil }
1808
- for s in TmuxModel.shared.sessions where s.name == session {
1809
- if let active = s.panes.first(where: { $0.isActive }) {
1810
- return (active.currentCommand, active.title, s.name)
1811
- }
1812
- }
1813
- return nil
1814
- }()
1815
- let ctx = tmuxCtx ?? tmuxBySession
1816
-
1817
- mapWindows.append(ScreenMapWindowEntry(
1818
- id: win.wid, pid: win.pid, app: win.app, title: win.title,
1819
- originalFrame: win.frame, editedFrame: win.frame, virtualFrame: win.frame,
1820
- zIndex: i, layer: assignedLayer, displayIndex: win.displayIndex,
1821
- isOnScreen: win.isOnScreen,
1822
- latticesSession: latticesSession ?? ctx?.session,
1823
- tmuxCommand: ctx?.command,
1824
- tmuxPaneTitle: ctx?.paneTitle
1825
- ))
1826
- }
1827
-
1828
- let totalLayers = (mapWindows.map(\.layer).max() ?? 0) + 1
1829
- NSLog("[ScreenMap] Peeling complete: %d layers from %d windows across %d displays (tmux panes indexed: %d)", totalLayers, mapWindows.count, byDisplay.count, tmuxPidLookup.count)
1830
-
1831
- // Build display geometries
1832
- var displayGeometries: [DisplayGeometry] = []
1833
- for (i, screen) in screens.enumerated() {
1834
- let cgOriginY = primaryHeight - screen.frame.maxY
1835
- let cgRect = CGRect(x: screen.frame.origin.x, y: cgOriginY,
1836
- width: screen.frame.width, height: screen.frame.height)
1837
- displayGeometries.append(DisplayGeometry(index: i, cgRect: cgRect, label: screen.localizedName))
1838
- }
1839
-
1840
- let newEditor = ScreenMapEditorState(windows: mapWindows, displays: displayGeometries)
1841
-
1842
- // Populate layer names from workspace config
1843
- if let layers = WorkspaceManager.shared.config?.layers {
1844
- for (i, layer) in layers.enumerated() {
1845
- newEditor.layerNames[i] = layer.label
1846
- }
1847
- }
1848
-
1849
- // Start monitor-first: focus the display under the cursor, or the first display.
1850
- if !displayGeometries.isEmpty {
1851
- let mouseLocation = NSEvent.mouseLocation
1852
- let mouseCG = CGPoint(x: mouseLocation.x, y: primaryHeight - mouseLocation.y)
1853
- if let display = displayGeometries.first(where: { $0.cgRect.contains(mouseCG) }) {
1854
- newEditor.focusedDisplayIndex = display.index
1855
- } else {
1856
- newEditor.focusedDisplayIndex = displayGeometries[0].index
1857
- }
1858
- }
1859
-
1860
- editor = newEditor
1861
- let liveIds = Set(mapWindows.map(\.id))
1862
- windowSets = existingSets.compactMap { set in
1863
- let filtered = set.windowIds.intersection(liveIds)
1864
- guard !filtered.isEmpty else { return nil }
1865
- return ScreenMapWindowSet(id: set.id, name: set.name, windowIds: filtered)
1866
- }
1867
- if let activeWindowSetID, !windowSets.contains(where: { $0.id == activeWindowSetID }) {
1868
- self.activeWindowSetID = nil
1869
- }
1870
- selectedWindowIds = []
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
- }
1877
- }
1878
-
1879
- /// Re-snapshot, preserving display/layer context
1880
- func refresh() {
1881
- let savedDisplay = editor?.focusedDisplayIndex
1882
- let savedLayers = editor?.selectedLayers ?? []
1883
- let savedViewportPreset = editor?.activeViewportPreset
1884
- enter()
1885
- if let ed = editor {
1886
- ed.focusedDisplayIndex = savedDisplay
1887
- ed.selectedLayers = savedLayers
1888
- if let savedViewportPreset {
1889
- focusViewportPreset(savedViewportPreset, flashView: false)
1890
- }
1891
- }
1892
- }
1893
-
1894
- // MARK: - Canvas Navigation
1895
-
1896
- func recenterViewport(at worldPoint: CGPoint) {
1897
- editor?.activeViewportPreset = nil
1898
- queueCanvasNavigation(centeredOn: worldPoint, rect: nil, zoomToFit: false)
1899
- }
1900
-
1901
- func focusCanvas(on rect: CGRect, focusDisplay displayIndex: Int? = nil, resetDisplayFocus: Bool = false, zoomToFit: Bool = true) {
1902
- guard let ed = editor else { return }
1903
-
1904
- if let displayIndex {
1905
- ed.focusDisplay(displayIndex)
1906
- } else if resetDisplayFocus {
1907
- ed.focusDisplay(nil)
1908
- }
1909
- ed.activeViewportPreset = nil
1910
-
1911
- queueCanvasNavigation(
1912
- centeredOn: CGPoint(x: rect.midX, y: rect.midY),
1913
- rect: rect,
1914
- zoomToFit: zoomToFit
1915
- )
1916
- }
1917
-
1918
- func focusViewportPreset(_ preset: ScreenMapViewportPreset, flashView: Bool = true) {
1919
- guard let ed = editor else { return }
1920
- ed.activeViewportPreset = preset
1921
- let rect = ed.viewportRect(for: preset)
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
- }
1928
- queueCanvasNavigation(
1929
- centeredOn: CGPoint(x: rect.midX, y: rect.midY),
1930
- rect: rect,
1931
- zoomToFit: preset != .overview
1932
- )
1933
- if flashView {
1934
- flash(preset.title)
1935
- }
1936
- }
1937
-
1938
- func jumpToCanvasRegion(_ region: ScreenMapCanvasRegion) {
1939
- DiagnosticLog.shared.info("[Canvas] jump → \(region.title) · \(region.subtitle)")
1940
- switch region.kind {
1941
- case .overview:
1942
- focusViewportPreset(.overview)
1943
- case .display:
1944
- focusCanvas(on: region.rect, focusDisplay: region.displayIndex, zoomToFit: true)
1945
- case .layer:
1946
- focusCanvas(on: region.rect, zoomToFit: true)
1947
- }
1948
- }
1949
-
1950
- func applyPendingCanvasNavigationIfNeeded() {
1951
- guard let ed = editor, let target = ed.pendingCanvasNavigation else { return }
1952
- ed.pendingCanvasNavigation = nil
1953
- let shouldLogView = target.rect != nil || target.zoomToFit
1954
-
1955
- var targetZoom: CGFloat? = nil
1956
- if target.zoomToFit,
1957
- let rect = target.rect,
1958
- ed.fitScale > 0,
1959
- ed.viewportSize.width > 0,
1960
- ed.viewportSize.height > 0 {
1961
- let paddedW = max(rect.width, 120)
1962
- let paddedH = max(rect.height, 80)
1963
- let desiredScale = min(
1964
- (ed.viewportSize.width * 0.84) / paddedW,
1965
- (ed.viewportSize.height * 0.84) / paddedH
1966
- )
1967
- targetZoom = max(
1968
- ScreenMapEditorState.minZoom,
1969
- min(ScreenMapEditorState.maxZoom, desiredScale / ed.fitScale)
1970
- )
1971
- }
1972
-
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
- }
1982
- if shouldLogView {
1983
- let viewport = ed.viewportWorldRect
1984
- DiagnosticLog.shared.info(
1985
- "[Canvas] view → \(ed.canvasScopeSummary) center=(\(Int(viewport.midX)),\(Int(viewport.midY))) viewport=\(Int(viewport.width))×\(Int(viewport.height)) zoom=\(Int(ed.zoomLevel * 100))%"
1986
- )
1987
- }
1988
- }
1989
-
1990
- private func queueCanvasNavigation(centeredOn targetCenter: CGPoint, rect: CGRect?, zoomToFit: Bool) {
1991
- guard let ed = editor else { return }
1992
- ed.pendingCanvasNavigation = ScreenMapCanvasNavigationTarget(
1993
- center: targetCenter,
1994
- rect: rect,
1995
- zoomToFit: zoomToFit
1996
- )
1997
- ed.canvasNavigationRevision &+= 1
1998
- ed.objectWillChange.send()
1999
- objectWillChange.send()
2000
- }
2001
-
2002
- private func setViewport(centeredOn targetCenter: CGPoint, zoomLevel targetZoom: CGFloat?) {
2003
- guard let ed = editor else { return }
2004
-
2005
- if let targetZoom {
2006
- ed.zoomLevel = targetZoom
2007
- ed.scale = ed.fitScale * targetZoom
2008
- }
2009
-
2010
- let effectiveScale = max(ed.fitScale * ed.zoomLevel, 0.0001)
2011
- let viewport = CGSize(
2012
- width: max(ed.viewportSize.width, 1),
2013
- height: max(ed.viewportSize.height, 1)
2014
- )
2015
- let viewportWorld = CGSize(
2016
- width: viewport.width / effectiveScale,
2017
- height: viewport.height / effectiveScale
2018
- )
2019
- let world = ed.canvasWorldBounds
2020
-
2021
- let clampedCenter = CGPoint(
2022
- x: clampedViewportCenter(
2023
- target: targetCenter.x,
2024
- minEdge: world.minX,
2025
- maxEdge: world.maxX,
2026
- viewportExtent: viewportWorld.width
2027
- ),
2028
- y: clampedViewportCenter(
2029
- target: targetCenter.y,
2030
- minEdge: world.minY,
2031
- maxEdge: world.maxY,
2032
- viewportExtent: viewportWorld.height
2033
- )
2034
- )
2035
-
2036
- ed.panOffset = CGPoint(
2037
- x: viewport.width / 2 - ed.mapOrigin.x - (clampedCenter.x - ed.bboxOrigin.x) * effectiveScale,
2038
- y: viewport.height / 2 - ed.mapOrigin.y - (clampedCenter.y - ed.bboxOrigin.y) * effectiveScale
2039
- )
2040
- ed.objectWillChange.send()
2041
- objectWillChange.send()
2042
- }
2043
-
2044
- private func clampedViewportCenter(target: CGFloat, minEdge: CGFloat, maxEdge: CGFloat, viewportExtent: CGFloat) -> CGFloat {
2045
- if maxEdge - minEdge <= viewportExtent {
2046
- return (minEdge + maxEdge) / 2
2047
- }
2048
- let minCenter = minEdge + viewportExtent / 2
2049
- let maxCenter = maxEdge - viewportExtent / 2
2050
- return min(max(target, minCenter), maxCenter)
2051
- }
2052
-
2053
- // MARK: - Key Handler
2054
-
2055
- func handleKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
2056
- let diag = DiagnosticLog.shared
2057
-
2058
- // Tiling mode intercepts keys before anything else
2059
- if editor?.isTilingMode == true {
2060
- switch keyCode {
2061
- case 53: // Escape — always dismiss
2062
- onDismiss?()
2063
- return true
2064
- case 123: // ← → left
2065
- tileSelectedWindowInEditor(to: .left)
2066
- return true
2067
- case 124: // → → right
2068
- tileSelectedWindowInEditor(to: .right)
2069
- return true
2070
- case 126: // ↑ → top (Shift = maximize)
2071
- if modifiers.contains(.shift) {
2072
- tileSelectedWindowInEditor(to: .maximize)
2073
- } else {
2074
- tileSelectedWindowInEditor(to: .top)
2075
- }
2076
- return true
2077
- case 125: // ↓ → bottom
2078
- tileSelectedWindowInEditor(to: .bottom)
2079
- return true
2080
- case 8: // c → center
2081
- tileSelectedWindowInEditor(to: .center)
2082
- return true
2083
- case 18: // 1 → topLeft
2084
- tileSelectedWindowInEditor(to: .topLeft)
2085
- return true
2086
- case 19: // 2 → topRight
2087
- tileSelectedWindowInEditor(to: .topRight)
2088
- return true
2089
- case 20: // 3 → bottomLeft
2090
- tileSelectedWindowInEditor(to: .bottomLeft)
2091
- return true
2092
- case 21: // 4 → bottomRight
2093
- tileSelectedWindowInEditor(to: .bottomRight)
2094
- return true
2095
- case 23: // 5 → leftThird
2096
- tileSelectedWindowInEditor(to: .leftThird)
2097
- return true
2098
- case 22: // 6 → centerThird
2099
- tileSelectedWindowInEditor(to: .centerThird)
2100
- return true
2101
- case 26: // 7 → rightThird
2102
- tileSelectedWindowInEditor(to: .rightThird)
2103
- return true
2104
- default:
2105
- exitTilingMode()
2106
- flash("Tiling cancelled")
2107
- return true
2108
- }
2109
- }
2110
-
2111
- // Ctrl+Option direct tiling shortcuts (always active, single selection)
2112
- if modifiers.contains([.control, .option]) && selectedWindowIds.count == 1 {
2113
- switch keyCode {
2114
- case 123: // Ctrl+Opt+← → left
2115
- tileSelectedWindowInEditor(to: .left)
2116
- return true
2117
- case 124: // Ctrl+Opt+→ → right
2118
- tileSelectedWindowInEditor(to: .right)
2119
- return true
2120
- case 126: // Ctrl+Opt+↑ → top (+ Shift = maximize)
2121
- if modifiers.contains(.shift) {
2122
- tileSelectedWindowInEditor(to: .maximize)
2123
- } else {
2124
- tileSelectedWindowInEditor(to: .top)
2125
- }
2126
- return true
2127
- case 125: // Ctrl+Opt+↓ → bottom
2128
- tileSelectedWindowInEditor(to: .bottom)
2129
- return true
2130
- default:
2131
- break
2132
- }
2133
- }
2134
-
2135
- // Search mode intercepts keys before normal handling
2136
- if isSearchActive {
2137
- switch keyCode {
2138
- case 53: // Escape — always dismiss
2139
- onDismiss?()
2140
- return true
2141
- case 36: // Enter → select or focus
2142
- if modifiers.contains(.command) {
2143
- focusSelectedWindowOnScreen()
2144
- } else {
2145
- searchSelectHighlighted()
2146
- }
2147
- return true
2148
- case 125: // ↓ → next result
2149
- searchNavigate(delta: 1)
2150
- return true
2151
- case 126: // ↑ → previous result
2152
- searchNavigate(delta: -1)
2153
- return true
2154
- default:
2155
- // Let other keys pass through to the text field
2156
- return false
2157
- }
2158
- }
2159
-
2160
- switch keyCode {
2161
- case 53: // Escape — always dismiss
2162
- diag.info("[ScreenMap] exit")
2163
- onDismiss?()
2164
- return true
2165
-
2166
- case 36: // Enter
2167
- if modifiers.contains(.command) {
2168
- // ⌘↩ → focus selected window on screen
2169
- focusSelectedWindowOnScreen()
2170
- } else {
2171
- // ↩ → apply edits
2172
- if editor?.isPreviewing == true { endPreview() }
2173
- diag.info("[ScreenMap] apply edits")
2174
- applyEdits()
2175
- }
2176
- return true
2177
-
2178
- // MARK: Right hand — Navigation
2179
-
2180
- case 4: // h → previous display
2181
- stepDisplayFocus(.previous)
2182
- return true
2183
-
2184
- case 37: // l → next display
2185
- stepDisplayFocus(.next)
2186
- return true
2187
-
2188
- case 38: // j → next layer
2189
- editor?.cycleLayer()
2190
- diag.info("[ScreenMap] layer → \(editor?.layerLabel ?? "nil")")
2191
- objectWillChange.send()
2192
- return true
2193
-
2194
- case 40: // k → previous layer
2195
- editor?.cyclePreviousLayer()
2196
- diag.info("[ScreenMap] layer → \(editor?.layerLabel ?? "nil")")
2197
- objectWillChange.send()
2198
- return true
2199
-
2200
- case 45: // n → next window
2201
- selectNextWindow()
2202
- return true
2203
-
2204
- case 35: // p → previous window
2205
- selectPreviousWindow()
2206
- return true
2207
-
2208
- case 48: // Tab → cycle windows
2209
- if modifiers.contains(.shift) {
2210
- selectPreviousWindow()
2211
- } else {
2212
- selectNextWindow()
2213
- }
2214
- return true
2215
-
2216
- case 18: // 1 → main viewport
2217
- focusViewportPreset(.main)
2218
- return true
2219
-
2220
- case 19: // 2 → top-right viewport
2221
- focusViewportPreset(.topRight)
2222
- return true
2223
-
2224
- case 20: // 3 → bottom-left viewport
2225
- focusViewportPreset(.bottomLeft)
2226
- return true
2227
-
2228
- case 21: // 4 → bottom-right viewport
2229
- focusViewportPreset(.bottomRight)
2230
- return true
2231
-
2232
- case 33: // [ → move to previous layer
2233
- if let ed = editor {
2234
- for wid in selectedWindowIds {
2235
- if let idx = ed.windows.firstIndex(where: { $0.id == wid }) {
2236
- let oldLayer = ed.windows[idx].layer
2237
- let newLayer = max(0, oldLayer - 1)
2238
- ed.reassignLayer(windowId: wid, toLayer: newLayer, fitToAvailable: true)
2239
- }
2240
- }
2241
- objectWillChange.send()
2242
- }
2243
- return true
2244
-
2245
- case 30: // ] → move to next layer
2246
- if let ed = editor {
2247
- for wid in selectedWindowIds {
2248
- if let idx = ed.windows.firstIndex(where: { $0.id == wid }) {
2249
- let oldLayer = ed.windows[idx].layer
2250
- ed.reassignLayer(windowId: wid, toLayer: oldLayer + 1, fitToAvailable: true)
2251
- }
2252
- }
2253
- objectWillChange.send()
2254
- }
2255
- return true
2256
-
2257
- // MARK: Left hand — Actions
2258
-
2259
- case 1: // s → spread
2260
- smartSpreadLayer()
2261
- return true
2262
-
2263
- case 14: // e → expose
2264
- exposeLayer()
2265
- return true
2266
-
2267
- case 17: // t → tile (1 window = tiling mode, otherwise bulk tile)
2268
- if selectedWindowIds.count == 1 {
2269
- enterTilingMode()
2270
- } else {
2271
- tileLayer()
2272
- }
2273
- return true
2274
-
2275
- case 2: // d → distribute
2276
- distributeVisible()
2277
- return true
2278
-
2279
- case 15: // r → overview
2280
- focusViewportPreset(.overview)
2281
- return true
2282
-
2283
- case 5: // g → grow to fill
2284
- fitAvailableSpace()
2285
- return true
2286
-
2287
- case 32: // u → save current selection as set
2288
- createWindowSetFromSelection()
2289
- return true
2290
-
2291
- case 46: // m → materialize current viewport
2292
- materializeViewport()
2293
- return true
2294
-
2295
- case 3: // f → flatten
2296
- flattenLayers()
2297
- return true
2298
-
2299
- case 8: // c → consolidate
2300
- consolidateLayers()
2301
- return true
2302
-
2303
- case 9: // v → toggle preview
2304
- previewLayer()
2305
- return true
2306
-
2307
- case 0: // a → select all
2308
- selectAll()
2309
- return true
2310
-
2311
- case 7: // x → deselect all
2312
- clearSelection()
2313
- flash("Deselected")
2314
- return true
2315
-
2316
- case 6: // z → discard edits
2317
- if let ed = editor, ed.pendingEditCount > 0 {
2318
- ed.discardEdits()
2319
- flash("Edits discarded")
2320
- } else {
2321
- flash("No edits to discard")
2322
- }
2323
- return true
2324
-
2325
- case 29: // 0 → overview (secondary)
2326
- focusViewportPreset(.overview)
2327
- return true
2328
-
2329
- case 123: // ← previous display (secondary)
2330
- stepDisplayFocus(.previous)
2331
- return true
2332
-
2333
- case 124: // → next display (secondary)
2334
- stepDisplayFocus(.next)
2335
- return true
2336
-
2337
- case 44: // / → open window search
2338
- openSearch()
2339
- return true
2340
-
2341
- case 12: // q → dismiss screen map
2342
- if editor?.isPreviewing == true { endPreview() }
2343
- WindowBezel.shared.dismiss()
2344
- onDismiss?()
2345
- return true
2346
-
2347
- default:
2348
- return true
2349
- }
2350
- }
2351
-
2352
- // MARK: - Actions
2353
-
2354
- func applyEdits(showFlash: Bool = true) {
2355
- guard let ed = editor else { return }
2356
- let pendingEdits = ed.windows.filter(\.hasEdits)
2357
- guard !pendingEdits.isEmpty else {
2358
- if showFlash { flash("No changes to apply") }
2359
- return
2360
- }
2361
-
2362
- var positions: [UInt32: (pid: Int32, frame: WindowFrame)] = [:]
2363
- for win in pendingEdits {
2364
- positions[win.id] = (pid: win.pid, frame: WindowFrame(
2365
- x: Double(win.originalFrame.origin.x), y: Double(win.originalFrame.origin.y),
2366
- w: Double(win.originalFrame.width), h: Double(win.originalFrame.height)
2367
- ))
2368
- }
2369
- savedPositions = positions
2370
-
2371
- let sorted = pendingEdits.sorted(by: { $0.layer > $1.layer })
2372
- let allMoves = sorted.map { (wid: $0.id, pid: $0.pid, frame: $0.editedFrame) }
2373
- NSLog("[ScreenMap] Applying %d edits", allMoves.count)
2374
-
2375
- let actionLog = ed.actionLog
2376
-
2377
- // Apply AX changes (no hide/show — Screen Map stays visible)
2378
- WindowTiler.batchMoveWindows(allMoves)
2379
-
2380
- // Commit edited frames as new originals so the map doesn't reload
2381
- for i in ed.windows.indices {
2382
- ed.windows[i].originalFrame = ed.windows[i].editedFrame
2383
- }
2384
- ed.objectWillChange.send()
2385
- objectWillChange.send()
2386
-
2387
- // Verify in background — if anything drifted, retry once then refresh
2388
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
2389
- let drifted = WindowTiler.verifyMoves(allMoves)
2390
- if !drifted.isEmpty {
2391
- NSLog("[ScreenMap] %d/%d windows drifted, retrying", drifted.count, allMoves.count)
2392
- WindowTiler.batchMoveWindows(drifted)
2393
- }
2394
- actionLog.verify()
2395
- }
2396
-
2397
- if showFlash {
2398
- let noun = pendingEdits.count == 1 ? "edit" : "edits"
2399
- flash("Applied \(pendingEdits.count) \(noun)")
2400
- }
2401
- }
2402
-
2403
- func applyEditsFromButton() {
2404
- if editor?.isPreviewing == true { endPreview() }
2405
- applyEdits()
2406
- }
2407
-
2408
- private func projectionTargetBounds(for editor: ScreenMapEditorState) -> CGRect {
2409
- if let focused = editor.focusedDisplay {
2410
- return focused.cgRect
2411
- }
2412
- guard let first = editor.displays.first else {
2413
- return editor.viewportWorldRect
2414
- }
2415
- return editor.displays.dropFirst().reduce(first.cgRect) { $0.union($1.cgRect) }
2416
- }
2417
-
2418
- private func parkingBounds(for editor: ScreenMapEditorState) -> CGRect {
2419
- let target = projectionTargetBounds(for: editor)
2420
- let union = editor.displays.dropFirst().reduce(editor.displays.first?.cgRect ?? target) { $0.union($1.cgRect) }
2421
- let width: CGFloat = 420
2422
- return CGRect(
2423
- x: union.maxX + 120,
2424
- y: union.minY + 40,
2425
- width: width,
2426
- height: max(union.height - 80, 320)
2427
- )
2428
- }
2429
-
2430
- private func projectedViewportFrames(
2431
- for windows: [ScreenMapWindowEntry],
2432
- source: CGRect,
2433
- target: CGRect
2434
- ) -> [UInt32: CGRect] {
2435
- guard source.width > 0, source.height > 0, target.width > 0, target.height > 0 else { return [:] }
2436
-
2437
- let scale = min(target.width / source.width, target.height / source.height)
2438
- let projectedSize = CGSize(width: source.width * scale, height: source.height * scale)
2439
- let projectedOrigin = CGPoint(
2440
- x: target.minX + (target.width - projectedSize.width) / 2,
2441
- y: target.minY + (target.height - projectedSize.height) / 2
2442
- )
2443
-
2444
- var frames: [UInt32: CGRect] = [:]
2445
- for win in windows {
2446
- let frame = win.virtualFrame
2447
- let mapped = CGRect(
2448
- x: projectedOrigin.x + (frame.minX - source.minX) * scale,
2449
- y: projectedOrigin.y + (frame.minY - source.minY) * scale,
2450
- width: max(frame.width * scale, 180),
2451
- height: max(frame.height * scale, 100)
2452
- )
2453
- frames[win.id] = mapped
2454
- }
2455
- return frames
2456
- }
2457
-
2458
- private func parkedViewportFrames(
2459
- for windows: [ScreenMapWindowEntry],
2460
- parkingBounds: CGRect
2461
- ) -> [UInt32: CGRect] {
2462
- guard !windows.isEmpty else { return [:] }
2463
-
2464
- let spacing: CGFloat = 20
2465
- let maxCardWidth = max(min(parkingBounds.width - spacing * 2, 320), 180)
2466
- var cursor = CGPoint(x: parkingBounds.minX, y: parkingBounds.minY)
2467
- var columnX = parkingBounds.minX
2468
- var frames: [UInt32: CGRect] = [:]
2469
-
2470
- for win in windows.sorted(by: { $0.zIndex < $1.zIndex }) {
2471
- let aspect = max(win.virtualFrame.width / max(win.virtualFrame.height, 1), 0.6)
2472
- let width = min(max(win.virtualFrame.width * 0.55, 180), maxCardWidth)
2473
- let height = min(max(width / aspect, 100), 220)
2474
-
2475
- if cursor.y + height > parkingBounds.maxY {
2476
- columnX += maxCardWidth + spacing
2477
- cursor = CGPoint(x: columnX, y: parkingBounds.minY)
2478
- }
2479
-
2480
- frames[win.id] = CGRect(x: cursor.x, y: cursor.y, width: width, height: height)
2481
- cursor.y += height + spacing
2482
- }
2483
-
2484
- return frames
2485
- }
2486
-
2487
- func materializeViewport() {
2488
- guard let ed = editor else { return }
2489
-
2490
- let scopedWindows = ed.focusedDisplayIndex != nil ? ed.focusedVisibleWindows : ed.visibleWindows
2491
- guard !scopedWindows.isEmpty else {
2492
- flash("No windows in scope")
2493
- return
2494
- }
2495
-
2496
- let viewport = ed.viewportWorldRect
2497
- let projectable = scopedWindows.filter { win in
2498
- win.virtualFrame.intersects(viewport) ||
2499
- viewport.contains(CGPoint(x: win.virtualFrame.midX, y: win.virtualFrame.midY))
2500
- }
2501
- guard !projectable.isEmpty else {
2502
- flash("Viewport is empty")
2503
- return
2504
- }
2505
-
2506
- let targetBounds = projectionTargetBounds(for: ed)
2507
- let parkingBounds = parkingBounds(for: ed)
2508
- let projectedFrames = projectedViewportFrames(
2509
- for: projectable,
2510
- source: viewport,
2511
- target: targetBounds
2512
- )
2513
- let parkedWindows = scopedWindows.filter { projectedFrames[$0.id] == nil }
2514
- let parkedFrames = parkedViewportFrames(for: parkedWindows, parkingBounds: parkingBounds)
2515
-
2516
- for idx in ed.windows.indices {
2517
- let wid = ed.windows[idx].id
2518
- if let frame = projectedFrames[wid] ?? parkedFrames[wid] {
2519
- ed.windows[idx].editedFrame = frame
2520
- }
2521
- }
2522
-
2523
- DiagnosticLog.shared.info(
2524
- "[Canvas] materialize → projected \(projectedFrames.count), parked \(parkedFrames.count), scope=\(ed.canvasScopeSummary)"
2525
- )
2526
- if ed.isPreviewing { endPreview() }
2527
- applyEdits(showFlash: false)
2528
- flash("Projected \(projectedFrames.count) windows")
2529
- }
2530
-
2531
- func exitScreenMap() {
2532
- if editor?.isPreviewing == true { endPreview() }
2533
- WindowBezel.shared.dismiss()
2534
- if let ed = editor, ed.pendingEditCount > 0 {
2535
- ed.discardEdits()
2536
- flash("Edits discarded")
2537
- } else {
2538
- onDismiss?()
2539
- }
2540
- }
2541
-
2542
- func tileLayer() {
2543
- guard let ed = editor else { return }
2544
- let before = ed.actionLog.snapshot(ed.windows)
2545
- let count = ed.autoTileLayer()
2546
- let after = ed.actionLog.snapshot(ed.windows)
2547
- let summary: String
2548
- if count >= 2 { summary = "Tiled \(count) windows" }
2549
- else if count == 1 { summary = "Only 1 window in layer" }
2550
- else { summary = "Select a single layer first" }
2551
- let entry = ed.actionLog.record(action: "tile", summary: summary, before: before, after: after)
2552
- ed.lastActionRef = entry.ref
2553
- flash("\(summary) [\(entry.ref)]")
2554
- objectWillChange.send()
2555
- }
2556
-
2557
- func exposeLayer() {
2558
- guard let ed = editor else { return }
2559
- let before = ed.actionLog.snapshot(ed.windows)
2560
- let count = ed.exposeLayer()
2561
- let after = ed.actionLog.snapshot(ed.windows)
2562
- let summary: String
2563
- if count >= 2 { summary = "Exposed \(count) windows" }
2564
- else if count == 1 { summary = "Only 1 window in layer" }
2565
- else { summary = "Select a single layer first" }
2566
- let entry = ed.actionLog.record(action: "expose", summary: summary, before: before, after: after)
2567
- ed.lastActionRef = entry.ref
2568
- flash("\(summary) [\(entry.ref)]")
2569
- objectWillChange.send()
2570
- }
2571
-
2572
- func smartSpreadLayer() {
2573
- guard let ed = editor else { return }
2574
- let before = ed.actionLog.snapshot(ed.windows)
2575
- let count = ed.smartSpreadLayer()
2576
- let after = ed.actionLog.snapshot(ed.windows)
2577
- let summary: String
2578
- if count >= 2 { summary = "Spread \(count) windows" }
2579
- else if count == 1 { summary = "Only 1 window in layer" }
2580
- else { summary = "Select a single layer first" }
2581
- let entry = ed.actionLog.record(action: "spread", summary: summary, before: before, after: after)
2582
- ed.lastActionRef = entry.ref
2583
- flash("\(summary) [\(entry.ref)]")
2584
- objectWillChange.send()
2585
- }
2586
-
2587
- func distributeVisible() {
2588
- guard let ed = editor else { return }
2589
- let before = ed.actionLog.snapshot(ed.windows)
2590
- let count = ed.distributeLayer()
2591
- let after = ed.actionLog.snapshot(ed.windows)
2592
- let summary: String
2593
- if count >= 2 { summary = "Distributed \(count) windows" }
2594
- else if count == 1 { summary = "Only 1 window to distribute" }
2595
- else { summary = "No visible windows to distribute" }
2596
- let entry = ed.actionLog.record(action: "distribute", summary: summary, before: before, after: after)
2597
- ed.lastActionRef = entry.ref
2598
- flash("\(summary) [\(entry.ref)]")
2599
- objectWillChange.send()
2600
- }
2601
-
2602
- func fitAvailableSpace() {
2603
- guard let ed = editor else { return }
2604
- let before = ed.actionLog.snapshot(ed.windows)
2605
- let count = ed.fitAvailableSpace()
2606
- let after = ed.actionLog.snapshot(ed.windows)
2607
- let summary: String
2608
- if count >= 2 { summary = "Grew \(count) windows to fill" }
2609
- else if count == 1 { summary = "Grew 1 window to fill" }
2610
- else { summary = "No windows to grow" }
2611
- let entry = ed.actionLog.record(action: "fit", summary: summary, before: before, after: after)
2612
- ed.lastActionRef = entry.ref
2613
- flash("\(summary) [\(entry.ref)]")
2614
- objectWillChange.send()
2615
- }
2616
-
2617
- func consolidateLayers() {
2618
- guard let ed = editor else { return }
2619
- let before = ed.actionLog.snapshot(ed.windows)
2620
- let result = ed.consolidateLayers()
2621
- let after = ed.actionLog.snapshot(ed.windows)
2622
- let summary = result.old == result.new
2623
- ? "Already optimal"
2624
- : "Consolidated \(result.old) → \(result.new) layers"
2625
- let entry = ed.actionLog.record(action: "merge", summary: summary, before: before, after: after)
2626
- ed.lastActionRef = entry.ref
2627
- flash("\(summary) [\(entry.ref)]")
2628
- objectWillChange.send()
2629
- }
2630
-
2631
- func flattenLayers() {
2632
- guard let ed = editor else { return }
2633
- let before = ed.actionLog.snapshot(ed.windows)
2634
- let result = ed.flattenSelectedLayers()
2635
- let after = ed.actionLog.snapshot(ed.windows)
2636
- let summary: String
2637
- if let result = result {
2638
- summary = "Merged \(result.count) windows into L\(result.target)"
2639
- } else {
2640
- summary = "Select 2+ layers to flatten"
2641
- }
2642
- let entry = ed.actionLog.record(action: "flatten", summary: summary, before: before, after: after)
2643
- ed.lastActionRef = entry.ref
2644
- flash("\(summary) [\(entry.ref)]")
2645
- objectWillChange.send()
2646
- }
2647
-
2648
- // MARK: - Per-Window Tiling
2649
-
2650
- func tileSelectedWindowInEditor(to position: TilePosition) {
2651
- guard let ed = editor, selectedWindowIds.count == 1,
2652
- let winId = selectedWindowIds.first,
2653
- let idx = ed.windows.firstIndex(where: { $0.id == winId }),
2654
- let display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex })
2655
- else { return }
2656
- ed.syncLayoutFrame(at: idx, to: WindowTiler.tileFrame(for: position, inDisplay: display.cgRect))
2657
- ed.isTilingMode = false
2658
- ed.objectWillChange.send(); objectWillChange.send()
2659
- flash(position.label)
2660
- }
2661
-
2662
- func tileSelectedWindowInEditor(fractions: (CGFloat, CGFloat, CGFloat, CGFloat), label: String) {
2663
- guard let ed = editor, selectedWindowIds.count == 1,
2664
- let winId = selectedWindowIds.first,
2665
- let idx = ed.windows.firstIndex(where: { $0.id == winId }),
2666
- let display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex })
2667
- else { return }
2668
- ed.syncLayoutFrame(at: idx, to: WindowTiler.tileFrame(fractions: fractions, inDisplay: display.cgRect))
2669
- ed.isTilingMode = false
2670
- ed.objectWillChange.send(); objectWillChange.send()
2671
- flash(label)
2672
- }
2673
-
2674
- func applyLayout(name: String) {
2675
- guard let ed = editor else { return }
2676
- let wm = WorkspaceManager.shared
2677
- guard let layout = wm.gridLayouts[name] else {
2678
- flash("Layout '\(name)' not found")
2679
- return
2680
- }
2681
-
2682
- var matched = 0
2683
- for spec in layout.windows {
2684
- // Find matching window(s) by app name (case-insensitive substring)
2685
- let appLower = spec.app.lowercased()
2686
- let candidates = ed.windows.indices.filter { idx in
2687
- let win = ed.windows[idx]
2688
- let nameMatch = win.app.lowercased().contains(appLower)
2689
- if let titleFilter = spec.title {
2690
- return nameMatch && win.title.lowercased().contains(titleFilter.lowercased())
2691
- }
2692
- return nameMatch
2693
- }
2694
- guard let idx = candidates.first else { continue }
2695
-
2696
- // Resolve tile position (check presets first, then built-in)
2697
- guard let fractions = wm.resolveTileFractions(spec.tile) else { continue }
2698
-
2699
- // Resolve display (spatial number → displayIndex)
2700
- let display: DisplayGeometry
2701
- if let spatialNum = spec.display {
2702
- let order = ed.spatialDisplayOrder
2703
- if spatialNum >= 1 && spatialNum <= order.count {
2704
- display = order[spatialNum - 1]
2705
- } else {
2706
- display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex }) ?? ed.displays[0]
2707
- }
2708
- } else {
2709
- display = ed.displays.first(where: { $0.index == ed.windows[idx].displayIndex }) ?? ed.displays[0]
2710
- }
2711
-
2712
- ed.syncLayoutFrame(at: idx, to: WindowTiler.tileFrame(fractions: fractions, inDisplay: display.cgRect))
2713
- // Update display index if layout spec moves to a different display
2714
- if let spatialNum = spec.display {
2715
- let order = ed.spatialDisplayOrder
2716
- if spatialNum >= 1 && spatialNum <= order.count {
2717
- let moved = ed.windows[idx]
2718
- ed.windows[idx] = ScreenMapWindowEntry(
2719
- id: moved.id, pid: moved.pid,
2720
- app: moved.app, title: moved.title,
2721
- originalFrame: moved.originalFrame,
2722
- editedFrame: moved.editedFrame,
2723
- virtualFrame: moved.virtualFrame,
2724
- zIndex: moved.zIndex, layer: moved.layer,
2725
- displayIndex: order[spatialNum - 1].index,
2726
- isOnScreen: moved.isOnScreen,
2727
- latticesSession: moved.latticesSession,
2728
- tmuxCommand: moved.tmuxCommand,
2729
- tmuxPaneTitle: moved.tmuxPaneTitle
2730
- )
2731
- }
2732
- }
2733
- matched += 1
2734
- }
2735
-
2736
- ed.objectWillChange.send(); objectWillChange.send()
2737
- flash("Layout '\(name)': \(matched)/\(layout.windows.count) matched")
2738
- }
2739
-
2740
- func enterTilingMode() {
2741
- guard let ed = editor, selectedWindowIds.count == 1 else { return }
2742
- ed.isTilingMode = true
2743
- ed.objectWillChange.send(); objectWillChange.send()
2744
- }
2745
-
2746
- func exitTilingMode() {
2747
- guard let ed = editor else { return }
2748
- ed.isTilingMode = false
2749
- ed.objectWillChange.send(); objectWillChange.send()
2750
- }
2751
-
2752
- // MARK: - Preview
2753
-
2754
- func previewLayer() {
2755
- guard let ed = editor else { return }
2756
- if ed.isPreviewing { endPreview(); return }
2757
-
2758
- let visible = ed.focusedVisibleWindows
2759
- guard !visible.isEmpty else { flash("No windows to preview"); return }
2760
-
2761
- var captures: [UInt32: NSImage] = [:]
2762
- for win in visible {
2763
- if let cgImage = WindowCapture.image(
2764
- listOption: .optionIncludingWindow,
2765
- windowID: CGWindowID(win.id),
2766
- imageOption: [.boundsIgnoreFraming, .bestResolution]
2767
- ) {
2768
- captures[win.id] = NSImage(cgImage: cgImage,
2769
- size: NSSize(width: win.virtualFrame.width, height: win.virtualFrame.height))
2770
- }
2771
- }
2772
- previewCaptures = captures
2773
- ed.isPreviewing = true
2774
- objectWillChange.send()
2775
- }
2776
-
2777
- func showPreviewWindow(contentView: NSView, frame: NSRect) {
2778
- let window = NSWindow(
2779
- contentRect: frame, styleMask: .borderless,
2780
- backing: .buffered, defer: false
2781
- )
2782
- window.isOpaque = false
2783
- window.backgroundColor = .clear
2784
- window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
2785
- window.hasShadow = false
2786
- window.contentView = contentView
2787
- window.setFrame(frame, display: true)
2788
- window.orderFrontRegardless()
2789
- previewWindow = window
2790
-
2791
- previewGlobalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown, .leftMouseDown, .rightMouseDown]) { [weak self] _ in
2792
- self?.endPreview()
2793
- }
2794
- previewLocalMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .leftMouseDown, .rightMouseDown]) { [weak self] event in
2795
- self?.endPreview()
2796
- return nil
2797
- }
2798
- }
2799
-
2800
- func endPreview() {
2801
- guard editor?.isPreviewing == true else { return }
2802
- previewWindow?.orderOut(nil)
2803
- previewWindow = nil
2804
- if let m = previewGlobalMonitor { NSEvent.removeMonitor(m) }
2805
- if let m = previewLocalMonitor { NSEvent.removeMonitor(m) }
2806
- previewGlobalMonitor = nil
2807
- previewLocalMonitor = nil
2808
- previewCaptures = [:]
2809
- editor?.isPreviewing = false
2810
- objectWillChange.send()
2811
- }
2812
-
2813
- // MARK: - Flash
2814
-
2815
- func flash(_ message: String) {
2816
- flashMessage = message
2817
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
2818
- if self?.flashMessage == message { self?.flashMessage = nil }
2819
- }
2820
- }
2821
- }
2822
-
2823
- // MARK: - Bezel Panel (custom NSPanel)
2824
-
2825
- /// Panel that stays behind its target window and supports dragging both together.
2826
- private class BezelPanel: NSPanel {
2827
- var targetWid: UInt32 = 0
2828
- var targetPid: Int32 = 0
2829
- private var dragOrigin: NSPoint?
2830
- private var panelOriginAtDrag: NSPoint?
2831
- private var targetOriginAtDrag: CGPoint?
2832
-
2833
- // Never come to front on click — stay behind target
2834
- override func mouseDown(with event: NSEvent) {
2835
- let loc = event.locationInWindow
2836
- dragOrigin = NSEvent.mouseLocation
2837
- panelOriginAtDrag = frame.origin
2838
-
2839
- // Read current target window position (CG coords, top-left origin)
2840
- if let axWin = WindowTiler.findAXWindowByFrame(wid: targetWid, pid: targetPid) {
2841
- var posRef: CFTypeRef?
2842
- AXUIElementCopyAttributeValue(axWin, kAXPositionAttribute as CFString, &posRef)
2843
- var pos = CGPoint.zero
2844
- if let pv = posRef { AXValueGetValue(pv as! AXValue, .cgPoint, &pos) }
2845
- targetOriginAtDrag = pos
2846
- }
2847
-
2848
- // Keep behind target — don't call super which would order front
2849
- _ = loc // suppress unused warning
2850
- }
2851
-
2852
- override func mouseDragged(with event: NSEvent) {
2853
- guard let dragStart = dragOrigin,
2854
- let panelStart = panelOriginAtDrag else { return }
2855
-
2856
- let current = NSEvent.mouseLocation
2857
- let dx = current.x - dragStart.x
2858
- let dy = current.y - dragStart.y
2859
-
2860
- // Move the bezel panel
2861
- setFrameOrigin(NSPoint(x: panelStart.x + dx, y: panelStart.y + dy))
2862
-
2863
- // Move the target window via AX (CG coords: top-left origin, so dy is inverted)
2864
- if let targetStart = targetOriginAtDrag,
2865
- let axWin = WindowTiler.findAXWindowByFrame(wid: targetWid, pid: targetPid) {
2866
- var newPos = CGPoint(x: targetStart.x + dx, y: targetStart.y - dy)
2867
- let posVal: AXValue? = AXValueCreate(.cgPoint, &newPos)
2868
- if let pv = posVal {
2869
- AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
2870
- }
2871
- }
2872
- }
2873
-
2874
- override func mouseUp(with event: NSEvent) {
2875
- dragOrigin = nil
2876
- panelOriginAtDrag = nil
2877
- targetOriginAtDrag = nil
2878
- }
2879
- }
2880
-
2881
- // MARK: - Window Bezel (standalone companion window)
2882
-
2883
- /// Persistent chromeless companion window that frames a target window with info.
2884
- /// Singleton — reuses a single NSPanel, repositions/updates content for each target.
2885
- final class WindowBezel {
2886
- static let shared = WindowBezel()
2887
-
2888
- private var panel: BezelPanel?
2889
- private var currentTargetWid: UInt32?
2890
-
2891
- var isVisible: Bool { panel?.isVisible ?? false }
2892
-
2893
- /// Show or update bezel for a known ScreenMapWindowEntry.
2894
- func show(for win: ScreenMapWindowEntry, editor: ScreenMapEditorState) {
2895
- let screens = NSScreen.screens
2896
- guard !screens.isEmpty else { return }
2897
- let primaryHeight = screens.first!.frame.height
2898
- let targetScreen: NSScreen
2899
- if win.displayIndex < screens.count {
2900
- targetScreen = screens[win.displayIndex]
2901
- } else {
2902
- targetScreen = screens.first!
2903
- }
2904
-
2905
- let targetWindowNumber = Self.findNSWindowNumber(forCGWindowID: win.id)
2906
-
2907
- let displayName = editor.displays.first(where: { $0.index == win.displayIndex })?.label ?? "Display \(win.displayIndex)"
2908
- let displayNumber = editor.spatialNumber(for: win.displayIndex)
2909
- let layerName = editor.layerDisplayName(for: win.layer)
2910
- let windowsOnDisplay = editor.windows.filter { $0.displayIndex == win.displayIndex }.count
2911
- let layersOnDisplay = editor.layersForDisplay(win.displayIndex).count
2912
-
2913
- let cgFrame = win.editedFrame
2914
- let screenNS = targetScreen.frame
2915
-
2916
- let winLocalX = cgFrame.origin.x - screenNS.origin.x
2917
- let winLocalY = (primaryHeight - cgFrame.origin.y - cgFrame.height) - screenNS.origin.y
2918
-
2919
- // Detect flush edges
2920
- let tolerance: CGFloat = 10
2921
- let flush = ShowOnScreenBezelView.FlushEdges(
2922
- top: (screenNS.height - (winLocalY + cgFrame.height)) < tolerance,
2923
- bottom: winLocalY < tolerance,
2924
- left: winLocalX < tolerance,
2925
- right: (screenNS.width - (winLocalX + cgFrame.width)) < tolerance
2926
- )
2927
-
2928
- // Shelf placement: prefer non-flush edges
2929
- let bezelH: CGFloat = 48
2930
- let spaceBelow = winLocalY - bezelH
2931
- let spaceAbove = screenNS.height - (winLocalY + cgFrame.height) - bezelH
2932
- let spaceLeft = winLocalX
2933
- let spaceRight = screenNS.width - (winLocalX + cgFrame.width)
2934
-
2935
- let placement: ShowOnScreenBezelView.LabelPlacement
2936
- if !flush.bottom && spaceBelow >= 0 {
2937
- placement = .below
2938
- } else if !flush.top && spaceAbove >= 0 {
2939
- placement = .above
2940
- } else if !flush.right && spaceRight >= 200 {
2941
- placement = .right
2942
- } else if !flush.left && spaceLeft >= 200 {
2943
- placement = .left
2944
- } else if spaceBelow >= 0 {
2945
- placement = .below
2946
- } else if spaceAbove >= 0 {
2947
- placement = .above
2948
- } else {
2949
- placement = .right
2950
- }
2951
-
2952
- // Compute tight frame
2953
- let edgePx: CGFloat = 5
2954
- let shelfPx: CGFloat = 40
2955
- let inL: CGFloat = flush.left ? 0 : edgePx
2956
- let inR: CGFloat = flush.right ? 0 : edgePx
2957
- let inT: CGFloat = flush.top ? 0 : edgePx
2958
- let inB: CGFloat = flush.bottom ? 0 : edgePx
2959
-
2960
- var fX = winLocalX - inL
2961
- var fY = winLocalY - inB
2962
- var fW = cgFrame.width + inL + inR
2963
- var fH = cgFrame.height + inT + inB
2964
-
2965
- switch placement {
2966
- case .below: fY -= shelfPx; fH += shelfPx
2967
- case .above: fH += shelfPx
2968
- case .right: fW += 200
2969
- case .left: fX -= 200; fW += 200
2970
- }
2971
-
2972
- let tightFrame = NSRect(
2973
- x: screenNS.origin.x + fX,
2974
- y: screenNS.origin.y + fY,
2975
- width: fW,
2976
- height: fH
2977
- )
2978
-
2979
- let localWinFrame = CGRect(
2980
- x: winLocalX - fX,
2981
- y: winLocalY - fY,
2982
- width: cgFrame.width,
2983
- height: cgFrame.height
2984
- )
2985
- let tightSize = CGSize(width: tightFrame.width, height: tightFrame.height)
2986
-
2987
- // Capture window content for screenshot tool compositing
2988
- let windowSnapshot: NSImage? = {
2989
- guard let cgImage = WindowCapture.image(
2990
- listOption: .optionIncludingWindow,
2991
- windowID: win.id,
2992
- imageOption: [.bestResolution, .boundsIgnoreFraming]
2993
- ) else { return nil }
2994
- return NSImage(cgImage: cgImage, size: NSSize(width: cgFrame.width, height: cgFrame.height))
2995
- }()
2996
-
2997
- let bezelView = ShowOnScreenBezelView(
2998
- appName: win.app,
2999
- windowTitle: win.title,
3000
- displayName: displayName,
3001
- displayNumber: displayNumber,
3002
- layerName: layerName,
3003
- windowSize: "\(Int(cgFrame.width))×\(Int(cgFrame.height))",
3004
- windowsOnDisplay: windowsOnDisplay,
3005
- layersOnDisplay: layersOnDisplay,
3006
- windowLocalFrame: localWinFrame,
3007
- screenSize: tightSize,
3008
- labelPlacement: placement,
3009
- flush: flush,
3010
- windowSnapshot: windowSnapshot
3011
- )
3012
-
3013
- let hostingView = NSHostingView(rootView: bezelView)
3014
- let isNewWindow = (panel == nil)
3015
-
3016
- if panel == nil {
3017
- let p = BezelPanel(
3018
- contentRect: tightFrame,
3019
- styleMask: [.borderless, .nonactivatingPanel],
3020
- backing: .buffered,
3021
- defer: false
3022
- )
3023
- p.isOpaque = false
3024
- p.backgroundColor = .clear
3025
- p.level = .normal
3026
- p.hasShadow = true
3027
- p.hidesOnDeactivate = false
3028
- p.isReleasedWhenClosed = false
3029
- p.isMovable = false // we handle dragging ourselves
3030
- p.appearance = nil
3031
- panel = p
3032
- }
3033
-
3034
- guard let p = panel else { return }
3035
-
3036
- p.contentView = hostingView
3037
- p.targetWid = win.id
3038
- p.targetPid = win.pid
3039
- currentTargetWid = win.id
3040
-
3041
- if isNewWindow {
3042
- // First show: position and fade in
3043
- p.setFrame(tightFrame, display: false)
3044
- p.alphaValue = 0
3045
-
3046
- if let targetWinNum = targetWindowNumber {
3047
- p.orderFrontRegardless()
3048
- p.order(.below, relativeTo: targetWinNum)
3049
- } else {
3050
- p.orderFrontRegardless()
3051
- }
3052
-
3053
- NSAnimationContext.runAnimationGroup { ctx in
3054
- ctx.duration = 0.15
3055
- p.animator().alphaValue = 1.0
3056
- }
3057
- } else {
3058
- // Reuse: animate to new position/size
3059
- if let targetWinNum = targetWindowNumber {
3060
- p.order(.below, relativeTo: targetWinNum)
3061
- }
3062
-
3063
- NSAnimationContext.runAnimationGroup { ctx in
3064
- ctx.duration = 0.2
3065
- ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
3066
- p.animator().setFrame(tightFrame, display: true)
3067
- p.animator().alphaValue = 1.0
3068
- }
3069
- }
3070
- }
3071
-
3072
- /// Toggle bezel for the frontmost window (global hotkey).
3073
- static func showBezelForFrontmostWindow() {
3074
- if shared.isVisible {
3075
- shared.dismiss()
3076
- return
3077
- }
3078
-
3079
- guard let frontApp = NSWorkspace.shared.frontmostApplication,
3080
- frontApp.bundleIdentifier != "com.arach.lattices" else { return }
3081
-
3082
- let pid = frontApp.processIdentifier
3083
-
3084
- guard let infoList = CGWindowListCopyWindowInfo(
3085
- [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
3086
- ) as? [[String: Any]] else { return }
3087
-
3088
- var targetInfo: [String: Any]?
3089
- for info in infoList {
3090
- guard let wPid = info[kCGWindowOwnerPID as String] as? Int32,
3091
- wPid == pid,
3092
- let wLayer = info[kCGWindowLayer as String] as? Int,
3093
- wLayer == 0 else { continue }
3094
- if let bounds = info[kCGWindowBounds as String] as? [String: CGFloat],
3095
- let w = bounds["Width"], let h = bounds["Height"],
3096
- w > 50, h > 50 {
3097
- targetInfo = info
3098
- break
3099
- }
3100
- }
3101
- guard let info = targetInfo,
3102
- let wid = info[kCGWindowNumber as String] as? UInt32 else { return }
3103
-
3104
- let ctrl = ScreenMapController()
3105
- ctrl.enter()
3106
- guard let ed = ctrl.editor,
3107
- let win = ed.windows.first(where: { $0.id == wid }) else { return }
3108
-
3109
- shared.show(for: win, editor: ed)
3110
- }
3111
-
3112
- func dismiss() {
3113
- guard let p = panel else { return }
3114
- NSAnimationContext.runAnimationGroup({ ctx in
3115
- ctx.duration = 0.2
3116
- p.animator().alphaValue = 0
3117
- }) { [weak self] in
3118
- p.orderOut(nil)
3119
- self?.panel = nil
3120
- self?.currentTargetWid = nil
3121
- }
3122
- }
3123
-
3124
- private static func findNSWindowNumber(forCGWindowID cgWid: UInt32) -> Int? {
3125
- guard let infoList = CGWindowListCopyWindowInfo(
3126
- [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
3127
- ) as? [[String: Any]] else { return nil }
3128
- for info in infoList {
3129
- guard let wid = info[kCGWindowNumber as String] as? UInt32,
3130
- wid == cgWid else { continue }
3131
- return Int(wid)
3132
- }
3133
- return nil
3134
- }
3135
- }