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