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