@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.
Files changed (74) hide show
  1. package/README.md +155 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/AccessibilityTextExtractor.swift +111 -0
  5. package/app/Sources/ActionRow.swift +61 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +242 -0
  8. package/app/Sources/AppShellView.swift +62 -0
  9. package/app/Sources/AppTypeClassifier.swift +70 -0
  10. package/app/Sources/AppWindowShell.swift +63 -0
  11. package/app/Sources/CheatSheetHUD.swift +332 -0
  12. package/app/Sources/CommandModeState.swift +1362 -0
  13. package/app/Sources/CommandModeView.swift +1405 -0
  14. package/app/Sources/CommandModeWindow.swift +192 -0
  15. package/app/Sources/CommandPaletteView.swift +307 -0
  16. package/app/Sources/CommandPaletteWindow.swift +134 -0
  17. package/app/Sources/DaemonProtocol.swift +101 -0
  18. package/app/Sources/DaemonServer.swift +414 -0
  19. package/app/Sources/DesktopModel.swift +149 -0
  20. package/app/Sources/DesktopModelTypes.swift +71 -0
  21. package/app/Sources/DiagnosticLog.swift +271 -0
  22. package/app/Sources/EventBus.swift +30 -0
  23. package/app/Sources/HotkeyManager.swift +254 -0
  24. package/app/Sources/HotkeyStore.swift +338 -0
  25. package/app/Sources/InventoryManager.swift +35 -0
  26. package/app/Sources/InventoryPath.swift +43 -0
  27. package/app/Sources/KeyRecorderView.swift +210 -0
  28. package/app/Sources/LatticesApi.swift +1234 -0
  29. package/app/Sources/LayerBezel.swift +203 -0
  30. package/app/Sources/MainView.swift +479 -0
  31. package/app/Sources/MainWindow.swift +83 -0
  32. package/app/Sources/OcrModel.swift +430 -0
  33. package/app/Sources/OcrStore.swift +329 -0
  34. package/app/Sources/OmniSearchState.swift +283 -0
  35. package/app/Sources/OmniSearchView.swift +288 -0
  36. package/app/Sources/OmniSearchWindow.swift +105 -0
  37. package/app/Sources/OrphanRow.swift +129 -0
  38. package/app/Sources/PaletteCommand.swift +419 -0
  39. package/app/Sources/PermissionChecker.swift +125 -0
  40. package/app/Sources/Preferences.swift +99 -0
  41. package/app/Sources/ProcessModel.swift +199 -0
  42. package/app/Sources/ProcessQuery.swift +151 -0
  43. package/app/Sources/Project.swift +28 -0
  44. package/app/Sources/ProjectRow.swift +368 -0
  45. package/app/Sources/ProjectScanner.swift +128 -0
  46. package/app/Sources/ScreenMapState.swift +2387 -0
  47. package/app/Sources/ScreenMapView.swift +2820 -0
  48. package/app/Sources/ScreenMapWindowController.swift +89 -0
  49. package/app/Sources/SessionManager.swift +72 -0
  50. package/app/Sources/SettingsView.swift +1064 -0
  51. package/app/Sources/SettingsWindow.swift +20 -0
  52. package/app/Sources/TabGroupRow.swift +178 -0
  53. package/app/Sources/Terminal.swift +259 -0
  54. package/app/Sources/TerminalQuery.swift +156 -0
  55. package/app/Sources/TerminalSynthesizer.swift +200 -0
  56. package/app/Sources/Theme.swift +163 -0
  57. package/app/Sources/TilePickerView.swift +209 -0
  58. package/app/Sources/TmuxModel.swift +53 -0
  59. package/app/Sources/TmuxQuery.swift +81 -0
  60. package/app/Sources/WindowTiler.swift +1778 -0
  61. package/app/Sources/WorkspaceManager.swift +575 -0
  62. package/bin/client.js +4 -0
  63. package/bin/daemon-client.js +187 -0
  64. package/bin/lattices-app.js +221 -0
  65. package/bin/lattices.js +1551 -0
  66. package/docs/api.md +924 -0
  67. package/docs/app.md +297 -0
  68. package/docs/concepts.md +135 -0
  69. package/docs/config.md +245 -0
  70. package/docs/layers.md +410 -0
  71. package/docs/ocr.md +185 -0
  72. package/docs/overview.md +94 -0
  73. package/docs/quickstart.md +75 -0
  74. package/package.json +42 -0
@@ -0,0 +1,1778 @@
1
+ import AppKit
2
+ import CoreGraphics
3
+
4
+ // Private API: get CGWindowID from an AXUIElement
5
+ @_silgen_name("_AXUIElementGetWindow")
6
+ func _AXUIElementGetWindow(_ element: AXUIElement, _ windowID: UnsafeMutablePointer<CGWindowID>) -> AXError
7
+
8
+ // MARK: - SkyLight Private APIs (instant window moves, no animation)
9
+ // Loaded at runtime via dlsym — graceful fallback if unavailable.
10
+
11
+ private let skyLight: UnsafeMutableRawPointer? = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_NOW)
12
+
13
+ private typealias SLSMainConnectionIDFunc = @convention(c) () -> Int32
14
+ private typealias SLSMoveWindowFunc = @convention(c) (Int32, UInt32, UnsafeMutablePointer<CGPoint>) -> CGError
15
+ private typealias SLSDisableUpdateFunc = @convention(c) (Int32) -> Int32
16
+ private typealias SLSReenableUpdateFunc = @convention(c) (Int32) -> Int32
17
+
18
+ private let _SLSMainConnectionID: SLSMainConnectionIDFunc? = {
19
+ guard let sl = skyLight, let sym = dlsym(sl, "SLSMainConnectionID") else { return nil }
20
+ return unsafeBitCast(sym, to: SLSMainConnectionIDFunc.self)
21
+ }()
22
+
23
+ private let _SLSMoveWindow: SLSMoveWindowFunc? = {
24
+ guard let sl = skyLight, let sym = dlsym(sl, "SLSMoveWindow") else { return nil }
25
+ return unsafeBitCast(sym, to: SLSMoveWindowFunc.self)
26
+ }()
27
+
28
+ private let _SLSDisableUpdate: SLSDisableUpdateFunc? = {
29
+ guard let sl = skyLight, let sym = dlsym(sl, "SLSDisableUpdate") else { return nil }
30
+ return unsafeBitCast(sym, to: SLSDisableUpdateFunc.self)
31
+ }()
32
+
33
+ private let _SLSReenableUpdate: SLSReenableUpdateFunc? = {
34
+ guard let sl = skyLight, let sym = dlsym(sl, "SLSReenableUpdate") else { return nil }
35
+ return unsafeBitCast(sym, to: SLSReenableUpdateFunc.self)
36
+ }()
37
+
38
+ // MARK: - Window Highlight Overlay
39
+
40
+ final class WindowHighlight {
41
+ static let shared = WindowHighlight()
42
+
43
+ private var overlayWindow: NSWindow?
44
+ private var fadeTimer: Timer?
45
+
46
+ /// Flash a green border overlay at the given screen frame
47
+ func flash(frame: NSRect, duration: TimeInterval = 1.2) {
48
+ dismiss()
49
+
50
+ let inset: CGFloat = -8 // slightly larger than the window
51
+ let expandedFrame = frame.insetBy(dx: inset, dy: inset)
52
+
53
+ let window = NSWindow(
54
+ contentRect: expandedFrame,
55
+ styleMask: .borderless,
56
+ backing: .buffered,
57
+ defer: false
58
+ )
59
+ window.isOpaque = false
60
+ window.backgroundColor = .clear
61
+ window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
62
+ window.hasShadow = false
63
+ window.ignoresMouseEvents = true
64
+ window.collectionBehavior = [.canJoinAllSpaces, .stationary]
65
+
66
+ let borderView = HighlightBorderView(frame: NSRect(origin: .zero, size: expandedFrame.size))
67
+ window.contentView = borderView
68
+
69
+ window.alphaValue = 0
70
+ window.orderFrontRegardless()
71
+
72
+ overlayWindow = window
73
+
74
+ // Fade in
75
+ NSAnimationContext.runAnimationGroup { ctx in
76
+ ctx.duration = 0.15
77
+ window.animator().alphaValue = 1.0
78
+ }
79
+
80
+ // Schedule fade out
81
+ fadeTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
82
+ self?.fadeOut()
83
+ }
84
+ }
85
+
86
+ func dismiss() {
87
+ fadeTimer?.invalidate()
88
+ fadeTimer = nil
89
+ overlayWindow?.orderOut(nil)
90
+ overlayWindow = nil
91
+ }
92
+
93
+ private func fadeOut() {
94
+ guard let window = overlayWindow else { return }
95
+ NSAnimationContext.runAnimationGroup({ ctx in
96
+ ctx.duration = 0.3
97
+ window.animator().alphaValue = 0
98
+ }, completionHandler: { [weak self] in
99
+ self?.dismiss()
100
+ })
101
+ }
102
+ }
103
+
104
+ private class HighlightBorderView: NSView {
105
+ override func draw(_ dirtyRect: NSRect) {
106
+ let borderWidth: CGFloat = 4
107
+ let cornerRadius: CGFloat = 12
108
+
109
+ // Outer glow
110
+ let glowRect = bounds.insetBy(dx: 1, dy: 1)
111
+ let glowPath = NSBezierPath(roundedRect: glowRect, xRadius: cornerRadius + 2, yRadius: cornerRadius + 2)
112
+ glowPath.lineWidth = borderWidth + 4
113
+ NSColor(calibratedRed: 0.2, green: 0.9, blue: 0.4, alpha: 0.15).setStroke()
114
+ glowPath.stroke()
115
+
116
+ // Main border
117
+ let rect = bounds.insetBy(dx: borderWidth / 2 + 2, dy: borderWidth / 2 + 2)
118
+ let path = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius)
119
+ path.lineWidth = borderWidth
120
+ NSColor(calibratedRed: 0.2, green: 0.9, blue: 0.4, alpha: 0.9).setStroke()
121
+ path.stroke()
122
+ }
123
+ }
124
+
125
+ enum TilePosition: String, CaseIterable, Identifiable {
126
+ case left = "left"
127
+ case right = "right"
128
+ case top = "top"
129
+ case bottom = "bottom"
130
+ case topLeft = "top-left"
131
+ case topRight = "top-right"
132
+ case bottomLeft = "bottom-left"
133
+ case bottomRight = "bottom-right"
134
+ case maximize = "maximize"
135
+ case center = "center"
136
+ case leftThird = "left-third"
137
+ case centerThird = "center-third"
138
+ case rightThird = "right-third"
139
+
140
+ var id: String { rawValue }
141
+
142
+ var label: String {
143
+ switch self {
144
+ case .left: return "Left"
145
+ case .right: return "Right"
146
+ case .top: return "Top"
147
+ case .bottom: return "Bottom"
148
+ case .topLeft: return "Top Left"
149
+ case .topRight: return "Top Right"
150
+ case .bottomLeft: return "Bottom Left"
151
+ case .bottomRight: return "Bottom Right"
152
+ case .maximize: return "Max"
153
+ case .center: return "Center"
154
+ case .leftThird: return "Left Third"
155
+ case .centerThird: return "Center Third"
156
+ case .rightThird: return "Right Third"
157
+ }
158
+ }
159
+
160
+ var icon: String {
161
+ switch self {
162
+ case .left: return "rectangle.lefthalf.filled"
163
+ case .right: return "rectangle.righthalf.filled"
164
+ case .top: return "rectangle.tophalf.filled"
165
+ case .bottom: return "rectangle.bottomhalf.filled"
166
+ case .topLeft: return "rectangle.inset.topleft.filled"
167
+ case .topRight: return "rectangle.inset.topright.filled"
168
+ case .bottomLeft: return "rectangle.inset.bottomleft.filled"
169
+ case .bottomRight: return "rectangle.inset.bottomright.filled"
170
+ case .maximize: return "rectangle.fill"
171
+ case .center: return "rectangle.center.inset.filled"
172
+ case .leftThird: return "rectangle.leadingthird.inset.filled"
173
+ case .centerThird: return "rectangle.center.inset.filled"
174
+ case .rightThird: return "rectangle.trailingthird.inset.filled"
175
+ }
176
+ }
177
+
178
+ /// Returns (x, y, w, h) as fractions of screen
179
+ var rect: (CGFloat, CGFloat, CGFloat, CGFloat) {
180
+ switch self {
181
+ case .left: return (0, 0, 0.5, 1.0)
182
+ case .right: return (0.5, 0, 0.5, 1.0)
183
+ case .top: return (0, 0, 1.0, 0.5)
184
+ case .bottom: return (0, 0.5, 1.0, 0.5)
185
+ case .topLeft: return (0, 0, 0.5, 0.5)
186
+ case .topRight: return (0.5, 0, 0.5, 0.5)
187
+ case .bottomLeft: return (0, 0.5, 0.5, 0.5)
188
+ case .bottomRight: return (0.5, 0.5, 0.5, 0.5)
189
+ case .maximize: return (0, 0, 1.0, 1.0)
190
+ case .center: return (0.15, 0.1, 0.7, 0.8)
191
+ case .leftThird: return (0, 0, 0.333, 1.0)
192
+ case .centerThird: return (0.333, 0, 0.334, 1.0)
193
+ case .rightThird: return (0.667, 0, 0.333, 1.0)
194
+ }
195
+ }
196
+ }
197
+
198
+ // MARK: - Private CGS API for Spaces (loaded dynamically from SkyLight)
199
+
200
+ struct SpaceInfo: Identifiable {
201
+ let id: Int // CGS space ID
202
+ let index: Int // 1-based index within its display
203
+ let display: Int // 0-based display index
204
+ let isCurrent: Bool
205
+ }
206
+
207
+ struct DisplaySpaces {
208
+ let displayIndex: Int
209
+ let displayId: String
210
+ let spaces: [SpaceInfo]
211
+ let currentSpaceId: Int
212
+ }
213
+
214
+ private enum CGS {
215
+ // Use Int32 for CGS connection IDs (C `int`), UInt64 for space IDs
216
+ typealias MainConnectionIDFunc = @convention(c) () -> Int32
217
+ typealias GetActiveSpaceFunc = @convention(c) (Int32) -> UInt64
218
+ typealias CopyManagedDisplaySpacesFunc = @convention(c) (Int32) -> CFArray
219
+ typealias CopySpacesForWindowsFunc = @convention(c) (Int32, Int32, CFArray) -> CFArray
220
+ typealias SetCurrentSpaceFunc = @convention(c) (Int32, CFString, UInt64) -> Void
221
+
222
+ private static let handle = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY)
223
+
224
+ static let mainConnectionID: MainConnectionIDFunc? = {
225
+ guard let h = handle, let sym = dlsym(h, "CGSMainConnectionID") else { return nil }
226
+ return unsafeBitCast(sym, to: MainConnectionIDFunc.self)
227
+ }()
228
+
229
+ static let getActiveSpace: GetActiveSpaceFunc? = {
230
+ guard let h = handle, let sym = dlsym(h, "CGSGetActiveSpace") else { return nil }
231
+ return unsafeBitCast(sym, to: GetActiveSpaceFunc.self)
232
+ }()
233
+
234
+ static let copyManagedDisplaySpaces: CopyManagedDisplaySpacesFunc? = {
235
+ guard let h = handle, let sym = dlsym(h, "CGSCopyManagedDisplaySpaces") else { return nil }
236
+ return unsafeBitCast(sym, to: CopyManagedDisplaySpacesFunc.self)
237
+ }()
238
+
239
+ static let copySpacesForWindows: CopySpacesForWindowsFunc? = {
240
+ guard let h = handle, let sym = dlsym(h, "SLSCopySpacesForWindows") else { return nil }
241
+ return unsafeBitCast(sym, to: CopySpacesForWindowsFunc.self)
242
+ }()
243
+
244
+ static let setCurrentSpace: SetCurrentSpaceFunc? = {
245
+ guard let h = handle, let sym = dlsym(h, "SLSManagedDisplaySetCurrentSpace") else { return nil }
246
+ return unsafeBitCast(sym, to: SetCurrentSpaceFunc.self)
247
+ }()
248
+
249
+ // Move windows between spaces
250
+ typealias AddWindowsToSpacesFunc = @convention(c) (Int32, CFArray, CFArray) -> Void
251
+ typealias RemoveWindowsFromSpacesFunc = @convention(c) (Int32, CFArray, CFArray) -> Void
252
+
253
+ static let addWindowsToSpaces: AddWindowsToSpacesFunc? = {
254
+ guard let h = handle else { return nil }
255
+ guard let sym = dlsym(h, "CGSAddWindowsToSpaces") ?? dlsym(h, "SLSAddWindowsToSpaces") else { return nil }
256
+ return unsafeBitCast(sym, to: AddWindowsToSpacesFunc.self)
257
+ }()
258
+
259
+ static let removeWindowsFromSpaces: RemoveWindowsFromSpacesFunc? = {
260
+ guard let h = handle else { return nil }
261
+ guard let sym = dlsym(h, "CGSRemoveWindowsFromSpaces") ?? dlsym(h, "SLSRemoveWindowsFromSpaces") else { return nil }
262
+ return unsafeBitCast(sym, to: RemoveWindowsFromSpacesFunc.self)
263
+ }()
264
+ }
265
+
266
+ enum WindowTiler {
267
+ /// Whether CGS move-between-spaces APIs are available
268
+ static var canMoveWindowsBetweenSpaces: Bool {
269
+ CGS.addWindowsToSpaces != nil && CGS.removeWindowsFromSpaces != nil
270
+ }
271
+
272
+ /// Convert fractional rect to AppleScript bounds {left, top, right, bottom}
273
+ /// AppleScript uses top-left origin; NSScreen uses bottom-left origin
274
+ private static func appleScriptBounds(for position: TilePosition, screen: NSScreen? = nil) -> (Int, Int, Int, Int) {
275
+ let targetScreen = screen ?? NSScreen.main
276
+ guard let targetScreen else { return (0, 0, 960, 540) }
277
+ let full = targetScreen.frame
278
+ let visible = targetScreen.visibleFrame
279
+
280
+ let visTop = Int(full.height - visible.maxY)
281
+ let visLeft = Int(visible.minX)
282
+ let visW = Int(visible.width)
283
+ let visH = Int(visible.height)
284
+
285
+ let (fx, fy, fw, fh) = position.rect
286
+ let x1 = visLeft + Int(CGFloat(visW) * fx)
287
+ let y1 = visTop + Int(CGFloat(visH) * fy)
288
+ let x2 = x1 + Int(CGFloat(visW) * fw)
289
+ let y2 = y1 + Int(CGFloat(visH) * fh)
290
+ return (x1, y1, x2, y2)
291
+ }
292
+
293
+ /// Compute AX-coordinate frame for a tile position on a given screen
294
+ static func tileFrame(for position: TilePosition, on screen: NSScreen) -> CGRect {
295
+ let visible = screen.visibleFrame
296
+ guard let primary = NSScreen.screens.first else { return .zero }
297
+ let primaryH = primary.frame.height
298
+ let axTop = primaryH - visible.maxY
299
+ let (fx, fy, fw, fh) = position.rect
300
+ return CGRect(
301
+ x: visible.origin.x + visible.width * fx,
302
+ y: axTop + visible.height * fy,
303
+ width: visible.width * fw,
304
+ height: visible.height * fh
305
+ )
306
+ }
307
+
308
+ /// Compute AX-coordinate frame for a tile position within a raw display CGRect (CG/AX coords)
309
+ static func tileFrame(for position: TilePosition, inDisplay displayRect: CGRect) -> CGRect {
310
+ let (fx, fy, fw, fh) = position.rect
311
+ return CGRect(
312
+ x: displayRect.origin.x + displayRect.width * fx,
313
+ y: displayRect.origin.y + displayRect.height * fy,
314
+ width: displayRect.width * fw,
315
+ height: displayRect.height * fh
316
+ )
317
+ }
318
+
319
+ /// Compute AX-coordinate frame from fractional (x, y, w, h) within a raw display CGRect
320
+ static func tileFrame(fractions: (CGFloat, CGFloat, CGFloat, CGFloat), inDisplay displayRect: CGRect) -> CGRect {
321
+ let (fx, fy, fw, fh) = fractions
322
+ return CGRect(
323
+ x: displayRect.origin.x + displayRect.width * fx,
324
+ y: displayRect.origin.y + displayRect.height * fy,
325
+ width: displayRect.width * fw,
326
+ height: displayRect.height * fh
327
+ )
328
+ }
329
+
330
+ /// Tile a specific terminal window on a given screen.
331
+ /// Fast path: DesktopModel → AX. Fallback: AX search → AppleScript last resort.
332
+ static func tile(session: String, terminal: Terminal, to position: TilePosition, on screen: NSScreen) {
333
+ let diag = DiagnosticLog.shared
334
+ let t = diag.startTimed("tile: \(session) → \(position.rawValue)")
335
+
336
+ // Fast path: use DesktopModel cache → single AX move
337
+ if let entry = DesktopModel.shared.windowForSession(session) {
338
+ let frame = tileFrame(for: position, on: screen)
339
+ batchMoveAndRaiseWindows([(wid: entry.wid, pid: entry.pid, frame: frame)])
340
+ diag.success("tile fast path (DesktopModel): \(session)")
341
+ diag.finish(t)
342
+ return
343
+ }
344
+
345
+ // AX fallback: search terminal windows by title tag
346
+ let tag = Terminal.windowTag(for: session)
347
+ if let (pid, axWindow) = findWindowViaAX(terminal: terminal, tag: tag) {
348
+ let targetFrame = tileFrame(for: position, on: screen)
349
+ var newPos = CGPoint(x: targetFrame.origin.x, y: targetFrame.origin.y)
350
+ var newSize = CGSize(width: targetFrame.width, height: targetFrame.height)
351
+ let win = axWindow
352
+ if let sv = AXValueCreate(.cgSize, &newSize) {
353
+ AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
354
+ }
355
+ if let pv = AXValueCreate(.cgPoint, &newPos) {
356
+ AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
357
+ }
358
+ if let sv = AXValueCreate(.cgSize, &newSize) {
359
+ AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
360
+ }
361
+ if let pv = AXValueCreate(.cgPoint, &newPos) {
362
+ AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
363
+ }
364
+ AXUIElementPerformAction(win, kAXRaiseAction as CFString)
365
+ if let app = NSRunningApplication(processIdentifier: pid) { app.activate() }
366
+ diag.success("tile AX fallback: \(session)")
367
+ diag.finish(t)
368
+ return
369
+ }
370
+
371
+ // AppleScript last resort (slow, single-monitor)
372
+ diag.warn("tile AppleScript last resort: \(session)")
373
+ let bounds = appleScriptBounds(for: position, screen: screen)
374
+ switch terminal {
375
+ case .terminal:
376
+ tileAppleScript(app: "Terminal", tag: tag, bounds: bounds)
377
+ case .iterm2:
378
+ tileAppleScript(app: "iTerm2", tag: tag, bounds: bounds)
379
+ default:
380
+ tileFrontmost(bounds: bounds)
381
+ }
382
+ diag.finish(t)
383
+ }
384
+
385
+ /// Tile a specific terminal window (found by lattices session tag) to a position.
386
+ /// Uses the same fast path strategy as tile(session:terminal:to:on:) with main screen.
387
+ static func tile(session: String, terminal: Terminal, to position: TilePosition) {
388
+ let screen = NSScreen.main ?? NSScreen.screens[0]
389
+ tile(session: session, terminal: terminal, to: position, on: screen)
390
+ }
391
+
392
+ /// Tile the frontmost window (works for any terminal)
393
+ static func tileFrontmost(to position: TilePosition) {
394
+ tileFrontmost(bounds: appleScriptBounds(for: position))
395
+ }
396
+
397
+ // MARK: - Spaces
398
+
399
+ /// Get spaces organized by display
400
+ static func getDisplaySpaces() -> [DisplaySpaces] {
401
+ guard let mainConn = CGS.mainConnectionID,
402
+ let copyManaged = CGS.copyManagedDisplaySpaces else { return [] }
403
+
404
+ let cid = mainConn()
405
+ guard let managed = copyManaged(cid) as? [[String: Any]] else { return [] }
406
+
407
+ var result: [DisplaySpaces] = []
408
+ for (displayIdx, display) in managed.enumerated() {
409
+ let displayId = display["Display Identifier"] as? String ?? ""
410
+ let rawSpaces = display["Spaces"] as? [[String: Any]] ?? []
411
+ let currentDict = display["Current Space"] as? [String: Any]
412
+ let currentId = currentDict?["id64"] as? Int ?? currentDict?["ManagedSpaceID"] as? Int ?? 0
413
+
414
+ var spaces: [SpaceInfo] = []
415
+ for (spaceIdx, space) in rawSpaces.enumerated() {
416
+ let sid = space["id64"] as? Int ?? space["ManagedSpaceID"] as? Int ?? 0
417
+ let type = space["type"] as? Int ?? 0
418
+ if type == 0 {
419
+ spaces.append(SpaceInfo(
420
+ id: sid,
421
+ index: spaceIdx + 1,
422
+ display: displayIdx,
423
+ isCurrent: sid == currentId
424
+ ))
425
+ }
426
+ }
427
+
428
+ result.append(DisplaySpaces(
429
+ displayIndex: displayIdx,
430
+ displayId: displayId,
431
+ spaces: spaces,
432
+ currentSpaceId: currentId
433
+ ))
434
+ }
435
+ return result
436
+ }
437
+
438
+ /// Get the current active Space ID
439
+ static func getCurrentSpace() -> Int {
440
+ guard let mainConn = CGS.mainConnectionID, let getActive = CGS.getActiveSpace else { return 0 }
441
+ return Int(getActive(mainConn()))
442
+ }
443
+
444
+ /// Find a window by its title tag and return its CGWindowID and owner PID
445
+ static func findWindow(tag: String) -> (wid: UInt32, pid: pid_t)? {
446
+ guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
447
+ return nil
448
+ }
449
+ for info in windowList {
450
+ if let name = info[kCGWindowName as String] as? String,
451
+ name.contains(tag),
452
+ let wid = info[kCGWindowNumber as String] as? UInt32,
453
+ let pid = info[kCGWindowOwnerPID as String] as? pid_t {
454
+ return (wid, pid)
455
+ }
456
+ }
457
+ return nil
458
+ }
459
+
460
+ /// Get the space ID(s) a window is on
461
+ static func getSpacesForWindow(_ wid: UInt32) -> [Int] {
462
+ guard let mainConn = CGS.mainConnectionID,
463
+ let copySpaces = CGS.copySpacesForWindows else { return [] }
464
+ let cid = mainConn()
465
+ let arr = [NSNumber(value: wid)] as CFArray
466
+ guard let result = copySpaces(cid, 0x7, arr) as? [NSNumber] else { return [] }
467
+ return result.map { $0.intValue }
468
+ }
469
+
470
+ /// Switch a display to a specific Space
471
+ static func switchToSpace(spaceId: Int) {
472
+ guard let mainConn = CGS.mainConnectionID,
473
+ let setSpace = CGS.setCurrentSpace else { return }
474
+
475
+ let cid = mainConn()
476
+
477
+ // Find which display this space belongs to
478
+ let allDisplays = getDisplaySpaces()
479
+ for display in allDisplays {
480
+ if display.spaces.contains(where: { $0.id == spaceId }) {
481
+ setSpace(cid, display.displayId as CFString, UInt64(spaceId))
482
+ return
483
+ }
484
+ }
485
+ }
486
+
487
+ // MARK: - Move Window Between Spaces
488
+
489
+ enum MoveResult {
490
+ case success(method: String)
491
+ case alreadyOnSpace
492
+ case windowNotFound
493
+ case failed(reason: String)
494
+ }
495
+
496
+ /// Move a session's terminal window to a different Space.
497
+ /// Note: On macOS 14.5+ the CGS move APIs are silently denied.
498
+ /// When that happens we fall back to just switching the user's view.
499
+ static func moveWindowToSpace(session: String, terminal: Terminal, spaceId: Int) -> MoveResult {
500
+ let diag = DiagnosticLog.shared
501
+ let tag = Terminal.windowTag(for: session)
502
+ diag.info("moveWindowToSpace: session=\(session) tag=\(tag) targetSpace=\(spaceId)")
503
+
504
+ // Find the window — CG first, then AX→CG fallback
505
+ let wid: UInt32
506
+ if let (w, _) = findWindow(tag: tag) {
507
+ wid = w
508
+ diag.info("moveWindowToSpace: found via CG wid=\(w)")
509
+ } else if let (pid, axWindow) = findWindowViaAX(terminal: terminal, tag: tag),
510
+ let w = matchCGWindow(pid: pid, axWindow: axWindow) {
511
+ wid = w
512
+ diag.info("moveWindowToSpace: found via AX→CG wid=\(w)")
513
+ } else {
514
+ diag.warn("moveWindowToSpace: window not found for tag \(tag) — switching view only")
515
+ switchToSpace(spaceId: spaceId)
516
+ return .windowNotFound
517
+ }
518
+
519
+ // Check current spaces
520
+ let currentSpaces = getSpacesForWindow(wid)
521
+ diag.info("moveWindowToSpace: wid=\(wid) currentSpaces=\(currentSpaces)")
522
+ if currentSpaces.contains(spaceId) {
523
+ diag.info("moveWindowToSpace: already on target space — switching view")
524
+ switchToSpace(spaceId: spaceId)
525
+ return .alreadyOnSpace
526
+ }
527
+
528
+ // Try CGS direct move (works on older macOS, silently denied on 14.5+)
529
+ if let result = moveViaCGS(wid: wid, fromSpaces: currentSpaces, toSpace: spaceId) {
530
+ return result
531
+ }
532
+
533
+ // CGS unavailable — just switch the user's view
534
+ diag.info("moveWindowToSpace: CGS unavailable, switching view to space")
535
+ switchToSpace(spaceId: spaceId)
536
+ return .success(method: "switch-view")
537
+ }
538
+
539
+ /// Attempt CGS-based window move. Returns nil if APIs are unavailable.
540
+ private static func moveViaCGS(wid: UInt32, fromSpaces: [Int], toSpace: Int) -> MoveResult? {
541
+ let diag = DiagnosticLog.shared
542
+ guard let mainConn = CGS.mainConnectionID,
543
+ let addToSpaces = CGS.addWindowsToSpaces,
544
+ let removeFromSpaces = CGS.removeWindowsFromSpaces else {
545
+ return nil
546
+ }
547
+
548
+ let cid = mainConn()
549
+ let windowArray = [NSNumber(value: wid)] as CFArray
550
+ let targetArray = [NSNumber(value: toSpace)] as CFArray
551
+
552
+ addToSpaces(cid, windowArray, targetArray)
553
+ if !fromSpaces.isEmpty {
554
+ let sourceArray = fromSpaces.map { NSNumber(value: $0) } as CFArray
555
+ removeFromSpaces(cid, windowArray, sourceArray)
556
+ }
557
+
558
+ // Verify the move took effect (macOS 14.5+ silently denies)
559
+ let newSpaces = getSpacesForWindow(wid)
560
+ if newSpaces.contains(toSpace) && !fromSpaces.allSatisfy({ newSpaces.contains($0) }) {
561
+ diag.success("moveViaCGS: successfully moved wid=\(wid) to space \(toSpace)")
562
+ return .success(method: "CGS")
563
+ }
564
+
565
+ // CGS was silently denied — switch the view instead
566
+ diag.warn("moveViaCGS: silently denied (macOS 14.5+ restriction) — switching view")
567
+ switchToSpace(spaceId: toSpace)
568
+ return .success(method: "switch-view")
569
+ }
570
+
571
+ /// Navigate to a session's window: switch to its Space, raise it, highlight it
572
+ /// Falls back through CG → AX → AppleScript depending on available permissions
573
+ static func navigateToWindow(session: String, terminal: Terminal) {
574
+ let diag = DiagnosticLog.shared
575
+ let t = diag.startTimed("navigateToWindow: \(session)")
576
+ let tag = Terminal.windowTag(for: session)
577
+
578
+ // Path 1: CG window lookup (needs Screen Recording permission for window names)
579
+ if let (wid, pid) = findWindow(tag: tag) {
580
+ diag.success("Path 1 (CG): found wid=\(wid) pid=\(pid)")
581
+ navigateToKnownWindow(wid: wid, pid: pid, tag: tag, session: session, terminal: terminal)
582
+ diag.finish(t)
583
+ return
584
+ }
585
+ diag.warn("Path 1 (CG): findWindow failed — no Screen Recording?")
586
+
587
+ // Path 2: AX API fallback (needs Accessibility permission)
588
+ if let (pid, axWindow) = findWindowViaAX(terminal: terminal, tag: tag) {
589
+ diag.success("Path 2 (AX): found window for \(terminal.rawValue) pid=\(pid)")
590
+ // Try to match AX window → CG window for space switching
591
+ if let wid = matchCGWindow(pid: pid, axWindow: axWindow) {
592
+ diag.success("Path 2 (AX→CG): matched CG wid=\(wid)")
593
+ navigateToKnownWindow(wid: wid, pid: pid, tag: tag, session: session, terminal: terminal)
594
+ } else {
595
+ diag.warn("Path 2 (AX): no CG match — raising without space switch")
596
+ AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
597
+ AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
598
+ if let app = NSRunningApplication(processIdentifier: pid) {
599
+ app.activate()
600
+ }
601
+ if let frame = axWindowFrame(axWindow) {
602
+ diag.info("Highlighting via AX frame: \(frame)")
603
+ DispatchQueue.main.async { WindowHighlight.shared.flash(frame: frame) }
604
+ } else {
605
+ diag.error("axWindowFrame returned nil — no highlight")
606
+ }
607
+ }
608
+ diag.finish(t)
609
+ return
610
+ }
611
+ diag.warn("Path 2 (AX): findWindowViaAX failed — no Accessibility?")
612
+
613
+ // Path 3: AppleScript / bare activate fallback
614
+ diag.warn("Path 3: falling back to AppleScript/activate")
615
+ activateViaAppleScript(session: session, tag: tag, terminal: terminal)
616
+ diag.finish(t)
617
+ }
618
+
619
+ private static func navigateToKnownWindow(wid: UInt32, pid: pid_t, tag: String, session: String, terminal: Terminal) {
620
+ let diag = DiagnosticLog.shared
621
+ let windowSpaces = getSpacesForWindow(wid)
622
+ let currentSpace = getCurrentSpace()
623
+ diag.info("navigateToKnown: wid=\(wid) spaces=\(windowSpaces) current=\(currentSpace)")
624
+
625
+ if let windowSpace = windowSpaces.first, windowSpace != currentSpace {
626
+ diag.info("Switching from space \(currentSpace) → \(windowSpace)")
627
+ switchToSpace(spaceId: windowSpace)
628
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
629
+ raiseWindow(pid: pid, tag: tag, terminal: terminal)
630
+ highlightWindow(session: session)
631
+ }
632
+ } else {
633
+ diag.info("Window on current space — raising + highlighting")
634
+ raiseWindow(pid: pid, tag: tag, terminal: terminal)
635
+ highlightWindow(session: session)
636
+ }
637
+ }
638
+
639
+ /// Find a terminal window by title tag using AX API (requires Accessibility permission)
640
+ private static func findWindowViaAX(terminal: Terminal, tag: String) -> (pid: pid_t, window: AXUIElement)? {
641
+ let diag = DiagnosticLog.shared
642
+ guard let app = NSWorkspace.shared.runningApplications.first(where: {
643
+ $0.bundleIdentifier == terminal.bundleId
644
+ }) else {
645
+ diag.error("findWindowViaAX: \(terminal.rawValue) (\(terminal.bundleId)) not running")
646
+ return nil
647
+ }
648
+
649
+ let pid = app.processIdentifier
650
+ let appRef = AXUIElementCreateApplication(pid)
651
+ var windowsRef: CFTypeRef?
652
+ let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
653
+ guard err == .success, let windows = windowsRef as? [AXUIElement] else {
654
+ diag.error("findWindowViaAX: AX error \(err.rawValue) — Accessibility not granted?")
655
+ return nil
656
+ }
657
+
658
+ diag.info("findWindowViaAX: \(windows.count) windows for \(terminal.rawValue), searching for \(tag)")
659
+ for win in windows {
660
+ var titleRef: CFTypeRef?
661
+ AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleRef)
662
+ let title = titleRef as? String ?? "<no title>"
663
+ if title.contains(tag) {
664
+ diag.success("findWindowViaAX: matched \"\(title)\"")
665
+ return (pid, win)
666
+ } else {
667
+ diag.info(" skip: \"\(title)\"")
668
+ }
669
+ }
670
+ diag.warn("findWindowViaAX: no window matched tag \(tag)")
671
+ return nil
672
+ }
673
+
674
+ /// Match an AX window to its CG window ID using PID + bounds comparison
675
+ private static func matchCGWindow(pid: pid_t, axWindow: AXUIElement) -> UInt32? {
676
+ var posRef: CFTypeRef?
677
+ var sizeRef: CFTypeRef?
678
+ AXUIElementCopyAttributeValue(axWindow, kAXPositionAttribute as CFString, &posRef)
679
+ AXUIElementCopyAttributeValue(axWindow, kAXSizeAttribute as CFString, &sizeRef)
680
+ guard let pv = posRef, let sv = sizeRef else { return nil }
681
+
682
+ var pos = CGPoint.zero
683
+ var size = CGSize.zero
684
+ AXValueGetValue(pv as! AXValue, .cgPoint, &pos)
685
+ AXValueGetValue(sv as! AXValue, .cgSize, &size)
686
+
687
+ guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return nil }
688
+
689
+ for info in windowList {
690
+ guard let wPid = info[kCGWindowOwnerPID as String] as? pid_t,
691
+ wPid == pid,
692
+ let wid = info[kCGWindowNumber as String] as? UInt32,
693
+ let boundsDict = info[kCGWindowBounds as String] as? NSDictionary else { continue }
694
+ var rect = CGRect.zero
695
+ if CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) {
696
+ if abs(rect.origin.x - pos.x) < 2 && abs(rect.origin.y - pos.y) < 2 &&
697
+ abs(rect.width - size.width) < 2 && abs(rect.height - size.height) < 2 {
698
+ return wid
699
+ }
700
+ }
701
+ }
702
+ return nil
703
+ }
704
+
705
+ /// Get NSRect from an AX window element (AX uses top-left origin, convert to NS bottom-left)
706
+ private static func axWindowFrame(_ window: AXUIElement) -> NSRect? {
707
+ var posRef: CFTypeRef?
708
+ var sizeRef: CFTypeRef?
709
+ AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posRef)
710
+ AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeRef)
711
+ guard let pv = posRef, let sv = sizeRef else { return nil }
712
+
713
+ var pos = CGPoint.zero
714
+ var size = CGSize.zero
715
+ AXValueGetValue(pv as! AXValue, .cgPoint, &pos)
716
+ AXValueGetValue(sv as! AXValue, .cgSize, &size)
717
+
718
+ guard let primaryScreen = NSScreen.screens.first else { return nil }
719
+ let primaryHeight = primaryScreen.frame.height
720
+ return NSRect(x: pos.x, y: primaryHeight - pos.y - size.height, width: size.width, height: size.height)
721
+ }
722
+
723
+ /// Last-resort: use AppleScript for Terminal/iTerm2, or bare activate for others
724
+ private static func activateViaAppleScript(session: String, tag: String, terminal: Terminal) {
725
+ switch terminal {
726
+ case .terminal:
727
+ runScript("""
728
+ tell application "Terminal"
729
+ activate
730
+ repeat with w in windows
731
+ if name of w contains "\(tag)" then
732
+ set index of w to 1
733
+ exit repeat
734
+ end if
735
+ end repeat
736
+ end tell
737
+ """)
738
+ case .iterm2:
739
+ runScript("""
740
+ tell application "iTerm2"
741
+ activate
742
+ repeat with w in windows
743
+ if name of w contains "\(tag)" then
744
+ select w
745
+ exit repeat
746
+ end if
747
+ end repeat
748
+ end tell
749
+ """)
750
+ default:
751
+ if let app = NSWorkspace.shared.runningApplications.first(where: {
752
+ $0.bundleIdentifier == terminal.bundleId
753
+ }) {
754
+ app.activate()
755
+ }
756
+ }
757
+ }
758
+
759
+ /// Raise a specific window using AX API + AppleScript
760
+ private static func raiseWindow(pid: pid_t, tag: String, terminal: Terminal) {
761
+ let diag = DiagnosticLog.shared
762
+ let appRef = AXUIElementCreateApplication(pid)
763
+ var windowsRef: CFTypeRef?
764
+ let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
765
+ var raised = false
766
+ if err == .success, let windows = windowsRef as? [AXUIElement] {
767
+ for win in windows {
768
+ var titleRef: CFTypeRef?
769
+ AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleRef)
770
+ if let title = titleRef as? String, title.contains(tag) {
771
+ AXUIElementPerformAction(win, kAXRaiseAction as CFString)
772
+ AXUIElementSetAttributeValue(win, kAXMainAttribute as CFString, kCFBooleanTrue)
773
+ diag.success("raiseWindow: raised \"\(title)\"")
774
+ raised = true
775
+ break
776
+ }
777
+ }
778
+ }
779
+ if !raised {
780
+ diag.warn("raiseWindow: could not find window with tag \(tag) via AX (err=\(err.rawValue))")
781
+ }
782
+
783
+ if let app = NSRunningApplication(processIdentifier: pid) {
784
+ app.activate()
785
+ diag.info("raiseWindow: activated \(app.localizedName ?? "pid:\(pid)")")
786
+ }
787
+ }
788
+
789
+ // MARK: - Highlight
790
+
791
+ /// Flash a highlight border around a session's terminal window
792
+ static func highlightWindow(session: String) {
793
+ let diag = DiagnosticLog.shared
794
+ let tag = Terminal.windowTag(for: session)
795
+ diag.info("highlightWindow: tag=\(tag)")
796
+
797
+ // Path 1: CG approach (needs Screen Recording)
798
+ if let (wid, _) = findWindow(tag: tag) {
799
+ diag.info("highlight via CG: wid=\(wid)")
800
+ guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return }
801
+ for info in windowList {
802
+ if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
803
+ let dict = info[kCGWindowBounds as String] as? NSDictionary {
804
+ var rect = CGRect.zero
805
+ if CGRectMakeWithDictionaryRepresentation(dict, &rect) {
806
+ guard let primaryScreen = NSScreen.screens.first else { return }
807
+ let primaryHeight = primaryScreen.frame.height
808
+ let nsRect = NSRect(
809
+ x: rect.origin.x,
810
+ y: primaryHeight - rect.origin.y - rect.height,
811
+ width: rect.width,
812
+ height: rect.height
813
+ )
814
+ diag.success("highlight CG flash at \(Int(nsRect.origin.x)),\(Int(nsRect.origin.y)) \(Int(nsRect.width))×\(Int(nsRect.height))")
815
+ DispatchQueue.main.async { WindowHighlight.shared.flash(frame: nsRect) }
816
+ }
817
+ return
818
+ }
819
+ }
820
+ diag.warn("highlight CG: wid \(wid) not in window list")
821
+ return
822
+ }
823
+
824
+ // Path 2: AX fallback — search installed terminals for the tagged window
825
+ diag.info("highlight: CG failed, trying AX fallback across \(Terminal.installed.count) terminals")
826
+ for terminal in Terminal.installed {
827
+ if let (_, axWindow) = findWindowViaAX(terminal: terminal, tag: tag),
828
+ let frame = axWindowFrame(axWindow) {
829
+ diag.success("highlight AX flash at \(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))×\(Int(frame.height))")
830
+ DispatchQueue.main.async { WindowHighlight.shared.flash(frame: frame) }
831
+ return
832
+ }
833
+ }
834
+ diag.error("highlight: no method found window — no highlight shown")
835
+ }
836
+
837
+ // MARK: - Window Info
838
+
839
+ struct WindowInfo {
840
+ let spaceIndex: Int // 1-based space number
841
+ let displayIndex: Int // 0-based display index
842
+ let tilePosition: TilePosition? // inferred from bounds, nil if free-form
843
+ let wid: UInt32
844
+ }
845
+
846
+ /// Get spatial info for a session's terminal window (space, display, tile position)
847
+ static func getWindowInfo(session: String, terminal: Terminal) -> WindowInfo? {
848
+ let tag = Terminal.windowTag(for: session)
849
+
850
+ // Find the window
851
+ let wid: UInt32
852
+ if let (w, _) = findWindow(tag: tag) {
853
+ wid = w
854
+ } else if let (pid, axWindow) = findWindowViaAX(terminal: terminal, tag: tag),
855
+ let w = matchCGWindow(pid: pid, axWindow: axWindow) {
856
+ wid = w
857
+ } else {
858
+ return nil
859
+ }
860
+
861
+ // Determine which space/display the window is on
862
+ let windowSpaces = getSpacesForWindow(wid)
863
+ let allDisplays = getDisplaySpaces()
864
+
865
+ var spaceIndex = 1
866
+ var displayIndex = 0
867
+
868
+ if let windowSpaceId = windowSpaces.first {
869
+ for display in allDisplays {
870
+ if let space = display.spaces.first(where: { $0.id == windowSpaceId }) {
871
+ spaceIndex = space.index
872
+ displayIndex = display.displayIndex
873
+ break
874
+ }
875
+ }
876
+ }
877
+
878
+ let tile = inferTilePosition(wid: wid)
879
+
880
+ return WindowInfo(
881
+ spaceIndex: spaceIndex,
882
+ displayIndex: displayIndex,
883
+ tilePosition: tile,
884
+ wid: wid
885
+ )
886
+ }
887
+
888
+ /// Infer tile position from a window frame + screen without re-querying CGWindowList
889
+ static func inferTilePosition(frame: WindowFrame, screen: NSScreen) -> TilePosition? {
890
+ let visible = screen.visibleFrame
891
+ let full = screen.frame
892
+
893
+ // CG top-left origin → visible frame top-left origin
894
+ let primaryHeight = NSScreen.screens.first?.frame.height ?? full.height
895
+ let visTop = primaryHeight - visible.maxY
896
+ let fx = (frame.x - visible.origin.x) / visible.width
897
+ let fy = (frame.y - visTop) / visible.height
898
+ let fw = frame.w / visible.width
899
+ let fh = frame.h / visible.height
900
+
901
+ let tolerance: CGFloat = 0.05
902
+
903
+ for position in TilePosition.allCases {
904
+ let (px, py, pw, ph) = position.rect
905
+ if abs(fx - CGFloat(px)) < tolerance && abs(fy - CGFloat(py)) < tolerance &&
906
+ abs(fw - CGFloat(pw)) < tolerance && abs(fh - CGFloat(ph)) < tolerance {
907
+ return position
908
+ }
909
+ }
910
+ return nil
911
+ }
912
+
913
+ /// Infer tile position from a window's current bounds relative to its screen
914
+ private static func inferTilePosition(wid: UInt32) -> TilePosition? {
915
+ guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
916
+ return nil
917
+ }
918
+
919
+ // Find the window's bounds
920
+ var windowRect = CGRect.zero
921
+ for info in windowList {
922
+ if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
923
+ let dict = info[kCGWindowBounds as String] as? NSDictionary {
924
+ CGRectMakeWithDictionaryRepresentation(dict, &windowRect)
925
+ break
926
+ }
927
+ }
928
+ guard windowRect.width > 0 else { return nil }
929
+
930
+ // Find which screen contains the window center
931
+ let centerX = windowRect.midX
932
+ let centerY = windowRect.midY
933
+ guard let primaryScreen = NSScreen.screens.first else { return nil }
934
+ let primaryHeight = primaryScreen.frame.height
935
+
936
+ // CG uses top-left origin; convert to NS bottom-left for screen matching
937
+ let nsCenterY = primaryHeight - centerY
938
+
939
+ let screen = NSScreen.screens.first(where: {
940
+ $0.frame.contains(NSPoint(x: centerX, y: nsCenterY))
941
+ }) ?? NSScreen.main ?? primaryScreen
942
+
943
+ let visible = screen.visibleFrame
944
+ let full = screen.frame
945
+
946
+ // Convert CG rect to fractional coordinates relative to visible frame
947
+ // CG top-left origin → visible frame top-left origin
948
+ let visTop = full.height - visible.maxY + full.origin.y
949
+ let fx = (windowRect.origin.x - visible.origin.x) / visible.width
950
+ let fy = (windowRect.origin.y - visTop) / visible.height
951
+ let fw = windowRect.width / visible.width
952
+ let fh = windowRect.height / visible.height
953
+
954
+ let tolerance: CGFloat = 0.05
955
+
956
+ for position in TilePosition.allCases {
957
+ let (px, py, pw, ph) = position.rect
958
+ if abs(fx - px) < tolerance && abs(fy - py) < tolerance &&
959
+ abs(fw - pw) < tolerance && abs(fh - ph) < tolerance {
960
+ return position
961
+ }
962
+ }
963
+
964
+ return nil
965
+ }
966
+
967
+ // MARK: - By-ID Window Operations (Desktop Inventory)
968
+
969
+ /// Navigate to an arbitrary window by its CG window ID: switch space, raise, highlight
970
+ static func navigateToWindowById(wid: UInt32, pid: Int32) {
971
+ let diag = DiagnosticLog.shared
972
+ diag.info("navigateToWindowById: wid=\(wid) pid=\(pid)")
973
+
974
+ // Switch to window's space if needed
975
+ let windowSpaces = getSpacesForWindow(wid)
976
+ let currentSpace = getCurrentSpace()
977
+
978
+ if let windowSpace = windowSpaces.first, windowSpace != currentSpace {
979
+ diag.info("Switching from space \(currentSpace) → \(windowSpace)")
980
+ switchToSpace(spaceId: windowSpace)
981
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
982
+ raiseWindowById(wid: wid, pid: pid)
983
+ highlightWindowById(wid: wid)
984
+ }
985
+ } else {
986
+ raiseWindowById(wid: wid, pid: pid)
987
+ highlightWindowById(wid: wid)
988
+ }
989
+ }
990
+
991
+ /// Flash a highlight border on any window by its CG window ID
992
+ static func highlightWindowById(wid: UInt32) {
993
+ guard let frame = cgWindowFrame(wid: wid) else {
994
+ DiagnosticLog.shared.warn("highlightWindowById: no frame for wid=\(wid)")
995
+ return
996
+ }
997
+ DispatchQueue.main.async { WindowHighlight.shared.flash(frame: frame) }
998
+ }
999
+
1000
+ /// Tile any window by its CG window ID to a position using AX API
1001
+ static func tileWindowById(wid: UInt32, pid: Int32, to position: TilePosition) {
1002
+ let diag = DiagnosticLog.shared
1003
+ diag.info("tileWindowById: wid=\(wid) pid=\(pid) pos=\(position.rawValue)")
1004
+
1005
+ // Find the screen the window is on
1006
+ guard let windowFrame = cgWindowFrame(wid: wid) else {
1007
+ diag.warn("tileWindowById: no frame for wid=\(wid)")
1008
+ return
1009
+ }
1010
+ let screen = NSScreen.screens.first(where: {
1011
+ $0.frame.contains(NSPoint(x: windowFrame.midX, y: windowFrame.midY))
1012
+ }) ?? NSScreen.main ?? NSScreen.screens[0]
1013
+
1014
+ let visible = screen.visibleFrame
1015
+ let (fx, fy, fw, fh) = position.rect
1016
+
1017
+ // Calculate target in NS coordinates (bottom-left origin)
1018
+ let targetX = visible.origin.x + visible.width * fx
1019
+ let targetY = visible.origin.y + visible.height * (1.0 - fy - fh)
1020
+ let targetW = visible.width * fw
1021
+ let targetH = visible.height * fh
1022
+
1023
+ // Convert NS bottom-left → AX top-left origin
1024
+ guard let primaryScreen = NSScreen.screens.first else { return }
1025
+ let primaryHeight = primaryScreen.frame.height
1026
+ let axX = targetX
1027
+ let axY = primaryHeight - targetY - targetH
1028
+
1029
+ // Find the AX window matching this CG wid by frame comparison
1030
+ guard let axWindow = findAXWindowByFrame(wid: wid, pid: pid) else {
1031
+ diag.warn("tileWindowById: couldn't match AX window for wid=\(wid)")
1032
+ return
1033
+ }
1034
+
1035
+ // Set position and size via AX
1036
+ var newPos = CGPoint(x: axX, y: axY)
1037
+ var newSize = CGSize(width: targetW, height: targetH)
1038
+
1039
+ if let posValue = AXValueCreate(.cgPoint, &newPos) {
1040
+ AXUIElementSetAttributeValue(axWindow, kAXPositionAttribute as CFString, posValue)
1041
+ }
1042
+ if let sizeValue = AXValueCreate(.cgSize, &newSize) {
1043
+ AXUIElementSetAttributeValue(axWindow, kAXSizeAttribute as CFString, sizeValue)
1044
+ }
1045
+
1046
+ diag.success("tileWindowById: tiled wid=\(wid) to \(position.rawValue)")
1047
+ }
1048
+
1049
+ /// Distribute windows in a smart grid layout (delegates to batch operation)
1050
+ static func tileDistributeHorizontally(windows: [(wid: UInt32, pid: Int32)]) {
1051
+ batchRaiseAndDistribute(windows: windows)
1052
+ }
1053
+
1054
+ /// Distribute ALL visible non-Lattices windows into a smart grid on the screen with the most windows.
1055
+ static func distributeVisible() {
1056
+ let diag = DiagnosticLog.shared
1057
+ let t = diag.startTimed("distributeVisible")
1058
+
1059
+ let allEntries = DesktopModel.shared.allWindows()
1060
+ let visible = allEntries.filter { entry in
1061
+ entry.isOnScreen && entry.app != "Lattices" && entry.frame.w > 50 && entry.frame.h > 50
1062
+ }
1063
+
1064
+ guard !visible.isEmpty else {
1065
+ diag.info("distributeVisible: no visible windows to distribute")
1066
+ diag.finish(t)
1067
+ return
1068
+ }
1069
+
1070
+ let windows = visible.map { (wid: $0.wid, pid: $0.pid) }
1071
+ diag.info("distributeVisible: \(windows.count) windows")
1072
+ batchRaiseAndDistribute(windows: windows)
1073
+ diag.finish(t)
1074
+ }
1075
+
1076
+ /// Get NSRect (bottom-left origin) for a known CG window ID
1077
+ private static func cgWindowFrame(wid: UInt32) -> NSRect? {
1078
+ guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return nil }
1079
+ for info in windowList {
1080
+ if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
1081
+ let dict = info[kCGWindowBounds as String] as? NSDictionary {
1082
+ var rect = CGRect.zero
1083
+ if CGRectMakeWithDictionaryRepresentation(dict, &rect) {
1084
+ guard let primaryScreen = NSScreen.screens.first else { return nil }
1085
+ let primaryHeight = primaryScreen.frame.height
1086
+ return NSRect(
1087
+ x: rect.origin.x,
1088
+ y: primaryHeight - rect.origin.y - rect.height,
1089
+ width: rect.width,
1090
+ height: rect.height
1091
+ )
1092
+ }
1093
+ }
1094
+ }
1095
+ return nil
1096
+ }
1097
+
1098
+ /// Raise a window by matching its CG window ID to an AX element via frame comparison
1099
+ private static func raiseWindowById(wid: UInt32, pid: Int32) {
1100
+ let diag = DiagnosticLog.shared
1101
+
1102
+ if let axWindow = findAXWindowByFrame(wid: wid, pid: pid) {
1103
+ AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
1104
+ AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
1105
+ diag.success("raiseWindowById: raised wid=\(wid)")
1106
+ } else {
1107
+ diag.warn("raiseWindowById: couldn't match AX window for wid=\(wid)")
1108
+ }
1109
+
1110
+ if let app = NSRunningApplication(processIdentifier: pid) {
1111
+ app.activate()
1112
+ }
1113
+ }
1114
+
1115
+ /// Raise multiple windows at once, re-activating our app once at the end
1116
+ static func raiseWindowsAndReactivate(windows: [(wid: UInt32, pid: Int32)]) {
1117
+ let diag = DiagnosticLog.shared
1118
+ var activatedPids = Set<Int32>()
1119
+ for win in windows {
1120
+ if let axWindow = findAXWindowByFrame(wid: win.wid, pid: win.pid) {
1121
+ AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
1122
+ AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
1123
+ }
1124
+ if !activatedPids.contains(win.pid) {
1125
+ if let app = NSRunningApplication(processIdentifier: win.pid) {
1126
+ app.activate()
1127
+ activatedPids.insert(win.pid)
1128
+ }
1129
+ }
1130
+ }
1131
+ diag.success("raiseWindowsAndReactivate: raised \(windows.count) windows")
1132
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
1133
+ NSApp.activate(ignoringOtherApps: true)
1134
+ }
1135
+ }
1136
+
1137
+ /// Raise a window to front and then re-activate our own app so the panel stays visible
1138
+ static func raiseWindowAndReactivate(wid: UInt32, pid: Int32) {
1139
+ let diag = DiagnosticLog.shared
1140
+ diag.info("raiseWindowAndReactivate: wid=\(wid) pid=\(pid)")
1141
+
1142
+ // Switch to window's space if needed
1143
+ let windowSpaces = getSpacesForWindow(wid)
1144
+ let currentSpace = getCurrentSpace()
1145
+
1146
+ let doRaise = {
1147
+ if let axWindow = findAXWindowByFrame(wid: wid, pid: pid) {
1148
+ AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
1149
+ AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
1150
+ diag.success("raiseWindowAndReactivate: raised wid=\(wid)")
1151
+ }
1152
+ // Activate target app briefly so window comes to front
1153
+ if let app = NSRunningApplication(processIdentifier: pid) {
1154
+ app.activate()
1155
+ }
1156
+ // Re-activate our app so the panel stays visible
1157
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
1158
+ NSApp.activate(ignoringOtherApps: true)
1159
+ }
1160
+ }
1161
+
1162
+ if let windowSpace = windowSpaces.first, windowSpace != currentSpace {
1163
+ diag.info("Switching from space \(currentSpace) → \(windowSpace)")
1164
+ switchToSpace(spaceId: windowSpace)
1165
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { doRaise() }
1166
+ } else {
1167
+ doRaise()
1168
+ }
1169
+ }
1170
+
1171
+ // MARK: - Batch Window Operations
1172
+
1173
+ /// Move multiple windows to target frames in one shot.
1174
+ /// Single CGWindowList query, single AX query per process, all moves synchronous.
1175
+ static func batchMoveWindows(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)]) {
1176
+ guard !moves.isEmpty else { return }
1177
+ let diag = DiagnosticLog.shared
1178
+
1179
+ // Group by pid so we query each app's AX windows once
1180
+ var byPid: [Int32: [(wid: UInt32, target: CGRect)]] = [:]
1181
+ for move in moves {
1182
+ byPid[move.pid, default: []].append((wid: move.wid, target: move.frame))
1183
+ }
1184
+
1185
+ // For each process: get AX windows, match by CGWindowID, move+resize
1186
+ var moved = 0
1187
+ var failed = 0
1188
+ for (pid, windowMoves) in byPid {
1189
+ let appRef = AXUIElementCreateApplication(pid)
1190
+ var windowsRef: CFTypeRef?
1191
+ let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1192
+ guard err == .success, let axWindows = windowsRef as? [AXUIElement] else {
1193
+ diag.info("[batchMove] AX query failed for pid \(pid)")
1194
+ failed += windowMoves.count
1195
+ continue
1196
+ }
1197
+
1198
+ // Build wid → AXUIElement map using _AXUIElementGetWindow
1199
+ var axByWid: [UInt32: AXUIElement] = [:]
1200
+ for axWin in axWindows {
1201
+ var windowId: CGWindowID = 0
1202
+ if _AXUIElementGetWindow(axWin, &windowId) == .success {
1203
+ axByWid[windowId] = axWin
1204
+ }
1205
+ }
1206
+
1207
+ for wm in windowMoves {
1208
+ guard let axWin = axByWid[wm.wid] else {
1209
+ diag.info("[batchMove] no AX match for wid \(wm.wid)")
1210
+ failed += 1
1211
+ continue
1212
+ }
1213
+
1214
+ applyFrameToAXWindow(axWin, wid: wm.wid, target: wm.target)
1215
+ moved += 1
1216
+ }
1217
+ }
1218
+ if failed > 0 {
1219
+ diag.info("[batchMove] \(failed) windows failed to match")
1220
+ }
1221
+ diag.success("batchMoveWindows: moved \(moved)/\(moves.count) windows")
1222
+ }
1223
+
1224
+ /// Apply position+size to a single AX window. No delays, no retries — just set and go.
1225
+ private static func applyFrameToAXWindow(_ axWin: AXUIElement, wid: UInt32, target: CGRect) {
1226
+ var newPos = CGPoint(x: target.origin.x, y: target.origin.y)
1227
+ var newSize = CGSize(width: target.width, height: target.height)
1228
+
1229
+ // Size first (avoids clipping at screen edges), then position
1230
+ if let sv = AXValueCreate(.cgSize, &newSize) {
1231
+ AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
1232
+ }
1233
+ if let pv = AXValueCreate(.cgPoint, &newPos) {
1234
+ AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
1235
+ }
1236
+ }
1237
+
1238
+ /// Read back current AX position+size for a window element.
1239
+ static func readAXFrame(_ axWin: AXUIElement) -> CGRect? {
1240
+ var posRef: CFTypeRef?
1241
+ var sizeRef: CFTypeRef?
1242
+ guard AXUIElementCopyAttributeValue(axWin, kAXPositionAttribute as CFString, &posRef) == .success,
1243
+ AXUIElementCopyAttributeValue(axWin, kAXSizeAttribute as CFString, &sizeRef) == .success else {
1244
+ return nil
1245
+ }
1246
+ var pos = CGPoint.zero
1247
+ var size = CGSize.zero
1248
+ guard AXValueGetValue(posRef as! AXValue, .cgPoint, &pos),
1249
+ AXValueGetValue(sizeRef as! AXValue, .cgSize, &size) else {
1250
+ return nil
1251
+ }
1252
+ return CGRect(origin: pos, size: size)
1253
+ }
1254
+
1255
+ /// Verify which windows drifted from their targets using CGWindowList.
1256
+ /// Returns array of moves that still need correction.
1257
+ static func verifyMoves(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)], tolerance: CGFloat = 4) -> [(wid: UInt32, pid: Int32, frame: CGRect)] {
1258
+ guard let rawList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
1259
+ return moves // can't verify, return all
1260
+ }
1261
+
1262
+ var actualByWid: [UInt32: CGRect] = [:]
1263
+ for info in rawList {
1264
+ guard let wid = info[kCGWindowNumber as String] as? UInt32,
1265
+ let bounds = info[kCGWindowBounds as String] as? [String: Any],
1266
+ let x = bounds["X"] as? CGFloat, let y = bounds["Y"] as? CGFloat,
1267
+ let w = bounds["Width"] as? CGFloat, let h = bounds["Height"] as? CGFloat else { continue }
1268
+ actualByWid[wid] = CGRect(x: x, y: y, width: w, height: h)
1269
+ }
1270
+
1271
+ let diag = DiagnosticLog.shared
1272
+ var drifted: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
1273
+ for move in moves {
1274
+ guard let actual = actualByWid[move.wid] else {
1275
+ drifted.append(move)
1276
+ continue
1277
+ }
1278
+ let dx = abs(actual.origin.x - move.frame.origin.x)
1279
+ let dy = abs(actual.origin.y - move.frame.origin.y)
1280
+ let dw = abs(actual.width - move.frame.width)
1281
+ let dh = abs(actual.height - move.frame.height)
1282
+ if dx > tolerance || dy > tolerance || dw > tolerance || dh > tolerance {
1283
+ diag.info("[verify] wid \(move.wid) drifted: target \(move.frame) actual \(actual) (dx=\(Int(dx)) dy=\(Int(dy)) dw=\(Int(dw)) dh=\(Int(dh)))")
1284
+ drifted.append(move)
1285
+ }
1286
+ }
1287
+ return drifted
1288
+ }
1289
+
1290
+ /// Raise and focus a single window by its CGWindowID.
1291
+ static func focusWindow(wid: UInt32, pid: Int32) {
1292
+ let appRef = AXUIElementCreateApplication(pid)
1293
+ var windowsRef: CFTypeRef?
1294
+ guard AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef) == .success,
1295
+ let axWindows = windowsRef as? [AXUIElement] else { return }
1296
+
1297
+ for axWin in axWindows {
1298
+ var windowId: CGWindowID = 0
1299
+ if _AXUIElementGetWindow(axWin, &windowId) == .success, windowId == wid {
1300
+ AXUIElementPerformAction(axWin, kAXRaiseAction as CFString)
1301
+ AXUIElementSetAttributeValue(axWin, kAXMainAttribute as CFString, kCFBooleanTrue)
1302
+ break
1303
+ }
1304
+ }
1305
+
1306
+ if let app = NSRunningApplication(processIdentifier: pid) {
1307
+ app.activate()
1308
+ }
1309
+ }
1310
+
1311
+ /// Move AND raise windows in a single CG+AX pass (avoids duplicate lookups).
1312
+ /// Does not reactivate lattices at the end — caller controls that.
1313
+ static func batchMoveAndRaiseWindows(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)]) {
1314
+ guard !moves.isEmpty else { return }
1315
+ let diag = DiagnosticLog.shared
1316
+
1317
+ var byPid: [Int32: [(wid: UInt32, target: CGRect)]] = [:]
1318
+ for move in moves {
1319
+ byPid[move.pid, default: []].append((wid: move.wid, target: move.frame))
1320
+ }
1321
+
1322
+ var processed = 0
1323
+ var activatedPids = Set<Int32>()
1324
+
1325
+ // Freeze screen rendering for smooth batch moves
1326
+ let cid = _SLSMainConnectionID?()
1327
+ if let cid { _ = _SLSDisableUpdate?(cid) }
1328
+
1329
+ for (pid, windowMoves) in byPid {
1330
+ let appRef = AXUIElementCreateApplication(pid)
1331
+
1332
+ // Disable enhanced UI — breaks macOS tile lock so resize works
1333
+ AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, false as CFTypeRef)
1334
+
1335
+ var windowsRef: CFTypeRef?
1336
+ let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1337
+ guard err == .success, let axWindows = windowsRef as? [AXUIElement] else { continue }
1338
+
1339
+ // Build wid → AXUIElement map using _AXUIElementGetWindow
1340
+ var axByWid: [UInt32: AXUIElement] = [:]
1341
+ for axWin in axWindows {
1342
+ var windowId: CGWindowID = 0
1343
+ if _AXUIElementGetWindow(axWin, &windowId) == .success {
1344
+ axByWid[windowId] = axWin
1345
+ }
1346
+ }
1347
+
1348
+ for wm in windowMoves {
1349
+ guard let axWin = axByWid[wm.wid] else { continue }
1350
+
1351
+ var newPos = CGPoint(x: wm.target.origin.x, y: wm.target.origin.y)
1352
+ var newSize = CGSize(width: wm.target.width, height: wm.target.height)
1353
+
1354
+ // Size → Position → Size (same pattern as single-window tiler)
1355
+ if let sv = AXValueCreate(.cgSize, &newSize) {
1356
+ AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
1357
+ }
1358
+ if let pv = AXValueCreate(.cgPoint, &newPos) {
1359
+ AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
1360
+ }
1361
+ if let sv = AXValueCreate(.cgSize, &newSize) {
1362
+ AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
1363
+ }
1364
+
1365
+ // Raise
1366
+ AXUIElementPerformAction(axWin, kAXRaiseAction as CFString)
1367
+ AXUIElementSetAttributeValue(axWin, kAXMainAttribute as CFString, kCFBooleanTrue)
1368
+
1369
+ processed += 1
1370
+ }
1371
+
1372
+ // Re-enable enhanced UI
1373
+ AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, true as CFTypeRef)
1374
+
1375
+ // Activate each app once so its windows come to front
1376
+ if !activatedPids.contains(pid) {
1377
+ if let app = NSRunningApplication(processIdentifier: pid) {
1378
+ app.activate()
1379
+ activatedPids.insert(pid)
1380
+ }
1381
+ }
1382
+ }
1383
+
1384
+ // Unfreeze screen rendering
1385
+ if let cid { _ = _SLSReenableUpdate?(cid) }
1386
+ diag.success("batchMoveAndRaiseWindows: processed \(processed)/\(moves.count) windows")
1387
+ }
1388
+
1389
+ // MARK: - Grid Layout Strategy
1390
+
1391
+ /// Optimal grid shapes for common window counts.
1392
+ /// Returns array of column counts per row (top row first).
1393
+ /// e.g. 5 → [3, 2] means 3 on top row, 2 on bottom row.
1394
+ static func gridShape(for count: Int) -> [Int] {
1395
+ switch count {
1396
+ case 1: return [1]
1397
+ case 2: return [2]
1398
+ case 3: return [3]
1399
+ case 4: return [2, 2]
1400
+ case 5: return [3, 2]
1401
+ case 6: return [3, 3]
1402
+ case 7: return [4, 3]
1403
+ case 8: return [4, 4]
1404
+ case 9: return [3, 3, 3]
1405
+ case 10: return [5, 5]
1406
+ case 11: return [4, 4, 3]
1407
+ case 12: return [4, 4, 4]
1408
+ default:
1409
+ // General: bias toward more columns (landscape screens)
1410
+ let cols = Int(ceil(sqrt(Double(count) * 1.5)))
1411
+ var rows: [Int] = []
1412
+ var remaining = count
1413
+ while remaining > 0 {
1414
+ rows.append(min(cols, remaining))
1415
+ remaining -= cols
1416
+ }
1417
+ return rows
1418
+ }
1419
+ }
1420
+
1421
+ /// Compute grid slot rects in AX coordinates (top-left origin) for N windows
1422
+ static func computeGridSlots(count: Int, screen: NSScreen) -> [CGRect] {
1423
+ guard count > 0 else { return [] }
1424
+ let visible = screen.visibleFrame
1425
+ guard let primaryScreen = NSScreen.screens.first else { return [] }
1426
+ let primaryHeight = primaryScreen.frame.height
1427
+
1428
+ // AX Y of visible top edge
1429
+ let axTop = primaryHeight - visible.maxY
1430
+ let shape = gridShape(for: count)
1431
+ let rowCount = shape.count
1432
+ let rowH = visible.height / CGFloat(rowCount)
1433
+
1434
+ var slots: [CGRect] = []
1435
+ for (row, cols) in shape.enumerated() {
1436
+ let colW = visible.width / CGFloat(cols)
1437
+ let axY = axTop + CGFloat(row) * rowH
1438
+ for col in 0..<cols {
1439
+ let x = visible.origin.x + CGFloat(col) * colW
1440
+ slots.append(CGRect(x: x, y: axY, width: colW, height: rowH))
1441
+ }
1442
+ }
1443
+ return slots
1444
+ }
1445
+
1446
+ /// Raise multiple windows and arrange in smart grid — single CG query, single AX query per process
1447
+ static func batchRaiseAndDistribute(windows: [(wid: UInt32, pid: Int32)]) {
1448
+ guard !windows.isEmpty else { return }
1449
+ let diag = DiagnosticLog.shared
1450
+
1451
+ // Find screen from first window
1452
+ guard let firstFrame = cgWindowFrame(wid: windows[0].wid) else {
1453
+ diag.warn("batchRaiseAndDistribute: no frame for first window wid=\(windows[0].wid)")
1454
+ return
1455
+ }
1456
+ let screen = NSScreen.screens.first(where: {
1457
+ $0.frame.contains(NSPoint(x: firstFrame.midX, y: firstFrame.midY))
1458
+ }) ?? NSScreen.main ?? NSScreen.screens[0]
1459
+
1460
+ let visible = screen.visibleFrame
1461
+ let screenFrame = screen.frame
1462
+ let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
1463
+ let shape = gridShape(for: windows.count)
1464
+ let desc = shape.map(String.init).joined(separator: "+")
1465
+
1466
+ diag.info("Grid layout: \(windows.count) windows → [\(desc)]")
1467
+ diag.info(" Screen: \(screen.localizedName) \(Int(screenFrame.width))x\(Int(screenFrame.height))")
1468
+ diag.info(" Visible: origin=(\(Int(visible.origin.x)),\(Int(visible.origin.y))) size=\(Int(visible.width))x\(Int(visible.height))")
1469
+ diag.info(" Primary height: \(Int(primaryHeight))")
1470
+
1471
+ // Pre-compute all target slots
1472
+ let slots = computeGridSlots(count: windows.count, screen: screen)
1473
+ guard slots.count == windows.count else {
1474
+ diag.warn(" Slot count mismatch: \(slots.count) slots for \(windows.count) windows")
1475
+ return
1476
+ }
1477
+
1478
+ for (i, slot) in slots.enumerated() {
1479
+ diag.info(" Slot \(i): x=\(Int(slot.origin.x)) y=\(Int(slot.origin.y)) w=\(Int(slot.width)) h=\(Int(slot.height))")
1480
+ }
1481
+
1482
+ // Single CG query for frame lookup
1483
+ let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
1484
+ var cgFrames: [UInt32: CGRect] = [:]
1485
+ var cgNames: [UInt32: String] = [:]
1486
+ for info in windowList {
1487
+ guard let num = info[kCGWindowNumber as String] as? UInt32,
1488
+ let dict = info[kCGWindowBounds as String] as? NSDictionary else { continue }
1489
+ var rect = CGRect.zero
1490
+ if CGRectMakeWithDictionaryRepresentation(dict, &rect) { cgFrames[num] = rect }
1491
+ cgNames[num] = info[kCGWindowOwnerName as String] as? String
1492
+ }
1493
+
1494
+ // Log before frames
1495
+ for (i, win) in windows.enumerated() {
1496
+ let app = cgNames[win.wid] ?? "?"
1497
+ if let cg = cgFrames[win.wid] {
1498
+ diag.info(" Before[\(i)] wid=\(win.wid) \(app): x=\(Int(cg.origin.x)) y=\(Int(cg.origin.y)) w=\(Int(cg.width)) h=\(Int(cg.height))")
1499
+ } else {
1500
+ diag.warn(" Before[\(i)] wid=\(win.wid) \(app): NO CG FRAME")
1501
+ }
1502
+ }
1503
+
1504
+ // Group by pid for AX queries, keep slot mapping
1505
+ var widToSlot: [UInt32: Int] = [:]
1506
+ for (i, win) in windows.enumerated() { widToSlot[win.wid] = i }
1507
+
1508
+ var byPid: [Int32: [(wid: UInt32, target: CGRect)]] = [:]
1509
+ for (i, win) in windows.enumerated() {
1510
+ byPid[win.pid, default: []].append((wid: win.wid, target: slots[i]))
1511
+ }
1512
+
1513
+ struct AXWin { let el: AXUIElement; let pos: CGPoint; let size: CGSize }
1514
+
1515
+ // Pass 1: Move all windows to target positions (no raise yet)
1516
+ var moved = 0
1517
+ var failed: [UInt32] = []
1518
+ var resolvedAXElements: [(slotIdx: Int, el: AXUIElement)] = [] // for raise pass
1519
+
1520
+ for (pid, windowMoves) in byPid {
1521
+ let appRef = AXUIElementCreateApplication(pid)
1522
+ var windowsRef: CFTypeRef?
1523
+ let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1524
+ guard err == .success, let axWindows = windowsRef as? [AXUIElement] else {
1525
+ diag.warn(" AX query failed for pid=\(pid) err=\(err.rawValue)")
1526
+ failed.append(contentsOf: windowMoves.map(\.wid))
1527
+ continue
1528
+ }
1529
+
1530
+ var axCache: [AXWin] = []
1531
+ for axWin in axWindows {
1532
+ var posRef: CFTypeRef?; var sizeRef: CFTypeRef?
1533
+ AXUIElementCopyAttributeValue(axWin, kAXPositionAttribute as CFString, &posRef)
1534
+ AXUIElementCopyAttributeValue(axWin, kAXSizeAttribute as CFString, &sizeRef)
1535
+ guard let pv = posRef, let sv = sizeRef else { continue }
1536
+ var pos = CGPoint.zero; var size = CGSize.zero
1537
+ AXValueGetValue(pv as! AXValue, .cgPoint, &pos)
1538
+ AXValueGetValue(sv as! AXValue, .cgSize, &size)
1539
+ axCache.append(AXWin(el: axWin, pos: pos, size: size))
1540
+ }
1541
+
1542
+ for wm in windowMoves {
1543
+ guard let cgRect = cgFrames[wm.wid] else {
1544
+ diag.warn(" wid=\(wm.wid): no CG frame, skipping")
1545
+ failed.append(wm.wid)
1546
+ continue
1547
+ }
1548
+ guard let ax = axCache.first(where: {
1549
+ abs(cgRect.origin.x - $0.pos.x) < 2 && abs(cgRect.origin.y - $0.pos.y) < 2 &&
1550
+ abs(cgRect.width - $0.size.width) < 2 && abs(cgRect.height - $0.size.height) < 2
1551
+ }) else {
1552
+ diag.warn(" wid=\(wm.wid): CG frame (\(Int(cgRect.origin.x)),\(Int(cgRect.origin.y)) \(Int(cgRect.width))x\(Int(cgRect.height))) — no AX match among \(axCache.count) AX windows")
1553
+ for (j, axw) in axCache.enumerated() {
1554
+ diag.info(" AX[\(j)]: pos=(\(Int(axw.pos.x)),\(Int(axw.pos.y))) size=\(Int(axw.size.width))x\(Int(axw.size.height))")
1555
+ }
1556
+ failed.append(wm.wid)
1557
+ continue
1558
+ }
1559
+
1560
+ let slotIdx = widToSlot[wm.wid] ?? -1
1561
+ // Move only — raise comes later
1562
+ var newPos = CGPoint(x: wm.target.origin.x, y: wm.target.origin.y)
1563
+ var newSize = CGSize(width: wm.target.width, height: wm.target.height)
1564
+ let posOk = AXValueCreate(.cgPoint, &newPos).map {
1565
+ AXUIElementSetAttributeValue(ax.el, kAXPositionAttribute as CFString, $0)
1566
+ }
1567
+ let sizeOk = AXValueCreate(.cgSize, &newSize).map {
1568
+ AXUIElementSetAttributeValue(ax.el, kAXSizeAttribute as CFString, $0)
1569
+ }
1570
+ diag.info(" Move[\(slotIdx)] wid=\(wm.wid): target=(\(Int(wm.target.origin.x)),\(Int(wm.target.origin.y))) \(Int(wm.target.width))x\(Int(wm.target.height)) posErr=\(posOk?.rawValue ?? -1) sizeErr=\(sizeOk?.rawValue ?? -1)")
1571
+ resolvedAXElements.append((slotIdx: slotIdx, el: ax.el))
1572
+ moved += 1
1573
+ }
1574
+ }
1575
+
1576
+ // Pass 2: Raise all windows in slot order so they all come to front
1577
+ // Sort by slot index so the layout order is predictable
1578
+ resolvedAXElements.sort { $0.slotIdx < $1.slotIdx }
1579
+ for item in resolvedAXElements {
1580
+ AXUIElementPerformAction(item.el, kAXRaiseAction as CFString)
1581
+ }
1582
+ diag.info(" Raised \(resolvedAXElements.count) windows in slot order")
1583
+
1584
+ // Pass 3: Activate all apps so windows come to front of other apps
1585
+ var activatedPids = Set<Int32>()
1586
+ for win in windows {
1587
+ if !activatedPids.contains(win.pid) {
1588
+ if let app = NSRunningApplication(processIdentifier: win.pid) { app.activate() }
1589
+ activatedPids.insert(win.pid)
1590
+ }
1591
+ }
1592
+
1593
+ if !failed.isEmpty {
1594
+ diag.warn("batchRaiseAndDistribute: failed wids=\(failed)")
1595
+ }
1596
+ diag.success("batchRaiseAndDistribute: moved \(moved)/\(windows.count) [\(desc) grid]")
1597
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
1598
+ NSApp.activate(ignoringOtherApps: true)
1599
+ }
1600
+ }
1601
+
1602
+ /// Batch restore windows to saved frames (single CG query)
1603
+ static func batchRestoreWindows(_ restores: [(wid: UInt32, pid: Int32, frame: WindowFrame)]) {
1604
+ let moves = restores.map { (wid: $0.wid, pid: $0.pid,
1605
+ frame: CGRect(x: $0.frame.x, y: $0.frame.y,
1606
+ width: $0.frame.w, height: $0.frame.h)) }
1607
+ batchMoveWindows(moves)
1608
+ }
1609
+
1610
+ /// Restore a window to a saved frame (CG coordinates: top-left origin)
1611
+ static func restoreWindowFrame(wid: UInt32, pid: Int32, frame: WindowFrame) {
1612
+ guard let axWindow = findAXWindowByFrame(wid: wid, pid: pid) else {
1613
+ DiagnosticLog.shared.warn("restoreWindowFrame: couldn't match AX window for wid=\(wid)")
1614
+ return
1615
+ }
1616
+ var newPos = CGPoint(x: frame.x, y: frame.y)
1617
+ var newSize = CGSize(width: frame.w, height: frame.h)
1618
+ if let posValue = AXValueCreate(.cgPoint, &newPos) {
1619
+ AXUIElementSetAttributeValue(axWindow, kAXPositionAttribute as CFString, posValue)
1620
+ }
1621
+ if let sizeValue = AXValueCreate(.cgSize, &newSize) {
1622
+ AXUIElementSetAttributeValue(axWindow, kAXSizeAttribute as CFString, sizeValue)
1623
+ }
1624
+ DiagnosticLog.shared.success("restoreWindowFrame: restored wid=\(wid)")
1625
+ }
1626
+
1627
+ /// Find the AX window element for a given CG window ID by matching frames
1628
+ static func findAXWindowByFrame(wid: UInt32, pid: Int32) -> AXUIElement? {
1629
+ // Get CG frame for the window
1630
+ guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return nil }
1631
+
1632
+ var cgRect = CGRect.zero
1633
+ for info in windowList {
1634
+ if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
1635
+ let dict = info[kCGWindowBounds as String] as? NSDictionary {
1636
+ CGRectMakeWithDictionaryRepresentation(dict, &cgRect)
1637
+ break
1638
+ }
1639
+ }
1640
+ guard cgRect.width > 0 else { return nil }
1641
+
1642
+ // Find AX window with matching frame
1643
+ let appRef = AXUIElementCreateApplication(pid)
1644
+ var windowsRef: CFTypeRef?
1645
+ let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1646
+ guard err == .success, let windows = windowsRef as? [AXUIElement] else { return nil }
1647
+
1648
+ for win in windows {
1649
+ var posRef: CFTypeRef?
1650
+ var sizeRef: CFTypeRef?
1651
+ AXUIElementCopyAttributeValue(win, kAXPositionAttribute as CFString, &posRef)
1652
+ AXUIElementCopyAttributeValue(win, kAXSizeAttribute as CFString, &sizeRef)
1653
+ guard let pv = posRef, let sv = sizeRef else { continue }
1654
+
1655
+ var pos = CGPoint.zero
1656
+ var size = CGSize.zero
1657
+ AXValueGetValue(pv as! AXValue, .cgPoint, &pos)
1658
+ AXValueGetValue(sv as! AXValue, .cgSize, &size)
1659
+
1660
+ if abs(cgRect.origin.x - pos.x) < 2 && abs(cgRect.origin.y - pos.y) < 2 &&
1661
+ abs(cgRect.width - size.width) < 2 && abs(cgRect.height - size.height) < 2 {
1662
+ return win
1663
+ }
1664
+ }
1665
+ return nil
1666
+ }
1667
+
1668
+ // MARK: - Any-App Tiling via Accessibility
1669
+
1670
+ /// Tile the frontmost window of any app to a position using AX API.
1671
+ /// Works for any application (Finder, Chrome, etc.), not just terminals.
1672
+ static func tileFrontmostViaAX(to position: TilePosition) {
1673
+ guard let frontApp = NSWorkspace.shared.frontmostApplication,
1674
+ frontApp.bundleIdentifier != "com.arach.lattices" else { return }
1675
+
1676
+ let appRef = AXUIElementCreateApplication(frontApp.processIdentifier)
1677
+ var focusedRef: CFTypeRef?
1678
+ guard AXUIElementCopyAttributeValue(appRef, kAXFocusedWindowAttribute as CFString, &focusedRef) == .success,
1679
+ let axWindow = focusedRef else { return }
1680
+ let win = axWindow as! AXUIElement
1681
+
1682
+ let screen = screenForAXWindow(win)
1683
+ let target = tileFrame(for: position, on: screen)
1684
+
1685
+ // Disable enhanced UI on the APP element (not window) — breaks macOS tile lock
1686
+ AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, false as CFTypeRef)
1687
+
1688
+ // Freeze screen rendering so the size→position→size steps aren't visible
1689
+ let cid = _SLSMainConnectionID?()
1690
+ if let cid { _ = _SLSDisableUpdate?(cid) }
1691
+
1692
+ // Size → Position → Size (same pattern as Rectangle and rift)
1693
+ var pos = CGPoint(x: target.origin.x, y: target.origin.y)
1694
+ var size = CGSize(width: target.width, height: target.height)
1695
+
1696
+ if let sv = AXValueCreate(.cgSize, &size) {
1697
+ AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
1698
+ }
1699
+ if let pv = AXValueCreate(.cgPoint, &pos) {
1700
+ AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
1701
+ }
1702
+ if let sv = AXValueCreate(.cgSize, &size) {
1703
+ AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
1704
+ }
1705
+
1706
+ // Unfreeze screen rendering
1707
+ if let cid { _ = _SLSReenableUpdate?(cid) }
1708
+
1709
+ // Re-enable enhanced UI
1710
+ AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, true as CFTypeRef)
1711
+ }
1712
+
1713
+ /// Find which NSScreen contains a given AX window (nearest if center is off-screen)
1714
+ private static func screenForAXWindow(_ win: AXUIElement) -> NSScreen {
1715
+ var posRef: CFTypeRef?
1716
+ var sizeRef: CFTypeRef?
1717
+ AXUIElementCopyAttributeValue(win, kAXPositionAttribute as CFString, &posRef)
1718
+ AXUIElementCopyAttributeValue(win, kAXSizeAttribute as CFString, &sizeRef)
1719
+
1720
+ var pos = CGPoint.zero
1721
+ var size = CGSize.zero
1722
+ if let pv = posRef { AXValueGetValue(pv as! AXValue, .cgPoint, &pos) }
1723
+ if let sv = sizeRef { AXValueGetValue(sv as! AXValue, .cgSize, &size) }
1724
+
1725
+ let primaryH = NSScreen.screens.first?.frame.height ?? 1080
1726
+ let cx = pos.x + size.width / 2
1727
+ let cy = primaryH - (pos.y + size.height / 2)
1728
+ let pt = NSPoint(x: cx, y: cy)
1729
+
1730
+ return NSScreen.screens.first(where: { $0.frame.contains(pt) })
1731
+ ?? NSScreen.screens.min(by: {
1732
+ hypot(cx - $0.frame.midX, cy - $0.frame.midY) <
1733
+ hypot(cx - $1.frame.midX, cy - $1.frame.midY)
1734
+ })
1735
+ ?? NSScreen.main
1736
+ ?? NSScreen.screens[0]
1737
+ }
1738
+
1739
+ // MARK: - Private
1740
+
1741
+ private static func tileAppleScript(app: String, tag: String, bounds: (Int, Int, Int, Int)) {
1742
+ let (x1, y1, x2, y2) = bounds
1743
+ let script = """
1744
+ tell application "\(app)"
1745
+ repeat with w in windows
1746
+ if name of w contains "\(tag)" then
1747
+ set bounds of w to {\(x1), \(y1), \(x2), \(y2)}
1748
+ set index of w to 1
1749
+ exit repeat
1750
+ end if
1751
+ end repeat
1752
+ end tell
1753
+ """
1754
+ runScript(script)
1755
+ }
1756
+
1757
+ private static func tileFrontmost(bounds: (Int, Int, Int, Int)) {
1758
+ let (x1, y1, x2, y2) = bounds
1759
+ let script = """
1760
+ tell application "System Events"
1761
+ set frontApp to name of first application process whose frontmost is true
1762
+ end tell
1763
+ tell application frontApp
1764
+ set bounds of front window to {\(x1), \(y1), \(x2), \(y2)}
1765
+ end tell
1766
+ """
1767
+ runScript(script)
1768
+ }
1769
+
1770
+ private static func runScript(_ script: String) {
1771
+ let task = Process()
1772
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
1773
+ task.arguments = ["-e", script]
1774
+ task.standardOutput = FileHandle.nullDevice
1775
+ task.standardError = FileHandle.nullDevice
1776
+ try? task.run()
1777
+ }
1778
+ }