@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,1362 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
// MARK: - Phase
|
|
5
|
+
|
|
6
|
+
enum CommandModePhase: Equatable {
|
|
7
|
+
case idle
|
|
8
|
+
case inventory
|
|
9
|
+
case desktopInventory
|
|
10
|
+
case executing(String)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// MARK: - Inventory Snapshot
|
|
14
|
+
|
|
15
|
+
struct CommandModeInventory {
|
|
16
|
+
struct Item {
|
|
17
|
+
let name: String
|
|
18
|
+
let group: String // "Layer: X", "Group: Y", "Orphan"
|
|
19
|
+
let status: Status
|
|
20
|
+
let paneCount: Int
|
|
21
|
+
let tileHint: String? // "left", "right", etc.
|
|
22
|
+
}
|
|
23
|
+
enum Status { case running, attached, stopped }
|
|
24
|
+
|
|
25
|
+
let activeLayer: String?
|
|
26
|
+
let layerCount: Int
|
|
27
|
+
let items: [Item]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// MARK: - Chord
|
|
31
|
+
|
|
32
|
+
struct Chord {
|
|
33
|
+
let key: String // display label e.g. "a", "1"
|
|
34
|
+
let keyCode: UInt16
|
|
35
|
+
let label: String // e.g. "tile all"
|
|
36
|
+
let action: () -> Void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// MARK: - Desktop Inventory Mode
|
|
40
|
+
|
|
41
|
+
enum DesktopInventoryMode: Equatable {
|
|
42
|
+
case browsing
|
|
43
|
+
case tiling // t → tile picker
|
|
44
|
+
case gridPreview // s → preview grid layout before applying
|
|
45
|
+
case screenMap // m → interactive screen map editor
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// DisplayGeometry, ScreenMapWindowEntry, ScreenMapEditorState, ScreenMapActionLog
|
|
49
|
+
// are defined in ScreenMapState.swift
|
|
50
|
+
// MARK: - Filter Presets
|
|
51
|
+
|
|
52
|
+
enum FilterPreset: String, CaseIterable {
|
|
53
|
+
case all = "All"
|
|
54
|
+
case terminals = "Terminals"
|
|
55
|
+
case editors = "Editors"
|
|
56
|
+
case browsers = "Browsers"
|
|
57
|
+
case lattices = "Lattices"
|
|
58
|
+
case currentSpace = "Current Space"
|
|
59
|
+
|
|
60
|
+
var appTypes: Set<AppType>? {
|
|
61
|
+
switch self {
|
|
62
|
+
case .all: return nil
|
|
63
|
+
case .terminals: return [.terminal]
|
|
64
|
+
case .editors: return [.editor]
|
|
65
|
+
case .browsers: return [.browser]
|
|
66
|
+
case .lattices: return nil // special case
|
|
67
|
+
case .currentSpace: return nil // special case
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
var keyIndex: Int? {
|
|
72
|
+
switch self {
|
|
73
|
+
case .all: return 1
|
|
74
|
+
case .terminals: return 2
|
|
75
|
+
case .editors: return 3
|
|
76
|
+
case .browsers: return 4
|
|
77
|
+
case .lattices: return 5
|
|
78
|
+
case .currentSpace: return 6
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static func from(keyIndex: Int) -> FilterPreset? {
|
|
83
|
+
allCases.first { $0.keyIndex == keyIndex }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// MARK: - State Machine
|
|
88
|
+
|
|
89
|
+
final class CommandModeState: ObservableObject {
|
|
90
|
+
@Published var phase: CommandModePhase = .idle
|
|
91
|
+
@Published var inventory = CommandModeInventory(activeLayer: nil, layerCount: 0, items: [])
|
|
92
|
+
@Published var chords: [Chord] = []
|
|
93
|
+
@Published var desktopSnapshot: DesktopInventorySnapshot?
|
|
94
|
+
@Published var selectedWindowIds: Set<UInt32> = []
|
|
95
|
+
@Published var desktopMode: DesktopInventoryMode = .browsing
|
|
96
|
+
@Published var activePreset: FilterPreset? = nil
|
|
97
|
+
@Published var searchQuery: String = ""
|
|
98
|
+
@Published var isSearching: Bool = false
|
|
99
|
+
|
|
100
|
+
// MARK: - Marquee Drag State
|
|
101
|
+
@Published var isDragging: Bool = false
|
|
102
|
+
@Published var marqueeOrigin: CGPoint = .zero
|
|
103
|
+
@Published var marqueeCurrentPoint: CGPoint = .zero
|
|
104
|
+
|
|
105
|
+
/// Computed normalized rect from origin → current drag point
|
|
106
|
+
var marqueeRect: CGRect {
|
|
107
|
+
let x = min(marqueeOrigin.x, marqueeCurrentPoint.x)
|
|
108
|
+
let y = min(marqueeOrigin.y, marqueeCurrentPoint.y)
|
|
109
|
+
let w = abs(marqueeCurrentPoint.x - marqueeOrigin.x)
|
|
110
|
+
let h = abs(marqueeCurrentPoint.y - marqueeOrigin.y)
|
|
111
|
+
return CGRect(x: x, y: y, width: w, height: h)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Row frames in inventoryPanel coordinate space (updated by PreferenceKey)
|
|
115
|
+
var rowFrames: [UInt32: CGRect] = [:]
|
|
116
|
+
|
|
117
|
+
/// Raw mouse-down point for drag threshold detection (screen coordinates)
|
|
118
|
+
var dragStartPoint: NSPoint?
|
|
119
|
+
|
|
120
|
+
/// Selection state before drag started (for Cmd+drag additive mode)
|
|
121
|
+
private var preDragSelection: Set<UInt32> = []
|
|
122
|
+
|
|
123
|
+
// MARK: - Saved Positions (for restore after show & distribute)
|
|
124
|
+
/// Saved window frames before a show/distribute action — allows undo
|
|
125
|
+
@Published var savedPositions: [UInt32: (pid: Int32, frame: WindowFrame)]? = nil
|
|
126
|
+
|
|
127
|
+
/// Brief flash message shown after an action (auto-dismisses)
|
|
128
|
+
@Published var flashMessage: String? = nil
|
|
129
|
+
|
|
130
|
+
var onDismiss: (() -> Void)?
|
|
131
|
+
var onPanelResize: ((_ width: CGFloat, _ height: CGFloat) -> Void)?
|
|
132
|
+
|
|
133
|
+
/// Tracks the last item navigated to, for consistent Shift+arrow multi-select
|
|
134
|
+
private var cursorWindowId: UInt32?
|
|
135
|
+
|
|
136
|
+
// MARK: - Selection Helpers
|
|
137
|
+
|
|
138
|
+
/// Backwards-compat: returns single selected ID (first element)
|
|
139
|
+
var selectedWindowId: UInt32? {
|
|
140
|
+
selectedWindowIds.first
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func isSelected(_ id: UInt32) -> Bool {
|
|
144
|
+
selectedWindowIds.contains(id)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func selectSingle(_ id: UInt32) {
|
|
148
|
+
selectedWindowIds = [id]
|
|
149
|
+
cursorWindowId = id
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func toggleSelection(_ id: UInt32) {
|
|
153
|
+
if selectedWindowIds.contains(id) {
|
|
154
|
+
selectedWindowIds.remove(id)
|
|
155
|
+
} else {
|
|
156
|
+
selectedWindowIds.insert(id)
|
|
157
|
+
}
|
|
158
|
+
cursorWindowId = id
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func clearSelection() {
|
|
162
|
+
selectedWindowIds = []
|
|
163
|
+
cursorWindowId = nil
|
|
164
|
+
isDragging = false
|
|
165
|
+
dragStartPoint = nil
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// Select contiguous range from cursor anchor to target (Shift+click)
|
|
169
|
+
func selectRange(to targetId: UInt32) {
|
|
170
|
+
guard let anchorId = cursorWindowId else { selectSingle(targetId); return }
|
|
171
|
+
let list = flatWindowList
|
|
172
|
+
guard let anchorIdx = list.firstIndex(where: { $0.id == anchorId }),
|
|
173
|
+
let targetIdx = list.firstIndex(where: { $0.id == targetId }) else {
|
|
174
|
+
selectSingle(targetId)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
let lo = min(anchorIdx, targetIdx)
|
|
178
|
+
let hi = max(anchorIdx, targetIdx)
|
|
179
|
+
selectedWindowIds = Set(list[lo...hi].map(\.id))
|
|
180
|
+
// cursorWindowId stays as anchor for subsequent Shift+clicks
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// MARK: - Marquee Drag
|
|
184
|
+
|
|
185
|
+
func beginDrag(at point: CGPoint, additive: Bool) {
|
|
186
|
+
preDragSelection = additive ? selectedWindowIds : []
|
|
187
|
+
marqueeOrigin = point
|
|
188
|
+
marqueeCurrentPoint = point
|
|
189
|
+
isDragging = true
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func updateDrag(to point: CGPoint) {
|
|
193
|
+
marqueeCurrentPoint = point
|
|
194
|
+
updateMarqueeSelection()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func endDrag() {
|
|
198
|
+
isDragging = false
|
|
199
|
+
dragStartPoint = nil
|
|
200
|
+
preDragSelection = []
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Select all rows whose frames intersect the current marquee rect
|
|
204
|
+
private func updateMarqueeSelection() {
|
|
205
|
+
let rect = marqueeRect
|
|
206
|
+
var hits = preDragSelection
|
|
207
|
+
for (wid, frame) in rowFrames {
|
|
208
|
+
if rect.intersects(frame) {
|
|
209
|
+
hits.insert(wid)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
selectedWindowIds = hits
|
|
213
|
+
if let first = hits.first { cursorWindowId = first }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func activateSearch() {
|
|
217
|
+
isSearching = true
|
|
218
|
+
searchQuery = ""
|
|
219
|
+
clearSelection()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func deactivateSearch() {
|
|
223
|
+
isSearching = false
|
|
224
|
+
searchQuery = ""
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// Filtered desktop snapshot based on active preset and search query
|
|
228
|
+
var filteredSnapshot: DesktopInventorySnapshot? {
|
|
229
|
+
guard let snapshot = desktopSnapshot else { return nil }
|
|
230
|
+
|
|
231
|
+
let needsPresetFilter = activePreset != nil && activePreset != .all
|
|
232
|
+
let needsSearchFilter = isSearching && !searchQuery.isEmpty
|
|
233
|
+
guard needsPresetFilter || needsSearchFilter else { return snapshot }
|
|
234
|
+
|
|
235
|
+
let query = searchQuery.lowercased()
|
|
236
|
+
|
|
237
|
+
let filteredDisplays = snapshot.displays.compactMap { display -> DesktopInventorySnapshot.DisplayInfo? in
|
|
238
|
+
let filteredSpaces = display.spaces.compactMap { space -> DesktopInventorySnapshot.SpaceGroup? in
|
|
239
|
+
if let preset = activePreset, preset == .currentSpace && !space.isCurrent { return nil }
|
|
240
|
+
|
|
241
|
+
let filteredApps = space.apps.compactMap { appGroup -> DesktopInventorySnapshot.AppGroup? in
|
|
242
|
+
let filteredWindows = appGroup.windows.filter { win in
|
|
243
|
+
// Preset filter
|
|
244
|
+
if let preset = activePreset, preset != .all {
|
|
245
|
+
let passesPreset: Bool
|
|
246
|
+
switch preset {
|
|
247
|
+
case .lattices: passesPreset = win.isLattices
|
|
248
|
+
case .currentSpace: passesPreset = true
|
|
249
|
+
default:
|
|
250
|
+
if let types = preset.appTypes, let name = win.appName {
|
|
251
|
+
passesPreset = types.contains(AppTypeClassifier.classify(name))
|
|
252
|
+
} else {
|
|
253
|
+
passesPreset = false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if !passesPreset { return false }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Search filter
|
|
260
|
+
if needsSearchFilter {
|
|
261
|
+
let matchesApp = win.appName?.lowercased().contains(query) ?? false
|
|
262
|
+
let matchesTitle = win.title.lowercased().contains(query)
|
|
263
|
+
let matchesLattices = win.latticesSession?.lowercased().contains(query) ?? false
|
|
264
|
+
let matchesOcr = OcrModel.shared.results[win.id]?.fullText
|
|
265
|
+
.lowercased().contains(query) ?? false
|
|
266
|
+
if !matchesApp && !matchesTitle && !matchesLattices && !matchesOcr { return false }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
guard !filteredWindows.isEmpty else { return nil }
|
|
272
|
+
return DesktopInventorySnapshot.AppGroup(
|
|
273
|
+
id: appGroup.id, appName: appGroup.appName, windows: filteredWindows
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
guard !filteredApps.isEmpty else { return nil }
|
|
277
|
+
return DesktopInventorySnapshot.SpaceGroup(
|
|
278
|
+
id: space.id, index: space.index, isCurrent: space.isCurrent, apps: filteredApps
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
guard !filteredSpaces.isEmpty else { return nil }
|
|
282
|
+
return DesktopInventorySnapshot.DisplayInfo(
|
|
283
|
+
id: display.id, name: display.name, resolution: display.resolution,
|
|
284
|
+
visibleFrame: display.visibleFrame, isMain: display.isMain,
|
|
285
|
+
spaceCount: display.spaceCount, currentSpaceIndex: display.currentSpaceIndex,
|
|
286
|
+
spaces: filteredSpaces
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
return DesktopInventorySnapshot(displays: filteredDisplays, timestamp: snapshot.timestamp)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// Compact panel size for chord view
|
|
293
|
+
private let chordPanelSize: (CGFloat, CGFloat) = (580, 360)
|
|
294
|
+
|
|
295
|
+
/// Compute desktop inventory panel size based on display count, clamped to screen
|
|
296
|
+
private var desktopPanelSize: (CGFloat, CGFloat) {
|
|
297
|
+
let displayCount = max(1, desktopSnapshot?.displays.count ?? 1)
|
|
298
|
+
let ideal = CGFloat(displayCount) * 480 + CGFloat(displayCount - 1) + 32
|
|
299
|
+
let screenWidth = NSScreen.main?.visibleFrame.width ?? 1920
|
|
300
|
+
let width = min(ideal, screenWidth * 0.92)
|
|
301
|
+
let height: CGFloat = 640
|
|
302
|
+
return (width, height)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/// Flat window list for keyboard navigation (respects active filter)
|
|
306
|
+
var flatWindowList: [DesktopInventorySnapshot.InventoryWindowInfo] {
|
|
307
|
+
filteredSnapshot?.allWindows ?? []
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
var ocrMatchSnippets: [UInt32: String] {
|
|
311
|
+
guard isSearching, !searchQuery.isEmpty else { return [:] }
|
|
312
|
+
let query = searchQuery.lowercased()
|
|
313
|
+
let ocrResults = OcrModel.shared.results
|
|
314
|
+
var snippets: [UInt32: String] = [:]
|
|
315
|
+
for win in flatWindowList {
|
|
316
|
+
// Only show snippet if match came from OCR, not title/app
|
|
317
|
+
let matchesApp = win.appName?.lowercased().contains(query) ?? false
|
|
318
|
+
let matchesTitle = win.title.lowercased().contains(query)
|
|
319
|
+
let matchesLattices = win.latticesSession?.lowercased().contains(query) ?? false
|
|
320
|
+
if matchesApp || matchesTitle || matchesLattices { continue }
|
|
321
|
+
if let ocr = ocrResults[win.id],
|
|
322
|
+
let range = ocr.fullText.lowercased().range(of: query) {
|
|
323
|
+
snippets[win.id] = Self.extractSnippet(from: ocr.fullText, around: range)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return snippets
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private static func extractSnippet(from text: String, around range: Range<String.Index>, maxLen: Int = 80) -> String {
|
|
330
|
+
let half = max(0, (maxLen - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
|
|
331
|
+
let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
|
|
332
|
+
let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
|
|
333
|
+
var s = String(text[start..<end])
|
|
334
|
+
.replacingOccurrences(of: "\n", with: " ")
|
|
335
|
+
.trimmingCharacters(in: .whitespaces)
|
|
336
|
+
if start > text.startIndex { s = "…" + s }
|
|
337
|
+
if end < text.endIndex { s += "…" }
|
|
338
|
+
return s
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func enter() {
|
|
342
|
+
inventory = buildInventory()
|
|
343
|
+
chords = buildChords()
|
|
344
|
+
desktopSnapshot = buildDesktopInventory()
|
|
345
|
+
clearSelection()
|
|
346
|
+
desktopMode = .browsing
|
|
347
|
+
phase = .desktopInventory
|
|
348
|
+
// Don't call onPanelResize here — caller handles initial sizing
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/// Returns true if the key was consumed
|
|
352
|
+
func handleKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
|
|
353
|
+
// Backtick (keyCode 50) toggles desktop inventory from either phase
|
|
354
|
+
if keyCode == 50 {
|
|
355
|
+
if isSearching {
|
|
356
|
+
deactivateSearch()
|
|
357
|
+
return true
|
|
358
|
+
}
|
|
359
|
+
if phase == .desktopInventory {
|
|
360
|
+
// Back to chord view
|
|
361
|
+
clearSelection()
|
|
362
|
+
desktopMode = .browsing
|
|
363
|
+
activePreset = nil
|
|
364
|
+
phase = .inventory
|
|
365
|
+
onPanelResize?(chordPanelSize.0, chordPanelSize.1)
|
|
366
|
+
return true
|
|
367
|
+
} else if phase == .inventory {
|
|
368
|
+
// Enter desktop inventory
|
|
369
|
+
let diag = DiagnosticLog.shared
|
|
370
|
+
desktopSnapshot = buildDesktopInventory()
|
|
371
|
+
clearSelection()
|
|
372
|
+
desktopMode = .browsing
|
|
373
|
+
phase = .desktopInventory
|
|
374
|
+
let size = desktopPanelSize
|
|
375
|
+
onPanelResize?(size.0, size.1)
|
|
376
|
+
if let snap = desktopSnapshot {
|
|
377
|
+
let totalWindows = snap.allWindows.count
|
|
378
|
+
let totalSpaces = snap.displays.reduce(0) { $0 + $1.spaces.count }
|
|
379
|
+
diag.info("Desktop inventory: \(snap.displays.count) display(s), \(totalSpaces) space(s), \(totalWindows) window(s)")
|
|
380
|
+
}
|
|
381
|
+
return true
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Route desktop inventory keys
|
|
386
|
+
if phase == .desktopInventory {
|
|
387
|
+
return handleDesktopInventoryKey(keyCode, modifiers: modifiers)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Escape from chord view → dismiss
|
|
391
|
+
if keyCode == 53 {
|
|
392
|
+
dismiss()
|
|
393
|
+
return true
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
guard phase == .inventory else { return false }
|
|
397
|
+
|
|
398
|
+
// Check chord map
|
|
399
|
+
if let chord = chords.first(where: { $0.keyCode == keyCode }) {
|
|
400
|
+
phase = .executing(chord.label)
|
|
401
|
+
let action = chord.action
|
|
402
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
403
|
+
action()
|
|
404
|
+
self?.dismiss()
|
|
405
|
+
}
|
|
406
|
+
return true
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Unknown key — ignore
|
|
410
|
+
return true
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// MARK: - Desktop Inventory Key Handling
|
|
414
|
+
|
|
415
|
+
private func handleDesktopInventoryKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
|
|
416
|
+
switch desktopMode {
|
|
417
|
+
case .browsing: return handleBrowsingKey(keyCode, modifiers: modifiers)
|
|
418
|
+
case .tiling: return handleTilingKey(keyCode, modifiers: modifiers)
|
|
419
|
+
case .gridPreview: return handleGridPreviewKey(keyCode)
|
|
420
|
+
case .screenMap: return true // handled by standalone ScreenMapWindowController
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// MARK: Browsing — ↑↓ within column, ←→ between displays, Enter → actions
|
|
425
|
+
|
|
426
|
+
private func handleBrowsingKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
|
|
427
|
+
// Cmd+A → select all visible windows (works during search too — selects filtered results)
|
|
428
|
+
if keyCode == 0 && modifiers.contains(.command) {
|
|
429
|
+
let allIds = Set(flatWindowList.map(\.id))
|
|
430
|
+
if selectedWindowIds == allIds {
|
|
431
|
+
clearSelection() // toggle off
|
|
432
|
+
} else {
|
|
433
|
+
selectedWindowIds = allIds
|
|
434
|
+
}
|
|
435
|
+
if isSearching { deactivateSearch() }
|
|
436
|
+
return true
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
switch keyCode {
|
|
440
|
+
case 53: // Escape — always dismiss
|
|
441
|
+
onDismiss?()
|
|
442
|
+
return true
|
|
443
|
+
|
|
444
|
+
case 126: // ↑
|
|
445
|
+
if modifiers.contains(.shift) {
|
|
446
|
+
extendSelectionVertical(-1)
|
|
447
|
+
} else {
|
|
448
|
+
moveSelectionVertical(-1)
|
|
449
|
+
}
|
|
450
|
+
return true
|
|
451
|
+
|
|
452
|
+
case 125: // ↓
|
|
453
|
+
if modifiers.contains(.shift) {
|
|
454
|
+
extendSelectionVertical(1)
|
|
455
|
+
} else {
|
|
456
|
+
moveSelectionVertical(1)
|
|
457
|
+
}
|
|
458
|
+
return true
|
|
459
|
+
|
|
460
|
+
case 38: // j
|
|
461
|
+
if isSearching { return false }
|
|
462
|
+
if modifiers.contains(.shift) {
|
|
463
|
+
extendSelectionVertical(1)
|
|
464
|
+
} else {
|
|
465
|
+
moveSelectionVertical(1)
|
|
466
|
+
}
|
|
467
|
+
return true
|
|
468
|
+
|
|
469
|
+
case 40: // k
|
|
470
|
+
if isSearching { return false }
|
|
471
|
+
if modifiers.contains(.shift) {
|
|
472
|
+
extendSelectionVertical(-1)
|
|
473
|
+
} else {
|
|
474
|
+
moveSelectionVertical(-1)
|
|
475
|
+
}
|
|
476
|
+
return true
|
|
477
|
+
|
|
478
|
+
case 123: // ← → jump to previous display
|
|
479
|
+
moveSelectionToDisplay(delta: -1)
|
|
480
|
+
return true
|
|
481
|
+
|
|
482
|
+
case 124: // → → jump to next display
|
|
483
|
+
moveSelectionToDisplay(delta: 1)
|
|
484
|
+
return true
|
|
485
|
+
|
|
486
|
+
case 36: // Enter
|
|
487
|
+
if isSearching {
|
|
488
|
+
// Select first match and bring to front
|
|
489
|
+
if let first = flatWindowList.first {
|
|
490
|
+
selectSingle(first.id)
|
|
491
|
+
bringSelectedToFront()
|
|
492
|
+
}
|
|
493
|
+
deactivateSearch()
|
|
494
|
+
return true
|
|
495
|
+
}
|
|
496
|
+
if !selectedWindowIds.isEmpty {
|
|
497
|
+
if selectedWindowIds.count > 1 {
|
|
498
|
+
bringAllSelectedToFront()
|
|
499
|
+
} else {
|
|
500
|
+
bringSelectedToFront()
|
|
501
|
+
}
|
|
502
|
+
} else {
|
|
503
|
+
moveSelectionVertical(1) // select first window
|
|
504
|
+
}
|
|
505
|
+
return true
|
|
506
|
+
|
|
507
|
+
case 44: // / → activate search
|
|
508
|
+
if !isSearching {
|
|
509
|
+
activateSearch()
|
|
510
|
+
return true
|
|
511
|
+
}
|
|
512
|
+
return false
|
|
513
|
+
|
|
514
|
+
case 3: // f → focus window directly
|
|
515
|
+
if isSearching && selectedWindowIds.isEmpty { return false }
|
|
516
|
+
if isSearching { deactivateSearch() }
|
|
517
|
+
if !selectedWindowIds.isEmpty {
|
|
518
|
+
if selectedWindowIds.count > 1 {
|
|
519
|
+
focusAllSelected()
|
|
520
|
+
} else {
|
|
521
|
+
focusSelectedWindow()
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return true
|
|
525
|
+
|
|
526
|
+
case 17: // t → enter tiling mode directly
|
|
527
|
+
if isSearching && selectedWindowIds.isEmpty { return false }
|
|
528
|
+
if isSearching { deactivateSearch() }
|
|
529
|
+
if !selectedWindowIds.isEmpty {
|
|
530
|
+
desktopMode = .tiling
|
|
531
|
+
}
|
|
532
|
+
return true
|
|
533
|
+
|
|
534
|
+
case 1: // s → grid preview (or show & distribute if single)
|
|
535
|
+
if isSearching && selectedWindowIds.isEmpty { return false }
|
|
536
|
+
if isSearching { deactivateSearch() }
|
|
537
|
+
if !selectedWindowIds.isEmpty {
|
|
538
|
+
desktopMode = .gridPreview
|
|
539
|
+
}
|
|
540
|
+
return true
|
|
541
|
+
|
|
542
|
+
case 4: // h → highlight window directly
|
|
543
|
+
if isSearching && selectedWindowIds.isEmpty { return false }
|
|
544
|
+
if isSearching { deactivateSearch() }
|
|
545
|
+
if !selectedWindowIds.isEmpty {
|
|
546
|
+
if selectedWindowIds.count > 1 {
|
|
547
|
+
highlightAllSelected()
|
|
548
|
+
} else {
|
|
549
|
+
highlightSelectedWindow()
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return true
|
|
553
|
+
|
|
554
|
+
case 46: // m → screen map editor (standalone window)
|
|
555
|
+
if isSearching { deactivateSearch() }
|
|
556
|
+
ScreenMapWindowController.shared.show()
|
|
557
|
+
return true
|
|
558
|
+
|
|
559
|
+
case 18, 19, 20, 21, 23, 22: // 1-6 → filter presets (only when no selection and not searching)
|
|
560
|
+
if isSearching { return false }
|
|
561
|
+
if selectedWindowIds.isEmpty {
|
|
562
|
+
let keyToIndex: [UInt16: Int] = [18: 1, 19: 2, 20: 3, 21: 4, 23: 5, 22: 6]
|
|
563
|
+
if let idx = keyToIndex[keyCode], let preset = FilterPreset.from(keyIndex: idx) {
|
|
564
|
+
if activePreset == preset {
|
|
565
|
+
activePreset = nil // toggle off
|
|
566
|
+
} else {
|
|
567
|
+
activePreset = preset
|
|
568
|
+
}
|
|
569
|
+
clearSelection()
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return true
|
|
573
|
+
|
|
574
|
+
default:
|
|
575
|
+
if isSearching { return false }
|
|
576
|
+
return true
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// MARK: Tiling — position keys
|
|
581
|
+
|
|
582
|
+
private func handleTilingKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
|
|
583
|
+
switch keyCode {
|
|
584
|
+
case 53: // Escape — always dismiss
|
|
585
|
+
onDismiss?()
|
|
586
|
+
return true
|
|
587
|
+
|
|
588
|
+
case 123: tileSelectedWindow(to: .left); return true // ←
|
|
589
|
+
case 124: tileSelectedWindow(to: .right); return true // →
|
|
590
|
+
case 126: // ↑ — shift=maximize, plain=top half
|
|
591
|
+
if modifiers.contains(.shift) {
|
|
592
|
+
tileSelectedWindow(to: .maximize)
|
|
593
|
+
} else {
|
|
594
|
+
tileSelectedWindow(to: .top)
|
|
595
|
+
}
|
|
596
|
+
return true
|
|
597
|
+
case 125: tileSelectedWindow(to: .bottom); return true // ↓
|
|
598
|
+
case 18: tileSelectedWindow(to: .topLeft); return true // 1
|
|
599
|
+
case 19: tileSelectedWindow(to: .topRight); return true // 2
|
|
600
|
+
case 20: tileSelectedWindow(to: .bottomLeft); return true // 3
|
|
601
|
+
case 21: tileSelectedWindow(to: .bottomRight); return true// 4
|
|
602
|
+
case 23: tileSelectedWindow(to: .leftThird); return true // 5
|
|
603
|
+
case 22: tileSelectedWindow(to: .centerThird); return true// 6
|
|
604
|
+
case 26: tileSelectedWindow(to: .rightThird); return true // 7
|
|
605
|
+
case 8: tileSelectedWindow(to: .center); return true // c
|
|
606
|
+
case 2: distributeSelectedHorizontally(); return true // d → distribute
|
|
607
|
+
|
|
608
|
+
default:
|
|
609
|
+
return true
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// MARK: Grid Preview — Enter/s to apply, Esc to cancel
|
|
614
|
+
|
|
615
|
+
private func handleGridPreviewKey(_ keyCode: UInt16) -> Bool {
|
|
616
|
+
switch keyCode {
|
|
617
|
+
case 53: // Escape — always dismiss
|
|
618
|
+
onDismiss?()
|
|
619
|
+
return true
|
|
620
|
+
|
|
621
|
+
case 36, 1: // Enter or s → apply the layout
|
|
622
|
+
showAndDistributeSelected()
|
|
623
|
+
desktopMode = .browsing
|
|
624
|
+
return true
|
|
625
|
+
|
|
626
|
+
default:
|
|
627
|
+
return true
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/// Windows arranged in grid order for preview
|
|
632
|
+
var gridPreviewWindows: [DesktopInventorySnapshot.InventoryWindowInfo] {
|
|
633
|
+
flatWindowList.filter { selectedWindowIds.contains($0.id) }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/// Grid shape for current selection
|
|
637
|
+
var gridPreviewShape: [Int] {
|
|
638
|
+
WindowTiler.gridShape(for: selectedWindowIds.count)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// MARK: - Selection Actions
|
|
642
|
+
|
|
643
|
+
/// Move selection up/down within the flat window list (stays in same display column when possible)
|
|
644
|
+
private func moveSelectionVertical(_ delta: Int) {
|
|
645
|
+
guard let snapshot = filteredSnapshot else { return }
|
|
646
|
+
|
|
647
|
+
let anchor = cursorWindowId ?? selectedWindowId
|
|
648
|
+
if let anchor = anchor,
|
|
649
|
+
let displayIdx = displayIndex(for: anchor, in: snapshot) {
|
|
650
|
+
let displayWindows = windowsInDisplay(displayIdx, snapshot: snapshot)
|
|
651
|
+
if let localIdx = displayWindows.firstIndex(where: { $0.id == anchor }) {
|
|
652
|
+
let newIdx = max(0, min(displayWindows.count - 1, localIdx + delta))
|
|
653
|
+
selectSingle(displayWindows[newIdx].id)
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
// No selection — pick first window in first display
|
|
657
|
+
let windows = flatWindowList
|
|
658
|
+
guard !windows.isEmpty else { return }
|
|
659
|
+
if let id = delta > 0 ? windows.first?.id : windows.last?.id {
|
|
660
|
+
selectSingle(id)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if let wid = cursorWindowId, let win = flatWindowList.first(where: { $0.id == wid }) {
|
|
665
|
+
let title = win.title.isEmpty ? "(untitled)" : String(win.title.prefix(30))
|
|
666
|
+
DiagnosticLog.shared.info("Select: wid=\(wid) \"\(title)\"")
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/// Extend selection up/down (Shift+arrow) — adds items without removing existing selection
|
|
671
|
+
private func extendSelectionVertical(_ delta: Int) {
|
|
672
|
+
guard let snapshot = filteredSnapshot else { return }
|
|
673
|
+
|
|
674
|
+
let anchor = cursorWindowId ?? selectedWindowId
|
|
675
|
+
if let anchor = anchor,
|
|
676
|
+
let displayIdx = displayIndex(for: anchor, in: snapshot) {
|
|
677
|
+
let displayWindows = windowsInDisplay(displayIdx, snapshot: snapshot)
|
|
678
|
+
if let localIdx = displayWindows.firstIndex(where: { $0.id == anchor }) {
|
|
679
|
+
let newIdx = max(0, min(displayWindows.count - 1, localIdx + delta))
|
|
680
|
+
let newId = displayWindows[newIdx].id
|
|
681
|
+
selectedWindowIds.insert(newId)
|
|
682
|
+
cursorWindowId = newId
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
let windows = flatWindowList
|
|
686
|
+
guard !windows.isEmpty else { return }
|
|
687
|
+
if let id = delta > 0 ? windows.first?.id : windows.last?.id {
|
|
688
|
+
selectedWindowIds.insert(id)
|
|
689
|
+
cursorWindowId = id
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/// Jump selection to the adjacent display column
|
|
695
|
+
private func moveSelectionToDisplay(delta: Int) {
|
|
696
|
+
guard let snapshot = filteredSnapshot, snapshot.displays.count > 1 else { return }
|
|
697
|
+
|
|
698
|
+
let displayCount = snapshot.displays.count
|
|
699
|
+
|
|
700
|
+
// Find current display index
|
|
701
|
+
let currentDisplayIdx: Int
|
|
702
|
+
if let wid = selectedWindowId, let idx = displayIndex(for: wid, in: snapshot) {
|
|
703
|
+
currentDisplayIdx = idx
|
|
704
|
+
} else {
|
|
705
|
+
// No selection — start from first or last display
|
|
706
|
+
currentDisplayIdx = delta > 0 ? -1 : displayCount
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
let targetIdx = currentDisplayIdx + delta
|
|
710
|
+
guard targetIdx >= 0, targetIdx < displayCount else { return }
|
|
711
|
+
|
|
712
|
+
// Find the position in the current display for context
|
|
713
|
+
let targetWindows = windowsInDisplay(targetIdx, snapshot: snapshot)
|
|
714
|
+
guard !targetWindows.isEmpty else { return }
|
|
715
|
+
|
|
716
|
+
// Try to land at a similar position (same row index)
|
|
717
|
+
if let wid = selectedWindowId,
|
|
718
|
+
let srcIdx = displayIndex(for: wid, in: snapshot) {
|
|
719
|
+
let srcWindows = windowsInDisplay(srcIdx, snapshot: snapshot)
|
|
720
|
+
let srcPos = srcWindows.firstIndex(where: { $0.id == wid }) ?? 0
|
|
721
|
+
let targetPos = min(srcPos, targetWindows.count - 1)
|
|
722
|
+
selectSingle(targetWindows[targetPos].id)
|
|
723
|
+
} else if let id = targetWindows.first?.id {
|
|
724
|
+
selectSingle(id)
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
DiagnosticLog.shared.info("Jump to display \(targetIdx + 1)")
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// MARK: - Display Helpers
|
|
731
|
+
|
|
732
|
+
/// Get the display index for a given window ID
|
|
733
|
+
private func displayIndex(for wid: UInt32, in snapshot: DesktopInventorySnapshot) -> Int? {
|
|
734
|
+
for (dIdx, display) in snapshot.displays.enumerated() {
|
|
735
|
+
for space in display.spaces {
|
|
736
|
+
for app in space.apps {
|
|
737
|
+
if app.windows.contains(where: { $0.id == wid }) {
|
|
738
|
+
return dIdx
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return nil
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/// Get all windows in a display as a flat list (preserving space/app order)
|
|
747
|
+
private func windowsInDisplay(_ displayIdx: Int, snapshot: DesktopInventorySnapshot) -> [DesktopInventorySnapshot.InventoryWindowInfo] {
|
|
748
|
+
guard displayIdx < snapshot.displays.count else { return [] }
|
|
749
|
+
return snapshot.displays[displayIdx].spaces.flatMap { $0.apps.flatMap { $0.windows } }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private func bringSelectedToFront() {
|
|
753
|
+
guard let wid = selectedWindowId,
|
|
754
|
+
let window = flatWindowList.first(where: { $0.id == wid }) else { return }
|
|
755
|
+
DiagnosticLog.shared.info("Front: wid=\(wid) pid=\(window.pid)")
|
|
756
|
+
WindowTiler.raiseWindowAndReactivate(wid: wid, pid: window.pid)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private func bringAllSelectedToFront() {
|
|
760
|
+
let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
|
|
761
|
+
guard !windows.isEmpty else { return }
|
|
762
|
+
DiagnosticLog.shared.info("Front all: \(windows.count) windows")
|
|
763
|
+
WindowTiler.raiseWindowsAndReactivate(windows: windows.map { (wid: $0.id, pid: $0.pid) })
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private func focusSelectedWindow() {
|
|
767
|
+
guard let wid = selectedWindowId,
|
|
768
|
+
let window = flatWindowList.first(where: { $0.id == wid }) else { return }
|
|
769
|
+
DiagnosticLog.shared.info("Focus: wid=\(wid) pid=\(window.pid)")
|
|
770
|
+
WindowTiler.raiseWindowAndReactivate(wid: wid, pid: window.pid)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
private func highlightSelectedWindow() {
|
|
774
|
+
guard let wid = selectedWindowId else { return }
|
|
775
|
+
DiagnosticLog.shared.info("Highlight: wid=\(wid)")
|
|
776
|
+
WindowTiler.highlightWindowById(wid: wid)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private func tileSelectedWindow(to position: TilePosition) {
|
|
780
|
+
if selectedWindowIds.count > 1 {
|
|
781
|
+
tileAllSelected(to: position)
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
guard let wid = selectedWindowId,
|
|
785
|
+
let window = flatWindowList.first(where: { $0.id == wid }) else { return }
|
|
786
|
+
|
|
787
|
+
DiagnosticLog.shared.info("Tile: wid=\(wid) → \(position.rawValue)")
|
|
788
|
+
WindowTiler.tileWindowById(wid: wid, pid: window.pid, to: position)
|
|
789
|
+
|
|
790
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
791
|
+
self?.desktopSnapshot = self?.buildDesktopInventory()
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private func tileAllSelected(to position: TilePosition) {
|
|
796
|
+
let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
|
|
797
|
+
guard !windows.isEmpty else { return }
|
|
798
|
+
|
|
799
|
+
// For left/right with 2+ windows: distribute evenly across width
|
|
800
|
+
if windows.count >= 2 && (position == .left || position == .right) {
|
|
801
|
+
distributeSelectedHorizontally()
|
|
802
|
+
return
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
DiagnosticLog.shared.info("Tile all \(windows.count): \(position.rawValue)")
|
|
806
|
+
for win in windows {
|
|
807
|
+
WindowTiler.tileWindowById(wid: win.id, pid: win.pid, to: position)
|
|
808
|
+
}
|
|
809
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
810
|
+
self?.desktopSnapshot = self?.buildDesktopInventory()
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private func distributeSelectedHorizontally() {
|
|
815
|
+
let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
|
|
816
|
+
guard windows.count >= 2 else { return }
|
|
817
|
+
DiagnosticLog.shared.info("Distribute H: \(windows.count) windows")
|
|
818
|
+
WindowTiler.tileDistributeHorizontally(windows: windows.map { (wid: $0.id, pid: $0.pid) })
|
|
819
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
820
|
+
self?.desktopSnapshot = self?.buildDesktopInventory()
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// MARK: - Batch Actions (multi-select)
|
|
825
|
+
|
|
826
|
+
func focusAllSelected() {
|
|
827
|
+
let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
|
|
828
|
+
guard !windows.isEmpty else { return }
|
|
829
|
+
DiagnosticLog.shared.info("Focus all: \(windows.count) windows")
|
|
830
|
+
WindowTiler.raiseWindowsAndReactivate(windows: windows.map { (wid: $0.id, pid: $0.pid) })
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
func highlightAllSelected() {
|
|
834
|
+
let wids = flatWindowList.filter { selectedWindowIds.contains($0.id) }.map(\.id)
|
|
835
|
+
guard !wids.isEmpty else { return }
|
|
836
|
+
DiagnosticLog.shared.info("Highlight all: \(wids.count) windows")
|
|
837
|
+
for wid in wids {
|
|
838
|
+
WindowTiler.highlightWindowById(wid: wid)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/// Show all selected windows (raise to front) without changing layout
|
|
843
|
+
func showAllSelected() {
|
|
844
|
+
let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
|
|
845
|
+
guard !windows.isEmpty else { return }
|
|
846
|
+
savePositions(for: windows)
|
|
847
|
+
WindowTiler.raiseWindowsAndReactivate(windows: windows.map { (wid: $0.id, pid: $0.pid) })
|
|
848
|
+
flash("Showing \(windows.count) window\(windows.count == 1 ? "" : "s")")
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/// Show all selected windows AND distribute in smart grid — single batch operation
|
|
852
|
+
func showAndDistributeSelected() {
|
|
853
|
+
let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
|
|
854
|
+
guard !windows.isEmpty else { return }
|
|
855
|
+
savePositions(for: windows)
|
|
856
|
+
WindowTiler.batchRaiseAndDistribute(windows: windows.map { (wid: $0.id, pid: $0.pid) })
|
|
857
|
+
let shape = WindowTiler.gridShape(for: windows.count)
|
|
858
|
+
let grid = shape.map(String.init).joined(separator: "+")
|
|
859
|
+
flash("\(windows.count) windows [\(grid)]")
|
|
860
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
861
|
+
self?.desktopSnapshot = self?.buildDesktopInventory()
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/// Distribute selected in smart grid without raising
|
|
866
|
+
func distributeSelected() {
|
|
867
|
+
let windows = flatWindowList.filter { selectedWindowIds.contains($0.id) }
|
|
868
|
+
guard !windows.isEmpty else { return }
|
|
869
|
+
savePositions(for: windows)
|
|
870
|
+
WindowTiler.batchRaiseAndDistribute(windows: windows.map { (wid: $0.id, pid: $0.pid) })
|
|
871
|
+
let shape = WindowTiler.gridShape(for: windows.count)
|
|
872
|
+
let grid = shape.map(String.init).joined(separator: "+")
|
|
873
|
+
flash("\(windows.count) windows [\(grid)]")
|
|
874
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
875
|
+
self?.desktopSnapshot = self?.buildDesktopInventory()
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/// Save current positions of windows so they can be restored later
|
|
880
|
+
private func savePositions(for windows: [DesktopInventorySnapshot.InventoryWindowInfo]) {
|
|
881
|
+
// Don't overwrite if already saved (allow chaining actions)
|
|
882
|
+
guard savedPositions == nil else { return }
|
|
883
|
+
var positions: [UInt32: (pid: Int32, frame: WindowFrame)] = [:]
|
|
884
|
+
for win in windows {
|
|
885
|
+
positions[win.id] = (pid: win.pid, frame: win.frame)
|
|
886
|
+
}
|
|
887
|
+
savedPositions = positions
|
|
888
|
+
DiagnosticLog.shared.info("Saved positions for \(positions.count) windows")
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/// Restore windows to their saved positions — single batch operation
|
|
892
|
+
func restorePositions() {
|
|
893
|
+
guard let positions = savedPositions else { return }
|
|
894
|
+
DiagnosticLog.shared.info("Restoring \(positions.count) window positions")
|
|
895
|
+
let restores = positions.map { (wid: $0.key, pid: $0.value.pid, frame: $0.value.frame) }
|
|
896
|
+
WindowTiler.batchRestoreWindows(restores)
|
|
897
|
+
savedPositions = nil
|
|
898
|
+
flash("Restored \(restores.count) window\(restores.count == 1 ? "" : "s")")
|
|
899
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
900
|
+
self?.desktopSnapshot = self?.buildDesktopInventory()
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/// Accept the current layout — discard saved positions
|
|
905
|
+
func discardSavedPositions() {
|
|
906
|
+
savedPositions = nil
|
|
907
|
+
DiagnosticLog.shared.info("Accepted layout, discarded saved positions")
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/// Show a brief flash message that auto-dismisses
|
|
911
|
+
func flash(_ message: String) {
|
|
912
|
+
flashMessage = message
|
|
913
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
|
914
|
+
if self?.flashMessage == message { self?.flashMessage = nil }
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/// Copy a text representation of the desktop inventory to clipboard
|
|
919
|
+
func copyInventoryToClipboard() {
|
|
920
|
+
guard let snapshot = desktopSnapshot else { return }
|
|
921
|
+
var lines: [String] = ["DESKTOP INVENTORY"]
|
|
922
|
+
lines.append(String(repeating: "─", count: 60))
|
|
923
|
+
|
|
924
|
+
for display in snapshot.displays {
|
|
925
|
+
lines.append("")
|
|
926
|
+
lines.append("\(display.name) \(display.visibleFrame.w)×\(display.visibleFrame.h) (\(display.spaceCount) spaces)")
|
|
927
|
+
for space in display.spaces {
|
|
928
|
+
let tag = space.isCurrent ? " ◀ active" : ""
|
|
929
|
+
let winCount = space.apps.reduce(0) { $0 + $1.windows.count }
|
|
930
|
+
lines.append(" Space \(space.index)\(tag) (\(winCount) windows)")
|
|
931
|
+
for app in space.apps {
|
|
932
|
+
if app.windows.count == 1, let win = app.windows.first {
|
|
933
|
+
let tile = win.tilePosition?.label ?? "—"
|
|
934
|
+
let title = win.title.isEmpty ? "(untitled)" : win.title
|
|
935
|
+
let dmx = win.isLattices ? " [lattices]" : ""
|
|
936
|
+
let path = win.inventoryPath?.description ?? ""
|
|
937
|
+
lines.append(" \(app.appName) \(title)\(dmx) \(Int(win.frame.w))×\(Int(win.frame.h)) \(tile) \(path)")
|
|
938
|
+
} else {
|
|
939
|
+
lines.append(" \(app.appName)")
|
|
940
|
+
for win in app.windows {
|
|
941
|
+
let tile = win.tilePosition?.label ?? "—"
|
|
942
|
+
let title = win.title.isEmpty ? "(untitled)" : win.title
|
|
943
|
+
let dmx = win.isLattices ? " [lattices]" : ""
|
|
944
|
+
let path = win.inventoryPath?.description ?? ""
|
|
945
|
+
lines.append(" \(title)\(dmx) \(Int(win.frame.w))×\(Int(win.frame.h)) \(tile) \(path)")
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
let text = lines.joined(separator: "\n")
|
|
953
|
+
NSPasteboard.general.clearContents()
|
|
954
|
+
NSPasteboard.general.setString(text, forType: .string)
|
|
955
|
+
DiagnosticLog.shared.success("Copied inventory to clipboard (\(text.count) chars)")
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
func dismiss() {
|
|
959
|
+
phase = .idle
|
|
960
|
+
onDismiss?()
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// MARK: - Inventory Builder
|
|
964
|
+
|
|
965
|
+
private func buildInventory() -> CommandModeInventory {
|
|
966
|
+
let workspace = WorkspaceManager.shared
|
|
967
|
+
let tmux = TmuxModel.shared
|
|
968
|
+
let inventoryMgr = InventoryManager.shared
|
|
969
|
+
|
|
970
|
+
// Refresh inventory so orphans are current
|
|
971
|
+
inventoryMgr.refresh()
|
|
972
|
+
|
|
973
|
+
let activeLayer = workspace.activeLayer
|
|
974
|
+
let layerCount = workspace.config?.layers?.count ?? 0
|
|
975
|
+
|
|
976
|
+
var items: [CommandModeInventory.Item] = []
|
|
977
|
+
|
|
978
|
+
// Active layer projects
|
|
979
|
+
if let layer = activeLayer {
|
|
980
|
+
for lp in layer.projects {
|
|
981
|
+
if let groupId = lp.group, let group = workspace.group(byId: groupId) {
|
|
982
|
+
let running = workspace.isGroupRunning(group)
|
|
983
|
+
let paneCount = group.tabs.count
|
|
984
|
+
items.append(.init(
|
|
985
|
+
name: group.label,
|
|
986
|
+
group: "Layer: \(layer.label)",
|
|
987
|
+
status: running ? .running : .stopped,
|
|
988
|
+
paneCount: paneCount,
|
|
989
|
+
tileHint: lp.tile
|
|
990
|
+
))
|
|
991
|
+
} else if let path = lp.path {
|
|
992
|
+
let name = (path as NSString).lastPathComponent
|
|
993
|
+
let sessionName = WorkspaceManager.sessionName(for: path)
|
|
994
|
+
let session = tmux.sessions.first(where: { $0.name == sessionName })
|
|
995
|
+
let status: CommandModeInventory.Status
|
|
996
|
+
if let s = session {
|
|
997
|
+
status = s.attached ? .attached : .running
|
|
998
|
+
} else {
|
|
999
|
+
status = .stopped
|
|
1000
|
+
}
|
|
1001
|
+
items.append(.init(
|
|
1002
|
+
name: name,
|
|
1003
|
+
group: "Layer: \(layer.label)",
|
|
1004
|
+
status: status,
|
|
1005
|
+
paneCount: session?.panes.count ?? 0,
|
|
1006
|
+
tileHint: lp.tile
|
|
1007
|
+
))
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Tab groups not in active layer
|
|
1013
|
+
if let groups = workspace.config?.groups {
|
|
1014
|
+
let layerGroupIds = Set(activeLayer?.projects.compactMap(\.group) ?? [])
|
|
1015
|
+
for group in groups where !layerGroupIds.contains(group.id) {
|
|
1016
|
+
let running = workspace.isGroupRunning(group)
|
|
1017
|
+
items.append(.init(
|
|
1018
|
+
name: group.label,
|
|
1019
|
+
group: "Group: \(group.label)",
|
|
1020
|
+
status: running ? .running : .stopped,
|
|
1021
|
+
paneCount: group.tabs.count,
|
|
1022
|
+
tileHint: nil
|
|
1023
|
+
))
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Orphans
|
|
1028
|
+
for orphan in inventoryMgr.orphans {
|
|
1029
|
+
items.append(.init(
|
|
1030
|
+
name: orphan.name,
|
|
1031
|
+
group: "Orphan",
|
|
1032
|
+
status: orphan.attached ? .attached : .running,
|
|
1033
|
+
paneCount: orphan.panes.count,
|
|
1034
|
+
tileHint: nil
|
|
1035
|
+
))
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
return CommandModeInventory(
|
|
1039
|
+
activeLayer: activeLayer?.label,
|
|
1040
|
+
layerCount: layerCount,
|
|
1041
|
+
items: items
|
|
1042
|
+
)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// MARK: - Desktop Inventory Builder
|
|
1046
|
+
|
|
1047
|
+
private func buildDesktopInventory() -> DesktopInventorySnapshot {
|
|
1048
|
+
let originalScreens = NSScreen.screens
|
|
1049
|
+
let displaySpaces = WindowTiler.getDisplaySpaces()
|
|
1050
|
+
let primaryHeight = originalScreens.first?.frame.height ?? 0
|
|
1051
|
+
|
|
1052
|
+
// Sort screens left-to-right by frame origin, tie-break top-to-bottom
|
|
1053
|
+
let sortedScreens = originalScreens.sorted {
|
|
1054
|
+
if $0.frame.origin.x != $1.frame.origin.x {
|
|
1055
|
+
return $0.frame.origin.x < $1.frame.origin.x
|
|
1056
|
+
}
|
|
1057
|
+
return $0.frame.origin.y > $1.frame.origin.y
|
|
1058
|
+
}
|
|
1059
|
+
// Map sorted index → original index for displaySpaces lookup
|
|
1060
|
+
let sortedToOriginal = sortedScreens.map { s in originalScreens.firstIndex(where: { $0 === s })! }
|
|
1061
|
+
let screens = sortedScreens
|
|
1062
|
+
|
|
1063
|
+
// Build space-to-display mapping: spaceId → (displayIndex, spaceIndex)
|
|
1064
|
+
var spaceToDisplay: [Int: (displayIdx: Int, spaceIdx: Int)] = [:]
|
|
1065
|
+
for (dIdx, ds) in displaySpaces.enumerated() {
|
|
1066
|
+
for space in ds.spaces {
|
|
1067
|
+
spaceToDisplay[space.id] = (dIdx, space.index)
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Current space IDs per display
|
|
1072
|
+
let currentSpaceIds = Set(displaySpaces.map(\.currentSpaceId))
|
|
1073
|
+
|
|
1074
|
+
// Query ALL windows (not just on-screen) to capture every space
|
|
1075
|
+
guard let rawList = CGWindowListCopyWindowInfo(
|
|
1076
|
+
[.optionAll, .excludeDesktopElements],
|
|
1077
|
+
kCGNullWindowID
|
|
1078
|
+
) as? [[String: Any]] else {
|
|
1079
|
+
return DesktopInventorySnapshot(displays: [], timestamp: Date())
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Parse raw CG window info
|
|
1083
|
+
struct RawWindow {
|
|
1084
|
+
let wid: UInt32; let app: String; let pid: Int32
|
|
1085
|
+
let title: String; let frame: WindowFrame
|
|
1086
|
+
let latticesSession: String?; let spaceIds: [Int]
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// System/helper processes that create layer-0 windows users don't care about
|
|
1090
|
+
let blockedApps: Set<String> = [
|
|
1091
|
+
// macOS system
|
|
1092
|
+
"WindowServer", "Dock", "SystemUIServer", "Control Center",
|
|
1093
|
+
"Notification Center", "NotificationCenter", "Spotlight", "WindowManager",
|
|
1094
|
+
"TextInputMenuAgent", "TextInputSwitcher", "universalAccessAuthWarn",
|
|
1095
|
+
"AXVisualSupportAgent", "loginwindow", "ScreenSaverEngine",
|
|
1096
|
+
// UI service helpers (run as XPC, show popover/autofill UI)
|
|
1097
|
+
"AutoFill", "AuthenticationServicesHelper", "CursorUIViewService",
|
|
1098
|
+
"SharedWebCredentialViewService", "CoreServicesUIAgent",
|
|
1099
|
+
"UserNotificationCenter", "SecurityAgent", "OSDUIHelper",
|
|
1100
|
+
"PassKit UIService", "QuickLookUIService", "ScopedBookmarkAgent",
|
|
1101
|
+
// Dev tool helpers
|
|
1102
|
+
"Instruments", "FileMerge",
|
|
1103
|
+
]
|
|
1104
|
+
// Also block apps whose name ends with known helper suffixes
|
|
1105
|
+
let blockedSuffixes = ["UIService", "UIHelper", "Agent", "Helper", "ViewService"]
|
|
1106
|
+
|
|
1107
|
+
let ownPid = ProcessInfo.processInfo.processIdentifier
|
|
1108
|
+
let rawCount = rawList.count
|
|
1109
|
+
|
|
1110
|
+
var allWindows: [RawWindow] = []
|
|
1111
|
+
for info in rawList {
|
|
1112
|
+
guard let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
1113
|
+
let ownerName = info[kCGWindowOwnerName as String] as? String,
|
|
1114
|
+
let pid = info[kCGWindowOwnerPID as String] as? Int32,
|
|
1115
|
+
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
1116
|
+
else { continue }
|
|
1117
|
+
|
|
1118
|
+
// Skip our own windows
|
|
1119
|
+
guard pid != ownPid else { continue }
|
|
1120
|
+
|
|
1121
|
+
// Skip known system/helper processes
|
|
1122
|
+
guard !blockedApps.contains(ownerName) else { continue }
|
|
1123
|
+
if blockedSuffixes.contains(where: { ownerName.hasSuffix($0) }) { continue }
|
|
1124
|
+
|
|
1125
|
+
var rect = CGRect.zero
|
|
1126
|
+
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect),
|
|
1127
|
+
rect.width >= 100, rect.height >= 50 else { continue }
|
|
1128
|
+
|
|
1129
|
+
let layer = info[kCGWindowLayer as String] as? Int ?? 0
|
|
1130
|
+
guard layer == 0 else { continue }
|
|
1131
|
+
|
|
1132
|
+
let title = info[kCGWindowName as String] as? String ?? ""
|
|
1133
|
+
let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? false
|
|
1134
|
+
let spaceIds = WindowTiler.getSpacesForWindow(wid)
|
|
1135
|
+
|
|
1136
|
+
// Skip windows not assigned to any space (background helpers)
|
|
1137
|
+
guard !spaceIds.isEmpty else { continue }
|
|
1138
|
+
|
|
1139
|
+
// For windows on a current space, require them to be actually visible.
|
|
1140
|
+
// This filters hidden helper windows (AutoFill, CursorUIViewService, etc.)
|
|
1141
|
+
// while keeping real windows on other spaces.
|
|
1142
|
+
let isOnCurrentSpace = spaceIds.contains(where: { currentSpaceIds.contains($0) })
|
|
1143
|
+
if isOnCurrentSpace && !isOnScreen { continue }
|
|
1144
|
+
|
|
1145
|
+
let frame = WindowFrame(x: Double(rect.origin.x), y: Double(rect.origin.y),
|
|
1146
|
+
w: Double(rect.width), h: Double(rect.height))
|
|
1147
|
+
|
|
1148
|
+
var latticesSession: String?
|
|
1149
|
+
if let range = title.range(of: #"\[lattices:([^\]]+)\]"#, options: .regularExpression) {
|
|
1150
|
+
let match = String(title[range])
|
|
1151
|
+
latticesSession = String(match.dropFirst(9).dropLast(1))
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
allWindows.append(RawWindow(wid: wid, app: ownerName, pid: pid, title: title,
|
|
1155
|
+
frame: frame, latticesSession: latticesSession, spaceIds: spaceIds))
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
DiagnosticLog.shared.info("Desktop scan: \(rawCount) raw → \(allWindows.count) after filter")
|
|
1159
|
+
|
|
1160
|
+
// Assign each window to (display, space)
|
|
1161
|
+
struct AssignedWindow {
|
|
1162
|
+
let win: RawWindow; let displayIdx: Int; let spaceId: Int; let spaceIdx: Int; let isOnScreen: Bool
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
var assigned: [AssignedWindow] = []
|
|
1166
|
+
for win in allWindows {
|
|
1167
|
+
// Primary: use space→display mapping
|
|
1168
|
+
for sid in win.spaceIds {
|
|
1169
|
+
if let mapping = spaceToDisplay[sid] {
|
|
1170
|
+
assigned.append(AssignedWindow(
|
|
1171
|
+
win: win,
|
|
1172
|
+
displayIdx: mapping.displayIdx,
|
|
1173
|
+
spaceId: sid,
|
|
1174
|
+
spaceIdx: mapping.spaceIdx,
|
|
1175
|
+
isOnScreen: currentSpaceIds.contains(sid)
|
|
1176
|
+
))
|
|
1177
|
+
break // assign to first known space
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Fallback: match by frame center (no space info)
|
|
1182
|
+
if !win.spaceIds.contains(where: { spaceToDisplay[$0] != nil }) {
|
|
1183
|
+
let cx = win.frame.x + win.frame.w / 2
|
|
1184
|
+
let cy = win.frame.y + win.frame.h / 2
|
|
1185
|
+
let nsCy = primaryHeight - cy
|
|
1186
|
+
for (sIdx, screen) in screens.enumerated() {
|
|
1187
|
+
if screen.frame.contains(NSPoint(x: cx, y: nsCy)) {
|
|
1188
|
+
let origIdx = sortedToOriginal[sIdx]
|
|
1189
|
+
let ds = origIdx < displaySpaces.count ? displaySpaces[origIdx] : nil
|
|
1190
|
+
let currentSid = ds?.currentSpaceId ?? 0
|
|
1191
|
+
let currentIdx = ds?.spaces.first(where: { $0.isCurrent })?.index ?? 1
|
|
1192
|
+
assigned.append(AssignedWindow(
|
|
1193
|
+
win: win, displayIdx: origIdx,
|
|
1194
|
+
spaceId: currentSid, spaceIdx: currentIdx, isOnScreen: true
|
|
1195
|
+
))
|
|
1196
|
+
break
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Build hierarchical: Display → Space → App → Windows
|
|
1203
|
+
var displays: [DesktopInventorySnapshot.DisplayInfo] = []
|
|
1204
|
+
|
|
1205
|
+
for (screenIdx, screen) in screens.enumerated() {
|
|
1206
|
+
let frame = screen.frame
|
|
1207
|
+
let visible = screen.visibleFrame
|
|
1208
|
+
let name = screen.localizedName
|
|
1209
|
+
|
|
1210
|
+
let originalIdx = sortedToOriginal[screenIdx]
|
|
1211
|
+
let ds = originalIdx < displaySpaces.count ? displaySpaces[originalIdx] : nil
|
|
1212
|
+
let spaceCount = ds?.spaces.count ?? 1
|
|
1213
|
+
let currentSpaceIdx = ds?.spaces.first(where: { $0.isCurrent })?.index ?? 1
|
|
1214
|
+
|
|
1215
|
+
let screenWindows = assigned.filter { $0.displayIdx == originalIdx }
|
|
1216
|
+
|
|
1217
|
+
// Group by space
|
|
1218
|
+
var windowsBySpace: [Int: [AssignedWindow]] = [:]
|
|
1219
|
+
for aw in screenWindows {
|
|
1220
|
+
windowsBySpace[aw.spaceId, default: []].append(aw)
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Build SpaceGroups sorted by space index
|
|
1224
|
+
let isMain = screen == NSScreen.main
|
|
1225
|
+
let displayLabel = InventoryPath.displayName(for: screen, isMain: isMain)
|
|
1226
|
+
var spaceGroups: [DesktopInventorySnapshot.SpaceGroup] = []
|
|
1227
|
+
let allSpacesForDisplay = ds?.spaces ?? []
|
|
1228
|
+
|
|
1229
|
+
for spaceInfo in allSpacesForDisplay {
|
|
1230
|
+
let spaceWindows = windowsBySpace[spaceInfo.id] ?? []
|
|
1231
|
+
guard !spaceWindows.isEmpty else { continue }
|
|
1232
|
+
|
|
1233
|
+
// Group by app within space
|
|
1234
|
+
var appGroups: [String: [AssignedWindow]] = [:]
|
|
1235
|
+
for aw in spaceWindows {
|
|
1236
|
+
appGroups[aw.win.app, default: []].append(aw)
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
var groups: [DesktopInventorySnapshot.AppGroup] = []
|
|
1240
|
+
for appName in appGroups.keys.sorted() {
|
|
1241
|
+
let wins = appGroups[appName]!
|
|
1242
|
+
let appType = AppTypeClassifier.classify(appName)
|
|
1243
|
+
let inventoryWindows = wins.map { aw -> DesktopInventorySnapshot.InventoryWindowInfo in
|
|
1244
|
+
let tile = aw.isOnScreen ? WindowTiler.inferTilePosition(frame: aw.win.frame, screen: screen) : nil
|
|
1245
|
+
let path = InventoryPath(
|
|
1246
|
+
display: displayLabel,
|
|
1247
|
+
space: "space\(aw.spaceIdx)",
|
|
1248
|
+
appType: appType.rawValue,
|
|
1249
|
+
appName: appName,
|
|
1250
|
+
windowTitle: aw.win.title.isEmpty ? "untitled" : aw.win.title
|
|
1251
|
+
)
|
|
1252
|
+
return DesktopInventorySnapshot.InventoryWindowInfo(
|
|
1253
|
+
id: aw.win.wid,
|
|
1254
|
+
pid: aw.win.pid,
|
|
1255
|
+
title: aw.win.title,
|
|
1256
|
+
frame: aw.win.frame,
|
|
1257
|
+
tilePosition: tile,
|
|
1258
|
+
isLattices: aw.win.latticesSession != nil,
|
|
1259
|
+
latticesSession: aw.win.latticesSession,
|
|
1260
|
+
spaceIndex: aw.spaceIdx,
|
|
1261
|
+
isOnScreen: aw.isOnScreen,
|
|
1262
|
+
inventoryPath: path,
|
|
1263
|
+
appName: appName
|
|
1264
|
+
)
|
|
1265
|
+
}
|
|
1266
|
+
groups.append(DesktopInventorySnapshot.AppGroup(
|
|
1267
|
+
id: "\(spaceInfo.id)-\(appName)",
|
|
1268
|
+
appName: appName,
|
|
1269
|
+
windows: inventoryWindows
|
|
1270
|
+
))
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
spaceGroups.append(DesktopInventorySnapshot.SpaceGroup(
|
|
1274
|
+
id: spaceInfo.id,
|
|
1275
|
+
index: spaceInfo.index,
|
|
1276
|
+
isCurrent: spaceInfo.isCurrent,
|
|
1277
|
+
apps: groups
|
|
1278
|
+
))
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
displays.append(DesktopInventorySnapshot.DisplayInfo(
|
|
1282
|
+
id: ds?.displayId ?? "display-\(screenIdx)",
|
|
1283
|
+
name: name,
|
|
1284
|
+
resolution: (w: Int(frame.width), h: Int(frame.height)),
|
|
1285
|
+
visibleFrame: (w: Int(visible.width), h: Int(visible.height)),
|
|
1286
|
+
isMain: isMain,
|
|
1287
|
+
spaceCount: spaceCount,
|
|
1288
|
+
currentSpaceIndex: currentSpaceIdx,
|
|
1289
|
+
spaces: spaceGroups
|
|
1290
|
+
))
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
return DesktopInventorySnapshot(displays: displays, timestamp: Date())
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// MARK: - Chord Map
|
|
1297
|
+
|
|
1298
|
+
private func buildChords() -> [Chord] {
|
|
1299
|
+
let workspace = WorkspaceManager.shared
|
|
1300
|
+
|
|
1301
|
+
var chords: [Chord] = []
|
|
1302
|
+
|
|
1303
|
+
// [a] tile all — re-tile active layer's windows
|
|
1304
|
+
chords.append(Chord(key: "a", keyCode: 0, label: "tile all") {
|
|
1305
|
+
WorkspaceManager.shared.retileCurrentLayer()
|
|
1306
|
+
})
|
|
1307
|
+
|
|
1308
|
+
// [s] split — tile two most recent left/right
|
|
1309
|
+
chords.append(Chord(key: "s", keyCode: 1, label: "split") {
|
|
1310
|
+
let running = ProjectScanner.shared.projects.filter(\.isRunning)
|
|
1311
|
+
let term = Preferences.shared.terminal
|
|
1312
|
+
if running.count >= 2 {
|
|
1313
|
+
WindowTiler.tile(session: running[0].sessionName, terminal: term, to: .left)
|
|
1314
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
1315
|
+
WindowTiler.tile(session: running[1].sessionName, terminal: term, to: .right)
|
|
1316
|
+
}
|
|
1317
|
+
} else if running.count == 1 {
|
|
1318
|
+
WindowTiler.tile(session: running[0].sessionName, terminal: term, to: .maximize)
|
|
1319
|
+
}
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
// [m] maximize — maximize frontmost terminal
|
|
1323
|
+
chords.append(Chord(key: "m", keyCode: 46, label: "maximize") {
|
|
1324
|
+
let term = Preferences.shared.terminal
|
|
1325
|
+
// Find frontmost running project
|
|
1326
|
+
let running = ProjectScanner.shared.projects.filter(\.isRunning)
|
|
1327
|
+
if let first = running.first {
|
|
1328
|
+
WindowTiler.tile(session: first.sessionName, terminal: term, to: .maximize)
|
|
1329
|
+
}
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
// [1]-[3] layer focus (dynamic)
|
|
1333
|
+
let layers = workspace.config?.layers ?? []
|
|
1334
|
+
let layerKeyCodes: [UInt16] = [18, 19, 20] // 1, 2, 3
|
|
1335
|
+
for (i, layer) in layers.prefix(3).enumerated() {
|
|
1336
|
+
let idx = i
|
|
1337
|
+
chords.append(Chord(key: "\(i + 1)", keyCode: layerKeyCodes[i], label: layer.label.lowercased()) {
|
|
1338
|
+
WorkspaceManager.shared.tileLayer(index: idx)
|
|
1339
|
+
})
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// [l] launch layer — explicitly start non-running projects
|
|
1343
|
+
chords.append(Chord(key: "l", keyCode: 37, label: "launch layer") {
|
|
1344
|
+
let ws = WorkspaceManager.shared
|
|
1345
|
+
ws.tileLayer(index: ws.activeLayerIndex, launch: true, force: true)
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
// [r] refresh
|
|
1349
|
+
chords.append(Chord(key: "r", keyCode: 15, label: "refresh") {
|
|
1350
|
+
ProjectScanner.shared.scan()
|
|
1351
|
+
TmuxModel.shared.poll()
|
|
1352
|
+
InventoryManager.shared.refresh()
|
|
1353
|
+
})
|
|
1354
|
+
|
|
1355
|
+
// [p] palette
|
|
1356
|
+
chords.append(Chord(key: "p", keyCode: 35, label: "palette") {
|
|
1357
|
+
CommandPaletteWindow.shared.show()
|
|
1358
|
+
})
|
|
1359
|
+
|
|
1360
|
+
return chords
|
|
1361
|
+
}
|
|
1362
|
+
}
|