@lattices/cli 0.4.13 → 0.5.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 (180) hide show
  1. package/README.md +5 -7
  2. package/apps/mac/Info.plist +2 -2
  3. package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
  4. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/bin/lattices-app.ts +110 -17
  6. package/bin/lattices-build +125 -0
  7. package/bin/lattices-dev +89 -16
  8. package/bin/lattices.ts +977 -16
  9. package/docs/agents.md +81 -4
  10. package/docs/ai-chat-ux-review.md +416 -0
  11. package/docs/api.md +135 -3
  12. package/docs/app.md +30 -8
  13. package/docs/config.md +4 -0
  14. package/docs/mouse-gestures.md +191 -63
  15. package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
  16. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  17. package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
  18. package/docs/reference/dewey.config.ts +2 -2
  19. package/docs/release.md +171 -0
  20. package/docs/repo-structure.md +4 -5
  21. package/docs/voice.md +11 -27
  22. package/package.json +9 -10
  23. package/apps/mac/Package.swift +0 -27
  24. package/apps/mac/Sources/AppShell/App.swift +0 -26
  25. package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
  26. package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
  27. package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
  28. package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
  29. package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
  30. package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
  31. package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
  32. package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
  33. package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
  34. package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
  35. package/apps/mac/Sources/AppShell/MainView.swift +0 -847
  36. package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
  37. package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
  38. package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
  39. package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
  40. package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
  41. package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
  42. package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
  43. package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
  44. package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
  45. package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
  46. package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
  47. package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
  48. package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
  49. package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
  50. package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
  51. package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
  52. package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
  53. package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
  54. package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
  55. package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
  56. package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
  57. package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
  58. package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
  59. package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
  60. package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
  61. package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
  62. package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
  63. package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
  64. package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
  65. package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
  66. package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
  67. package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
  68. package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
  69. package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
  70. package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
  71. package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
  72. package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
  73. package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
  74. package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
  75. package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
  76. package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
  77. package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
  78. package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
  79. package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
  80. package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
  81. package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
  82. package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
  83. package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
  84. package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
  85. package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
  86. package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
  87. package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
  88. package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
  89. package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
  90. package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
  91. package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
  92. package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
  93. package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
  94. package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
  95. package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
  96. package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
  97. package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
  98. package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
  99. package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2271
  100. package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
  101. package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
  102. package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
  103. package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
  104. package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
  105. package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
  106. package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
  107. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
  108. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
  109. package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
  110. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
  111. package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
  112. package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
  113. package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
  114. package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
  115. package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
  116. package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
  117. package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
  118. package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
  119. package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
  120. package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
  121. package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
  122. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
  123. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
  124. package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
  125. package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
  126. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
  127. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
  128. package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
  129. package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
  130. package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
  131. package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
  132. package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
  133. package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
  134. package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
  135. package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
  136. package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
  137. package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
  138. package/apps/mac/Sources/Core/System/Capability.swift +0 -79
  139. package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
  140. package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
  141. package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
  142. package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
  143. package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
  144. package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
  145. package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
  146. package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
  147. package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
  148. package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
  149. package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
  150. package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
  151. package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
  152. package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
  153. package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
  154. package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
  155. package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
  156. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
  157. package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
  158. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
  159. package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
  160. package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
  161. package/apps/mac/Sources/UI/ActionRow.swift +0 -78
  162. package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
  163. package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
  164. package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
  165. package/apps/mac/Sources/UI/Theme.swift +0 -164
  166. package/apps/mac/Tests/StageDragTests.swift +0 -333
  167. package/apps/mac/Tests/StageJoinTests.swift +0 -313
  168. package/apps/mac/Tests/StageManagerTests.swift +0 -280
  169. package/apps/mac/Tests/StageTileTests.swift +0 -353
  170. package/swift/Package.swift +0 -20
  171. package/swift/Sources/DeckKit/DeckAction.swift +0 -51
  172. package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
  173. package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
  174. package/swift/Sources/DeckKit/DeckHost.swift +0 -7
  175. package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
  176. package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
  177. package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
  178. package/swift/Sources/DeckKit/DeckValue.swift +0 -93
  179. package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
  180. package/swift/Tests/DeckKitTests/DeckKitTests.swift +0 -286
@@ -1,2222 +0,0 @@
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 typealias SLSOrderWindowFunc = @convention(c) (Int32, UInt32, Int32, UInt32) -> CGError
29
-
30
- private let _SLSOrderWindow: SLSOrderWindowFunc? = {
31
- guard let sl = skyLight, let sym = dlsym(sl, "SLSOrderWindow") ?? dlsym(sl, "CGSOrderWindow") else { return nil }
32
- return unsafeBitCast(sym, to: SLSOrderWindowFunc.self)
33
- }()
34
-
35
- private let _SLSDisableUpdate: SLSDisableUpdateFunc? = {
36
- guard let sl = skyLight, let sym = dlsym(sl, "SLSDisableUpdate") else { return nil }
37
- return unsafeBitCast(sym, to: SLSDisableUpdateFunc.self)
38
- }()
39
-
40
- private let _SLSReenableUpdate: SLSReenableUpdateFunc? = {
41
- guard let sl = skyLight, let sym = dlsym(sl, "SLSReenableUpdate") else { return nil }
42
- return unsafeBitCast(sym, to: SLSReenableUpdateFunc.self)
43
- }()
44
-
45
- // MARK: - Window Highlight Overlay
46
-
47
- final class WindowHighlight {
48
- static let shared = WindowHighlight()
49
-
50
- private var overlayWindow: NSWindow?
51
- private var fadeTimer: Timer?
52
-
53
- /// Flash a green border overlay at the given screen frame
54
- func flash(frame: NSRect, duration: TimeInterval = 0.9) {
55
- dismiss()
56
-
57
- let inset: CGFloat = -6 // slightly larger than the window
58
- let expandedFrame = frame.insetBy(dx: inset, dy: inset)
59
-
60
- let window = NSWindow(
61
- contentRect: expandedFrame,
62
- styleMask: .borderless,
63
- backing: .buffered,
64
- defer: false
65
- )
66
- window.isOpaque = false
67
- window.backgroundColor = .clear
68
- window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
69
- window.hasShadow = false
70
- window.ignoresMouseEvents = true
71
- window.collectionBehavior = [.canJoinAllSpaces, .stationary]
72
-
73
- let borderView = HighlightBorderView(frame: NSRect(origin: .zero, size: expandedFrame.size))
74
- window.contentView = borderView
75
-
76
- window.alphaValue = 0
77
- window.orderFrontRegardless()
78
-
79
- overlayWindow = window
80
-
81
- // Fade in
82
- NSAnimationContext.runAnimationGroup { ctx in
83
- ctx.duration = 0.15
84
- window.animator().alphaValue = 1.0
85
- }
86
-
87
- // Schedule fade out
88
- fadeTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
89
- self?.fadeOut()
90
- }
91
- }
92
-
93
- func dismiss() {
94
- fadeTimer?.invalidate()
95
- fadeTimer = nil
96
- overlayWindow?.orderOut(nil)
97
- overlayWindow = nil
98
- }
99
-
100
- private func fadeOut() {
101
- guard let window = overlayWindow else { return }
102
- NSAnimationContext.runAnimationGroup({ ctx in
103
- ctx.duration = 0.3
104
- window.animator().alphaValue = 0
105
- }, completionHandler: { [weak self] in
106
- self?.dismiss()
107
- })
108
- }
109
- }
110
-
111
- private class HighlightBorderView: NSView {
112
- override func draw(_ dirtyRect: NSRect) {
113
- let borderWidth: CGFloat = 3
114
- let cornerRadius: CGFloat = 12
115
-
116
- // Outer glow
117
- let glowRect = bounds.insetBy(dx: 1, dy: 1)
118
- let glowPath = NSBezierPath(roundedRect: glowRect, xRadius: cornerRadius + 2, yRadius: cornerRadius + 2)
119
- glowPath.lineWidth = borderWidth + 2
120
- NSColor(calibratedRed: 0.2, green: 0.9, blue: 0.4, alpha: 0.07).setStroke()
121
- glowPath.stroke()
122
-
123
- // Main border
124
- let rect = bounds.insetBy(dx: borderWidth / 2 + 2, dy: borderWidth / 2 + 2)
125
- let path = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius)
126
- path.lineWidth = borderWidth
127
- NSColor(calibratedRed: 0.2, green: 0.9, blue: 0.4, alpha: 0.58).setStroke()
128
- path.stroke()
129
-
130
- let innerRect = rect.insetBy(dx: 3, dy: 3)
131
- let innerPath = NSBezierPath(roundedRect: innerRect, xRadius: max(cornerRadius - 3, 6), yRadius: max(cornerRadius - 3, 6))
132
- innerPath.lineWidth = 1
133
- NSColor.white.withAlphaComponent(0.10).setStroke()
134
- innerPath.stroke()
135
- }
136
- }
137
-
138
- // MARK: - Grid Tiling
139
-
140
- /// Compute fractional (x, y, w, h) for a cell in a cols×rows grid.
141
- func tileGrid(cols: Int, rows: Int, col: Int, row: Int) -> (CGFloat, CGFloat, CGFloat, CGFloat) {
142
- let w = 1.0 / CGFloat(cols)
143
- let h = 1.0 / CGFloat(rows)
144
- return (CGFloat(col) * w, CGFloat(row) * h, w, h)
145
- }
146
-
147
- /// Parse a grid string like "grid:3x2:0,0" → fractional (x, y, w, h).
148
- func parseGridString(_ str: String) -> (CGFloat, CGFloat, CGFloat, CGFloat)? {
149
- GridPlacement.parse(str)?.fractions
150
- }
151
-
152
- enum TilePosition: String, CaseIterable, Identifiable {
153
- // 1x1
154
- case maximize = "maximize"
155
- case center = "center"
156
- // 2x1 (halves, full height)
157
- case left = "left"
158
- case right = "right"
159
- // 1x2 (halves, full width)
160
- case top = "top"
161
- case bottom = "bottom"
162
- // 2x2 (quarters)
163
- case topLeft = "top-left"
164
- case topRight = "top-right"
165
- case bottomLeft = "bottom-left"
166
- case bottomRight = "bottom-right"
167
- // 3x1 (thirds, full height)
168
- case leftThird = "left-third"
169
- case centerThird = "center-third"
170
- case rightThird = "right-third"
171
- // 3x2 (sixths)
172
- case topLeftThird = "top-left-third"
173
- case topCenterThird = "top-center-third"
174
- case topRightThird = "top-right-third"
175
- case bottomLeftThird = "bottom-left-third"
176
- case bottomCenterThird = "bottom-center-third"
177
- case bottomRightThird = "bottom-right-third"
178
- // 4x1 (fourths, full height)
179
- case firstFourth = "first-fourth"
180
- case secondFourth = "second-fourth"
181
- case thirdFourth = "third-fourth"
182
- case lastFourth = "last-fourth"
183
- // 4x2 (eighths)
184
- case topFirstFourth = "top-first-fourth"
185
- case topSecondFourth = "top-second-fourth"
186
- case topThirdFourth = "top-third-fourth"
187
- case topLastFourth = "top-last-fourth"
188
- case bottomFirstFourth = "bottom-first-fourth"
189
- case bottomSecondFourth = "bottom-second-fourth"
190
- case bottomThirdFourth = "bottom-third-fourth"
191
- case bottomLastFourth = "bottom-last-fourth"
192
- // Horizontal thirds / quarters
193
- case topThird = "top-third"
194
- case middleThird = "middle-third"
195
- case bottomThird = "bottom-third"
196
- case leftQuarter = "left-quarter"
197
- case rightQuarter = "right-quarter"
198
- case topQuarter = "top-quarter"
199
- case bottomQuarter = "bottom-quarter"
200
-
201
- var id: String { rawValue }
202
-
203
- var label: String {
204
- switch self {
205
- case .maximize: return "Max"
206
- case .center: return "Center"
207
- case .left: return "Left"
208
- case .right: return "Right"
209
- case .top: return "Top"
210
- case .bottom: return "Bottom"
211
- case .topLeft: return "Top Left"
212
- case .topRight: return "Top Right"
213
- case .bottomLeft: return "Bottom Left"
214
- case .bottomRight: return "Bottom Right"
215
- case .leftThird: return "Left ⅓"
216
- case .centerThird: return "Center ⅓"
217
- case .rightThird: return "Right ⅓"
218
- case .topLeftThird: return "Top Left ⅓"
219
- case .topCenterThird: return "Top Center ⅓"
220
- case .topRightThird: return "Top Right ⅓"
221
- case .bottomLeftThird: return "Bottom Left ⅓"
222
- case .bottomCenterThird: return "Bottom Center ⅓"
223
- case .bottomRightThird: return "Bottom Right ⅓"
224
- case .firstFourth: return "1st ¼"
225
- case .secondFourth: return "2nd ¼"
226
- case .thirdFourth: return "3rd ¼"
227
- case .lastFourth: return "4th ¼"
228
- case .topFirstFourth: return "Top 1st ¼"
229
- case .topSecondFourth: return "Top 2nd ¼"
230
- case .topThirdFourth: return "Top 3rd ¼"
231
- case .topLastFourth: return "Top 4th ¼"
232
- case .bottomFirstFourth: return "Bottom 1st ¼"
233
- case .bottomSecondFourth: return "Bottom 2nd ¼"
234
- case .bottomThirdFourth: return "Bottom 3rd ¼"
235
- case .bottomLastFourth: return "Bottom 4th ¼"
236
- case .topThird: return "Top ⅓"
237
- case .middleThird: return "Middle ⅓"
238
- case .bottomThird: return "Bottom ⅓"
239
- case .leftQuarter: return "Left ¼"
240
- case .rightQuarter: return "Right ¼"
241
- case .topQuarter: return "Top ¼"
242
- case .bottomQuarter: return "Bottom ¼"
243
- }
244
- }
245
-
246
- var icon: String {
247
- switch self {
248
- case .left: return "rectangle.lefthalf.filled"
249
- case .right: return "rectangle.righthalf.filled"
250
- case .top: return "rectangle.tophalf.filled"
251
- case .bottom: return "rectangle.bottomhalf.filled"
252
- case .topLeft: return "rectangle.inset.topleft.filled"
253
- case .topRight: return "rectangle.inset.topright.filled"
254
- case .bottomLeft: return "rectangle.inset.bottomleft.filled"
255
- case .bottomRight: return "rectangle.inset.bottomright.filled"
256
- case .maximize: return "rectangle.fill"
257
- case .center: return "rectangle.center.inset.filled"
258
- case .leftThird: return "rectangle.leadingthird.inset.filled"
259
- case .centerThird: return "rectangle.center.inset.filled"
260
- case .rightThird: return "rectangle.trailingthird.inset.filled"
261
- case .topThird: return "rectangle.topthird.inset.filled"
262
- case .middleThird: return "rectangle.center.inset.filled"
263
- case .bottomThird: return "rectangle.bottomthird.inset.filled"
264
- case .leftQuarter: return "rectangle.leadinghalf.inset.filled"
265
- case .rightQuarter:return "rectangle.trailinghalf.inset.filled"
266
- case .topQuarter: return "rectangle.tophalf.inset.filled"
267
- case .bottomQuarter:return "rectangle.bottomhalf.inset.filled"
268
- default: return "rectangle.split.3x3.fill"
269
- }
270
- }
271
-
272
- /// Returns (x, y, w, h) as fractions of screen
273
- var rect: (CGFloat, CGFloat, CGFloat, CGFloat) {
274
- switch self {
275
- // 1x1
276
- case .maximize: return (0, 0, 1.0, 1.0)
277
- case .center: return (0.15, 0.1, 0.7, 0.8)
278
- // 2x1
279
- case .left: return tileGrid(cols: 2, rows: 1, col: 0, row: 0)
280
- case .right: return tileGrid(cols: 2, rows: 1, col: 1, row: 0)
281
- // 1x2
282
- case .top: return tileGrid(cols: 1, rows: 2, col: 0, row: 0)
283
- case .bottom: return tileGrid(cols: 1, rows: 2, col: 0, row: 1)
284
- // 2x2
285
- case .topLeft: return tileGrid(cols: 2, rows: 2, col: 0, row: 0)
286
- case .topRight: return tileGrid(cols: 2, rows: 2, col: 1, row: 0)
287
- case .bottomLeft: return tileGrid(cols: 2, rows: 2, col: 0, row: 1)
288
- case .bottomRight: return tileGrid(cols: 2, rows: 2, col: 1, row: 1)
289
- // 3x1
290
- case .leftThird: return tileGrid(cols: 3, rows: 1, col: 0, row: 0)
291
- case .centerThird: return tileGrid(cols: 3, rows: 1, col: 1, row: 0)
292
- case .rightThird: return tileGrid(cols: 3, rows: 1, col: 2, row: 0)
293
- // 3x2
294
- case .topLeftThird: return tileGrid(cols: 3, rows: 2, col: 0, row: 0)
295
- case .topCenterThird: return tileGrid(cols: 3, rows: 2, col: 1, row: 0)
296
- case .topRightThird: return tileGrid(cols: 3, rows: 2, col: 2, row: 0)
297
- case .bottomLeftThird: return tileGrid(cols: 3, rows: 2, col: 0, row: 1)
298
- case .bottomCenterThird: return tileGrid(cols: 3, rows: 2, col: 1, row: 1)
299
- case .bottomRightThird: return tileGrid(cols: 3, rows: 2, col: 2, row: 1)
300
- // 4x1
301
- case .firstFourth: return tileGrid(cols: 4, rows: 1, col: 0, row: 0)
302
- case .secondFourth: return tileGrid(cols: 4, rows: 1, col: 1, row: 0)
303
- case .thirdFourth: return tileGrid(cols: 4, rows: 1, col: 2, row: 0)
304
- case .lastFourth: return tileGrid(cols: 4, rows: 1, col: 3, row: 0)
305
- // 4x2
306
- case .topFirstFourth: return tileGrid(cols: 4, rows: 2, col: 0, row: 0)
307
- case .topSecondFourth: return tileGrid(cols: 4, rows: 2, col: 1, row: 0)
308
- case .topThirdFourth: return tileGrid(cols: 4, rows: 2, col: 2, row: 0)
309
- case .topLastFourth: return tileGrid(cols: 4, rows: 2, col: 3, row: 0)
310
- case .bottomFirstFourth: return tileGrid(cols: 4, rows: 2, col: 0, row: 1)
311
- case .bottomSecondFourth: return tileGrid(cols: 4, rows: 2, col: 1, row: 1)
312
- case .bottomThirdFourth: return tileGrid(cols: 4, rows: 2, col: 2, row: 1)
313
- case .bottomLastFourth: return tileGrid(cols: 4, rows: 2, col: 3, row: 1)
314
- case .topThird: return tileGrid(cols: 1, rows: 3, col: 0, row: 0)
315
- case .middleThird: return tileGrid(cols: 1, rows: 3, col: 0, row: 1)
316
- case .bottomThird: return tileGrid(cols: 1, rows: 3, col: 0, row: 2)
317
- case .leftQuarter: return tileGrid(cols: 4, rows: 1, col: 0, row: 0)
318
- case .rightQuarter: return tileGrid(cols: 4, rows: 1, col: 3, row: 0)
319
- case .topQuarter: return tileGrid(cols: 1, rows: 4, col: 0, row: 0)
320
- case .bottomQuarter: return tileGrid(cols: 1, rows: 4, col: 0, row: 3)
321
- }
322
- }
323
- }
324
-
325
- // MARK: - Private CGS API for Spaces (loaded dynamically from SkyLight)
326
-
327
- struct SpaceInfo: Identifiable {
328
- let id: Int // CGS space ID
329
- let index: Int // 1-based index within its display
330
- let display: Int // 0-based display index
331
- let isCurrent: Bool
332
- }
333
-
334
- struct DisplaySpaces {
335
- let displayIndex: Int
336
- let displayId: String
337
- let spaces: [SpaceInfo]
338
- let currentSpaceId: Int
339
- }
340
-
341
- private enum CGS {
342
- // Use Int32 for CGS connection IDs (C `int`), UInt64 for space IDs
343
- typealias MainConnectionIDFunc = @convention(c) () -> Int32
344
- typealias GetActiveSpaceFunc = @convention(c) (Int32) -> UInt64
345
- typealias CopyManagedDisplaySpacesFunc = @convention(c) (Int32) -> CFArray
346
- typealias CopySpacesForWindowsFunc = @convention(c) (Int32, Int32, CFArray) -> CFArray
347
- typealias SetCurrentSpaceFunc = @convention(c) (Int32, CFString, UInt64) -> Void
348
-
349
- private static let handle = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY)
350
-
351
- static let mainConnectionID: MainConnectionIDFunc? = {
352
- guard let h = handle, let sym = dlsym(h, "CGSMainConnectionID") else { return nil }
353
- return unsafeBitCast(sym, to: MainConnectionIDFunc.self)
354
- }()
355
-
356
- static let getActiveSpace: GetActiveSpaceFunc? = {
357
- guard let h = handle, let sym = dlsym(h, "CGSGetActiveSpace") else { return nil }
358
- return unsafeBitCast(sym, to: GetActiveSpaceFunc.self)
359
- }()
360
-
361
- static let copyManagedDisplaySpaces: CopyManagedDisplaySpacesFunc? = {
362
- guard let h = handle, let sym = dlsym(h, "CGSCopyManagedDisplaySpaces") else { return nil }
363
- return unsafeBitCast(sym, to: CopyManagedDisplaySpacesFunc.self)
364
- }()
365
-
366
- static let copySpacesForWindows: CopySpacesForWindowsFunc? = {
367
- guard let h = handle, let sym = dlsym(h, "SLSCopySpacesForWindows") else { return nil }
368
- return unsafeBitCast(sym, to: CopySpacesForWindowsFunc.self)
369
- }()
370
-
371
- static let setCurrentSpace: SetCurrentSpaceFunc? = {
372
- guard let h = handle, let sym = dlsym(h, "SLSManagedDisplaySetCurrentSpace") else { return nil }
373
- return unsafeBitCast(sym, to: SetCurrentSpaceFunc.self)
374
- }()
375
-
376
- // Move windows between spaces
377
- typealias AddWindowsToSpacesFunc = @convention(c) (Int32, CFArray, CFArray) -> Void
378
- typealias RemoveWindowsFromSpacesFunc = @convention(c) (Int32, CFArray, CFArray) -> Void
379
-
380
- static let addWindowsToSpaces: AddWindowsToSpacesFunc? = {
381
- guard let h = handle else { return nil }
382
- guard let sym = dlsym(h, "CGSAddWindowsToSpaces") ?? dlsym(h, "SLSAddWindowsToSpaces") else { return nil }
383
- return unsafeBitCast(sym, to: AddWindowsToSpacesFunc.self)
384
- }()
385
-
386
- static let removeWindowsFromSpaces: RemoveWindowsFromSpacesFunc? = {
387
- guard let h = handle else { return nil }
388
- guard let sym = dlsym(h, "CGSRemoveWindowsFromSpaces") ?? dlsym(h, "SLSRemoveWindowsFromSpaces") else { return nil }
389
- return unsafeBitCast(sym, to: RemoveWindowsFromSpacesFunc.self)
390
- }()
391
- }
392
-
393
- enum WindowTiler {
394
- /// Whether CGS move-between-spaces APIs are available
395
- static var canMoveWindowsBetweenSpaces: Bool {
396
- CGS.addWindowsToSpaces != nil && CGS.removeWindowsFromSpaces != nil
397
- }
398
-
399
- /// Convert fractional rect to AppleScript bounds {left, top, right, bottom}
400
- /// AppleScript uses top-left origin; NSScreen uses bottom-left origin
401
- private static func appleScriptBounds(for position: TilePosition, screen: NSScreen? = nil) -> (Int, Int, Int, Int) {
402
- appleScriptBounds(for: position.rect, screen: screen)
403
- }
404
-
405
- private static func appleScriptBounds(for fractions: (CGFloat, CGFloat, CGFloat, CGFloat), screen: NSScreen? = nil) -> (Int, Int, Int, Int) {
406
- let targetScreen = screen ?? NSScreen.main
407
- guard let targetScreen else { return (0, 0, 960, 540) }
408
- let full = targetScreen.frame
409
- let visible = targetScreen.visibleFrame
410
-
411
- let visTop = Int(full.height - visible.maxY)
412
- let visLeft = Int(visible.minX)
413
- let visW = Int(visible.width)
414
- let visH = Int(visible.height)
415
-
416
- let (fx, fy, fw, fh) = fractions
417
- let x1 = visLeft + Int(CGFloat(visW) * fx)
418
- let y1 = visTop + Int(CGFloat(visH) * fy)
419
- let x2 = x1 + Int(CGFloat(visW) * fw)
420
- let y2 = y1 + Int(CGFloat(visH) * fh)
421
- return (x1, y1, x2, y2)
422
- }
423
-
424
- /// Get the main screen's frame (safe to call from main thread only).
425
- static func mainScreenFrame() -> CGRect {
426
- return NSScreen.main?.frame ?? NSScreen.screens.first?.frame ?? CGRect(x: 0, y: 0, width: 1920, height: 1080)
427
- }
428
-
429
- /// Compute AX-coordinate frame for a tile position on a given screen
430
- static func tileFrame(for position: TilePosition, on screen: NSScreen) -> CGRect {
431
- tileFrame(fractions: position.rect, on: screen)
432
- }
433
-
434
- /// Compute AX-coordinate frame for arbitrary fractional placement on a given screen.
435
- static func tileFrame(fractions: (CGFloat, CGFloat, CGFloat, CGFloat), on screen: NSScreen) -> CGRect {
436
- let visible = screen.visibleFrame
437
- guard let primary = NSScreen.screens.first else { return .zero }
438
- let primaryH = primary.frame.height
439
- let axTop = primaryH - visible.maxY
440
- let (fx, fy, fw, fh) = fractions
441
- return CGRect(
442
- x: visible.origin.x + visible.width * fx,
443
- y: axTop + visible.height * fy,
444
- width: visible.width * fw,
445
- height: visible.height * fh
446
- )
447
- }
448
-
449
- static func tileFrame(for placement: PlacementSpec, on screen: NSScreen) -> CGRect {
450
- tileFrame(fractions: placement.fractions, on: screen)
451
- }
452
-
453
- /// Compute AX-coordinate frame for a tile position within a raw display CGRect (CG/AX coords)
454
- static func tileFrame(for position: TilePosition, inDisplay displayRect: CGRect) -> CGRect {
455
- let (fx, fy, fw, fh) = position.rect
456
- return CGRect(
457
- x: displayRect.origin.x + displayRect.width * fx,
458
- y: displayRect.origin.y + displayRect.height * fy,
459
- width: displayRect.width * fw,
460
- height: displayRect.height * fh
461
- )
462
- }
463
-
464
- /// Compute AX-coordinate frame from fractional (x, y, w, h) within a raw display CGRect
465
- static func tileFrame(fractions: (CGFloat, CGFloat, CGFloat, CGFloat), inDisplay displayRect: CGRect) -> CGRect {
466
- let (fx, fy, fw, fh) = fractions
467
- return CGRect(
468
- x: displayRect.origin.x + displayRect.width * fx,
469
- y: displayRect.origin.y + displayRect.height * fy,
470
- width: displayRect.width * fw,
471
- height: displayRect.height * fh
472
- )
473
- }
474
-
475
- /// Tile a specific terminal window on a given screen.
476
- /// Fast path: DesktopModel → AX. Fallback: AX search → AppleScript last resort.
477
- static func tile(session: String, terminal: Terminal, to position: TilePosition, on screen: NSScreen) {
478
- tile(session: session, terminal: terminal, to: .tile(position), on: screen)
479
- }
480
-
481
- static func tile(session: String, terminal: Terminal, to placement: PlacementSpec, on screen: NSScreen) {
482
- let diag = DiagnosticLog.shared
483
- let t = diag.startTimed("tile: \(session) → \(placement.wireValue)")
484
-
485
- // Fast path: use DesktopModel cache → single AX move
486
- if let entry = DesktopModel.shared.windowForSession(session) {
487
- let frame = tileFrame(for: placement, on: screen)
488
- batchMoveAndRaiseWindows([(wid: entry.wid, pid: entry.pid, frame: frame)])
489
- diag.success("tile fast path (DesktopModel): \(session)")
490
- diag.finish(t)
491
- return
492
- }
493
-
494
- // AX fallback: search terminal windows by title tag
495
- let tag = Terminal.windowTag(for: session)
496
- if let (pid, axWindow) = findWindowViaAX(terminal: terminal, tag: tag) {
497
- let targetFrame = tileFrame(for: placement, on: screen)
498
- var newPos = CGPoint(x: targetFrame.origin.x, y: targetFrame.origin.y)
499
- var newSize = CGSize(width: targetFrame.width, height: targetFrame.height)
500
- let win = axWindow
501
- if let sv = AXValueCreate(.cgSize, &newSize) {
502
- AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
503
- }
504
- if let pv = AXValueCreate(.cgPoint, &newPos) {
505
- AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
506
- }
507
- if let sv = AXValueCreate(.cgSize, &newSize) {
508
- AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
509
- }
510
- if let pv = AXValueCreate(.cgPoint, &newPos) {
511
- AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
512
- }
513
- AXUIElementPerformAction(win, kAXRaiseAction as CFString)
514
- if let app = NSRunningApplication(processIdentifier: pid) { app.activate() }
515
- diag.success("tile AX fallback: \(session)")
516
- diag.finish(t)
517
- return
518
- }
519
-
520
- // AppleScript last resort (slow, single-monitor)
521
- diag.warn("tile AppleScript last resort: \(session)")
522
- let bounds = appleScriptBounds(for: placement.fractions, screen: screen)
523
- switch terminal {
524
- case .terminal:
525
- tileAppleScript(app: "Terminal", tag: tag, bounds: bounds)
526
- case .iterm2:
527
- tileAppleScript(app: "iTerm2", tag: tag, bounds: bounds)
528
- default:
529
- tileFrontmost(bounds: bounds)
530
- }
531
- diag.finish(t)
532
- }
533
-
534
- /// Tile a specific terminal window (found by lattices session tag) to a position.
535
- /// Uses the same fast path strategy as tile(session:terminal:to:on:) with main screen.
536
- static func tile(session: String, terminal: Terminal, to position: TilePosition) {
537
- tile(session: session, terminal: terminal, to: .tile(position))
538
- }
539
-
540
- static func tile(session: String, terminal: Terminal, to placement: PlacementSpec) {
541
- let screen = NSScreen.main ?? NSScreen.screens[0]
542
- tile(session: session, terminal: terminal, to: placement, on: screen)
543
- }
544
-
545
- /// Tile the frontmost window (works for any terminal)
546
- static func tileFrontmost(to position: TilePosition) {
547
- tileFrontmost(bounds: appleScriptBounds(for: position))
548
- }
549
-
550
- // MARK: - Spaces
551
-
552
- /// Get spaces organized by display
553
- static func getDisplaySpaces() -> [DisplaySpaces] {
554
- guard let mainConn = CGS.mainConnectionID,
555
- let copyManaged = CGS.copyManagedDisplaySpaces else { return [] }
556
-
557
- let cid = mainConn()
558
- guard let managed = copyManaged(cid) as? [[String: Any]] else { return [] }
559
-
560
- var result: [DisplaySpaces] = []
561
- for (displayIdx, display) in managed.enumerated() {
562
- let displayId = display["Display Identifier"] as? String ?? ""
563
- let rawSpaces = display["Spaces"] as? [[String: Any]] ?? []
564
- let currentDict = display["Current Space"] as? [String: Any]
565
- let currentId = currentDict?["id64"] as? Int ?? currentDict?["ManagedSpaceID"] as? Int ?? 0
566
-
567
- var spaces: [SpaceInfo] = []
568
- for (spaceIdx, space) in rawSpaces.enumerated() {
569
- let sid = space["id64"] as? Int ?? space["ManagedSpaceID"] as? Int ?? 0
570
- let type = space["type"] as? Int ?? 0
571
- if type == 0 {
572
- spaces.append(SpaceInfo(
573
- id: sid,
574
- index: spaceIdx + 1,
575
- display: displayIdx,
576
- isCurrent: sid == currentId
577
- ))
578
- }
579
- }
580
-
581
- result.append(DisplaySpaces(
582
- displayIndex: displayIdx,
583
- displayId: displayId,
584
- spaces: spaces,
585
- currentSpaceId: currentId
586
- ))
587
- }
588
- return result
589
- }
590
-
591
- /// Get the current active Space ID
592
- static func getCurrentSpace() -> Int {
593
- guard let mainConn = CGS.mainConnectionID, let getActive = CGS.getActiveSpace else { return 0 }
594
- return Int(getActive(mainConn()))
595
- }
596
-
597
- static func adjacentSpace(in spaces: [SpaceInfo], currentSpaceId: Int, offset: Int) -> SpaceInfo? {
598
- guard let currentIndex = spaces.firstIndex(where: { $0.id == currentSpaceId }) else { return nil }
599
- let targetIndex = currentIndex + offset
600
- guard spaces.indices.contains(targetIndex) else { return nil }
601
- return spaces[targetIndex]
602
- }
603
-
604
- private struct AdjacentSpaceContext {
605
- let point: CGPoint
606
- let display: DisplaySpaces
607
- let activeSpaceId: Int
608
- let currentSpaceId: Int
609
- let target: SpaceInfo?
610
- }
611
-
612
- private static func adjacentSpaceContext(offset: Int, from cgPoint: CGPoint? = nil) -> AdjacentSpaceContext? {
613
- let point = cgPoint ?? currentMouseCGPoint()
614
- guard let display = displaySpaces(containing: point) else { return nil }
615
- let activeSpaceId = getCurrentSpace()
616
- let currentSpaceId = resolvedCurrentSpaceId(for: display, activeSpaceId: activeSpaceId)
617
- let target = adjacentSpace(in: display.spaces, currentSpaceId: currentSpaceId, offset: offset)
618
- return AdjacentSpaceContext(
619
- point: point,
620
- display: display,
621
- activeSpaceId: activeSpaceId,
622
- currentSpaceId: currentSpaceId,
623
- target: target
624
- )
625
- }
626
-
627
- static func adjacentSpaceTarget(offset: Int, from cgPoint: CGPoint? = nil) -> SpaceInfo? {
628
- adjacentSpaceContext(offset: offset, from: cgPoint)?.target
629
- }
630
-
631
- @discardableResult
632
- static func switchToAdjacentSpace(offset: Int, from cgPoint: CGPoint? = nil) -> Bool {
633
- guard let context = adjacentSpaceContext(offset: offset, from: cgPoint) else {
634
- DiagnosticLog.shared.warn("switchToAdjacentSpace: no adjacent space for offset \(offset) from \(formatCGPoint(cgPoint ?? currentMouseCGPoint()))")
635
- return false
636
- }
637
-
638
- let spaces = context.display.spaces.map(\.id)
639
- let targetText = context.target.map { String($0.id) } ?? "none"
640
- DiagnosticLog.shared.info(
641
- "switchToAdjacentSpace: offset=\(offset) point=\(formatCGPoint(context.point)) displayId=\(context.display.displayId) active=\(context.activeSpaceId) displayCurrent=\(context.display.currentSpaceId) resolved=\(context.currentSpaceId) target=\(targetText) spaces=\(spaces)"
642
- )
643
-
644
- if let target = context.target {
645
- let switched = switchToSpace(spaceId: target.id)
646
- DiagnosticLog.shared.info("switchToAdjacentSpace: SkyLight \(switched ? "reached" : "missed") target \(target.id)")
647
- if switched {
648
- return true
649
- }
650
- }
651
-
652
- if getDisplaySpaces().count == 1 {
653
- if let finalSpaceId = switchToAdjacentSpaceViaSystemShortcut(
654
- offset: offset,
655
- displayId: context.display.displayId,
656
- initialSpaceId: context.currentSpaceId
657
- ) {
658
- if let target = context.target, finalSpaceId != target.id {
659
- DiagnosticLog.shared.info("switchToAdjacentSpace: system shortcut changed \(context.currentSpaceId) → \(finalSpaceId) (expected \(target.id))")
660
- } else {
661
- DiagnosticLog.shared.info("switchToAdjacentSpace: system shortcut changed \(context.currentSpaceId) → \(finalSpaceId)")
662
- }
663
- return true
664
- }
665
-
666
- guard let target = context.target else {
667
- DiagnosticLog.shared.info("switchToAdjacentSpace: system shortcut stayed on \(context.currentSpaceId) and there is no adjacent space")
668
- return false
669
- }
670
-
671
- DiagnosticLog.shared.warn("switchToAdjacentSpace: system shortcut stayed on \(context.currentSpaceId), falling back to SkyLight target \(target.id)")
672
- }
673
-
674
- return false
675
- }
676
-
677
- /// Find a window by its title tag and return its CGWindowID and owner PID
678
- static func findWindow(tag: String) -> (wid: UInt32, pid: pid_t)? {
679
- SessionWindowLocator.findCGWindow(tag: tag).map { ($0.wid, $0.pid) }
680
- }
681
-
682
- /// Get the space ID(s) a window is on
683
- static func getSpacesForWindow(_ wid: UInt32) -> [Int] {
684
- guard let mainConn = CGS.mainConnectionID,
685
- let copySpaces = CGS.copySpacesForWindows else { return [] }
686
- let cid = mainConn()
687
- let arr = [NSNumber(value: wid)] as CFArray
688
- guard let result = copySpaces(cid, 0x7, arr) as? [NSNumber] else { return [] }
689
- return result.map { $0.intValue }
690
- }
691
-
692
- /// Switch a display to a specific Space.
693
- /// Returns true once the requested Space becomes current.
694
- @discardableResult
695
- static func switchToSpace(spaceId: Int, verify: Bool = true) -> Bool {
696
- guard let mainConn = CGS.mainConnectionID,
697
- let setSpace = CGS.setCurrentSpace else { return false }
698
-
699
- let cid = mainConn()
700
-
701
- // Find which display this space belongs to
702
- let allDisplays = getDisplaySpaces()
703
- for display in allDisplays {
704
- if display.spaces.contains(where: { $0.id == spaceId }) {
705
- let initialSpace = display.currentSpaceId
706
- if initialSpace == spaceId {
707
- DiagnosticLog.shared.info("switchToSpace: display \(display.displayIndex) already on target \(spaceId)")
708
- return true
709
- }
710
-
711
- DiagnosticLog.shared.info(
712
- "switchToSpace: requesting \(spaceId) on display \(display.displayIndex) id=\(display.displayId) from \(initialSpace)"
713
- )
714
- setSpace(cid, display.displayId as CFString, UInt64(spaceId))
715
- guard verify else { return true }
716
-
717
- let deadline = Date().addingTimeInterval(0.45)
718
- while Date() < deadline {
719
- usleep(30_000)
720
- let current = getDisplaySpaces().first(where: { $0.displayId == display.displayId })?.currentSpaceId ?? 0
721
- if current == spaceId {
722
- DiagnosticLog.shared.info("switchToSpace: display \(display.displayIndex) confirmed target \(spaceId)")
723
- return true
724
- }
725
- }
726
-
727
- DiagnosticLog.shared.warn(
728
- "switchToSpace: requested \(spaceId) on display \(display.displayIndex) from \(initialSpace), but current Space did not change"
729
- )
730
- return false
731
- }
732
- }
733
-
734
- DiagnosticLog.shared.warn("switchToSpace: couldn't resolve display for space \(spaceId)")
735
- return false
736
- }
737
-
738
- // MARK: - Move Window Between Spaces
739
-
740
- enum MoveResult {
741
- case success(method: String)
742
- case alreadyOnSpace
743
- case windowNotFound
744
- case failed(reason: String)
745
- }
746
-
747
- /// Move a session's terminal window to a different Space.
748
- /// Note: On macOS 14.5+ the CGS move APIs are silently denied.
749
- /// When that happens we fall back to just switching the user's view.
750
- static func moveWindowToSpace(session: String, terminal: Terminal, spaceId: Int) -> MoveResult {
751
- let diag = DiagnosticLog.shared
752
- let tag = Terminal.windowTag(for: session)
753
- diag.info("moveWindowToSpace: session=\(session) tag=\(tag) targetSpace=\(spaceId)")
754
-
755
- // Find the window — CG first, then AX→CG fallback
756
- let wid: UInt32
757
- if let match = SessionWindowLocator.findWindow(tag: tag, terminal: terminal) {
758
- wid = match.wid
759
- diag.info("moveWindowToSpace: located wid=\(match.wid) pid=\(match.pid)")
760
- } else {
761
- diag.warn("moveWindowToSpace: window not found for tag \(tag) — switching view only")
762
- switchToSpace(spaceId: spaceId)
763
- return .windowNotFound
764
- }
765
-
766
- // Check current spaces
767
- let currentSpaces = getSpacesForWindow(wid)
768
- diag.info("moveWindowToSpace: wid=\(wid) currentSpaces=\(currentSpaces)")
769
- if currentSpaces.contains(spaceId) {
770
- diag.info("moveWindowToSpace: already on target space — switching view")
771
- switchToSpace(spaceId: spaceId)
772
- return .alreadyOnSpace
773
- }
774
-
775
- // Try CGS direct move (works on older macOS, silently denied on 14.5+)
776
- if let result = moveViaCGS(wid: wid, fromSpaces: currentSpaces, toSpace: spaceId) {
777
- return result
778
- }
779
-
780
- // CGS unavailable — just switch the user's view
781
- diag.info("moveWindowToSpace: CGS unavailable, switching view to space")
782
- switchToSpace(spaceId: spaceId)
783
- return .success(method: "switch-view")
784
- }
785
-
786
- /// Attempt CGS-based window move. Returns nil if APIs are unavailable.
787
- /// Move a window between spaces via CGS private APIs. Internal — used by present() and moveWindowToSpace().
788
- internal static func moveViaCGS(wid: UInt32, fromSpaces: [Int], toSpace: Int) -> MoveResult? {
789
- let diag = DiagnosticLog.shared
790
- guard let mainConn = CGS.mainConnectionID,
791
- let addToSpaces = CGS.addWindowsToSpaces,
792
- let removeFromSpaces = CGS.removeWindowsFromSpaces else {
793
- return nil
794
- }
795
-
796
- let cid = mainConn()
797
- let windowArray = [NSNumber(value: wid)] as CFArray
798
- let targetArray = [NSNumber(value: toSpace)] as CFArray
799
-
800
- addToSpaces(cid, windowArray, targetArray)
801
- if !fromSpaces.isEmpty {
802
- let sourceArray = fromSpaces.map { NSNumber(value: $0) } as CFArray
803
- removeFromSpaces(cid, windowArray, sourceArray)
804
- }
805
-
806
- // Verify the move took effect (macOS 14.5+ silently denies)
807
- let newSpaces = getSpacesForWindow(wid)
808
- if newSpaces.contains(toSpace) && !fromSpaces.allSatisfy({ newSpaces.contains($0) }) {
809
- diag.success("moveViaCGS: successfully moved wid=\(wid) to space \(toSpace)")
810
- return .success(method: "CGS")
811
- }
812
-
813
- // CGS was silently denied — switch the view instead
814
- diag.warn("moveViaCGS: silently denied (macOS 14.5+ restriction) — switching view")
815
- switchToSpace(spaceId: toSpace)
816
- return .success(method: "switch-view")
817
- }
818
-
819
- /// Navigate to a session's window: switch to its Space, raise it, highlight it
820
- /// Falls back through CG → AX → AppleScript depending on available permissions
821
- static func navigateToWindow(session: String, terminal: Terminal) {
822
- let diag = DiagnosticLog.shared
823
- let t = diag.startTimed("navigateToWindow: \(session)")
824
- let tag = Terminal.windowTag(for: session)
825
-
826
- // Path 1: CG window lookup (needs Screen Recording permission for window names)
827
- if let match = SessionWindowLocator.findWindow(tag: tag, terminal: terminal) {
828
- diag.success("Path 1/2 (locator): found wid=\(match.wid) pid=\(match.pid)")
829
- navigateToKnownWindow(wid: match.wid, pid: match.pid, tag: tag, session: session, terminal: terminal)
830
- diag.finish(t)
831
- return
832
- }
833
- diag.warn("SessionWindowLocator failed — trying direct AX fallback")
834
-
835
- if let (pid, axWindow) = SessionWindowLocator.findAXWindow(terminal: terminal, tag: tag) {
836
- diag.success("Direct AX fallback: found window for \(terminal.rawValue) pid=\(pid)")
837
- AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
838
- AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
839
- if let app = NSRunningApplication(processIdentifier: pid) {
840
- app.activate()
841
- }
842
- if let frame = axWindowFrame(axWindow) {
843
- diag.info("Highlighting via AX frame: \(frame)")
844
- DispatchQueue.main.async { WindowHighlight.shared.flash(frame: frame) }
845
- } else {
846
- diag.error("axWindowFrame returned nil — no highlight")
847
- }
848
- diag.finish(t)
849
- return
850
- }
851
- diag.warn("Direct AX fallback failed — no Accessibility?")
852
-
853
- // Path 3: AppleScript / bare activate fallback
854
- diag.warn("Path 3: falling back to AppleScript/activate")
855
- activateViaAppleScript(session: session, tag: tag, terminal: terminal)
856
- diag.finish(t)
857
- }
858
-
859
- private static func navigateToKnownWindow(wid: UInt32, pid: pid_t, tag: String, session: String, terminal: Terminal) {
860
- let diag = DiagnosticLog.shared
861
- let windowSpaces = getSpacesForWindow(wid)
862
- let currentSpace = getCurrentSpace()
863
- diag.info("navigateToKnown: wid=\(wid) spaces=\(windowSpaces) current=\(currentSpace)")
864
-
865
- if let windowSpace = windowSpaces.first, windowSpace != currentSpace {
866
- diag.info("Switching from space \(currentSpace) → \(windowSpace)")
867
- switchToSpace(spaceId: windowSpace)
868
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
869
- raiseWindow(pid: pid, tag: tag, terminal: terminal)
870
- highlightWindow(session: session)
871
- }
872
- } else {
873
- diag.info("Window on current space — raising + highlighting")
874
- raiseWindow(pid: pid, tag: tag, terminal: terminal)
875
- highlightWindow(session: session)
876
- }
877
- }
878
-
879
- private static func findWindowViaAX(terminal: Terminal, tag: String) -> (pid: pid_t, window: AXUIElement)? {
880
- SessionWindowLocator.findAXWindow(terminal: terminal, tag: tag)
881
- }
882
-
883
- private static func matchCGWindow(pid: pid_t, axWindow: AXUIElement) -> UInt32? {
884
- SessionWindowLocator.matchCGWindow(pid: pid, axWindow: axWindow)
885
- }
886
-
887
- /// Get NSRect from an AX window element (AX uses top-left origin, convert to NS bottom-left)
888
- private static func axWindowFrame(_ window: AXUIElement) -> NSRect? {
889
- var posRef: CFTypeRef?
890
- var sizeRef: CFTypeRef?
891
- AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posRef)
892
- AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeRef)
893
- guard let pv = posRef, let sv = sizeRef else { return nil }
894
-
895
- var pos = CGPoint.zero
896
- var size = CGSize.zero
897
- AXValueGetValue(pv as! AXValue, .cgPoint, &pos)
898
- AXValueGetValue(sv as! AXValue, .cgSize, &size)
899
-
900
- guard let primaryScreen = NSScreen.screens.first else { return nil }
901
- let primaryHeight = primaryScreen.frame.height
902
- return NSRect(x: pos.x, y: primaryHeight - pos.y - size.height, width: size.width, height: size.height)
903
- }
904
-
905
- /// Last-resort: use AppleScript for Terminal/iTerm2, or bare activate for others
906
- private static func activateViaAppleScript(session: String, tag: String, terminal: Terminal) {
907
- switch terminal {
908
- case .terminal:
909
- runScript("""
910
- tell application "Terminal"
911
- activate
912
- repeat with w in windows
913
- if name of w contains "\(tag)" then
914
- set index of w to 1
915
- exit repeat
916
- end if
917
- end repeat
918
- end tell
919
- """)
920
- case .iterm2:
921
- runScript("""
922
- tell application "iTerm2"
923
- activate
924
- repeat with w in windows
925
- if name of w contains "\(tag)" then
926
- select w
927
- exit repeat
928
- end if
929
- end repeat
930
- end tell
931
- """)
932
- default:
933
- if let app = NSWorkspace.shared.runningApplications.first(where: {
934
- $0.bundleIdentifier == terminal.bundleId
935
- }) {
936
- app.activate()
937
- }
938
- }
939
- }
940
-
941
- /// Raise a specific window using AX API + AppleScript
942
- private static func raiseWindow(pid: pid_t, tag: String, terminal: Terminal) {
943
- let diag = DiagnosticLog.shared
944
- let appRef = AXUIElementCreateApplication(pid)
945
- var windowsRef: CFTypeRef?
946
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
947
- var raised = false
948
- if err == .success, let windows = windowsRef as? [AXUIElement] {
949
- for win in windows {
950
- var titleRef: CFTypeRef?
951
- AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleRef)
952
- if let title = titleRef as? String, title.contains(tag) {
953
- AXUIElementPerformAction(win, kAXRaiseAction as CFString)
954
- AXUIElementSetAttributeValue(win, kAXMainAttribute as CFString, kCFBooleanTrue)
955
- diag.success("raiseWindow: raised \"\(title)\"")
956
- raised = true
957
- break
958
- }
959
- }
960
- }
961
- if !raised {
962
- diag.warn("raiseWindow: could not find window with tag \(tag) via AX (err=\(err.rawValue))")
963
- }
964
-
965
- if let app = NSRunningApplication(processIdentifier: pid) {
966
- app.activate()
967
- diag.info("raiseWindow: activated \(app.localizedName ?? "pid:\(pid)")")
968
- }
969
- }
970
-
971
- // MARK: - Highlight
972
-
973
- /// Flash a highlight border around a session's terminal window
974
- static func highlightWindow(session: String) {
975
- let diag = DiagnosticLog.shared
976
- let tag = Terminal.windowTag(for: session)
977
- diag.info("highlightWindow: tag=\(tag)")
978
-
979
- // Path 1: CG approach (needs Screen Recording)
980
- if let (wid, _) = findWindow(tag: tag) {
981
- diag.info("highlight via CG: wid=\(wid)")
982
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return }
983
- for info in windowList {
984
- if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
985
- let dict = info[kCGWindowBounds as String] as? NSDictionary {
986
- var rect = CGRect.zero
987
- if CGRectMakeWithDictionaryRepresentation(dict, &rect) {
988
- guard let primaryScreen = NSScreen.screens.first else { return }
989
- let primaryHeight = primaryScreen.frame.height
990
- let nsRect = NSRect(
991
- x: rect.origin.x,
992
- y: primaryHeight - rect.origin.y - rect.height,
993
- width: rect.width,
994
- height: rect.height
995
- )
996
- diag.success("highlight CG flash at \(Int(nsRect.origin.x)),\(Int(nsRect.origin.y)) \(Int(nsRect.width))×\(Int(nsRect.height))")
997
- DispatchQueue.main.async { WindowHighlight.shared.flash(frame: nsRect) }
998
- }
999
- return
1000
- }
1001
- }
1002
- diag.warn("highlight CG: wid \(wid) not in window list")
1003
- return
1004
- }
1005
-
1006
- // Path 2: AX fallback — search installed terminals for the tagged window
1007
- diag.info("highlight: CG failed, trying AX fallback across \(Terminal.installed.count) terminals")
1008
- for terminal in Terminal.installed {
1009
- if let (_, axWindow) = findWindowViaAX(terminal: terminal, tag: tag),
1010
- let frame = axWindowFrame(axWindow) {
1011
- diag.success("highlight AX flash at \(Int(frame.origin.x)),\(Int(frame.origin.y)) \(Int(frame.width))×\(Int(frame.height))")
1012
- DispatchQueue.main.async { WindowHighlight.shared.flash(frame: frame) }
1013
- return
1014
- }
1015
- }
1016
- diag.error("highlight: no method found window — no highlight shown")
1017
- }
1018
-
1019
- // MARK: - Window Info
1020
-
1021
- struct WindowInfo {
1022
- let spaceIndex: Int // 1-based space number
1023
- let displayIndex: Int // 0-based display index
1024
- let tilePosition: TilePosition? // inferred from bounds, nil if free-form
1025
- let wid: UInt32
1026
- }
1027
-
1028
- /// Get spatial info for a session's terminal window (space, display, tile position)
1029
- static func getWindowInfo(session: String, terminal: Terminal) -> WindowInfo? {
1030
- let tag = Terminal.windowTag(for: session)
1031
-
1032
- // Find the window
1033
- let wid: UInt32
1034
- if let match = SessionWindowLocator.findWindow(tag: tag, terminal: terminal) {
1035
- wid = match.wid
1036
- } else {
1037
- return nil
1038
- }
1039
-
1040
- // Determine which space/display the window is on
1041
- let windowSpaces = getSpacesForWindow(wid)
1042
- let allDisplays = getDisplaySpaces()
1043
-
1044
- var spaceIndex = 1
1045
- var displayIndex = 0
1046
-
1047
- if let windowSpaceId = windowSpaces.first {
1048
- for display in allDisplays {
1049
- if let space = display.spaces.first(where: { $0.id == windowSpaceId }) {
1050
- spaceIndex = space.index
1051
- displayIndex = display.displayIndex
1052
- break
1053
- }
1054
- }
1055
- }
1056
-
1057
- let tile = inferTilePosition(wid: wid)
1058
-
1059
- return WindowInfo(
1060
- spaceIndex: spaceIndex,
1061
- displayIndex: displayIndex,
1062
- tilePosition: tile,
1063
- wid: wid
1064
- )
1065
- }
1066
-
1067
- /// Infer tile position from a window frame + screen without re-querying CGWindowList
1068
- static func inferTilePosition(frame: WindowFrame, screen: NSScreen) -> TilePosition? {
1069
- let visible = screen.visibleFrame
1070
- let full = screen.frame
1071
-
1072
- // CG top-left origin → visible frame top-left origin
1073
- let primaryHeight = NSScreen.screens.first?.frame.height ?? full.height
1074
- let visTop = primaryHeight - visible.maxY
1075
- let fx = (frame.x - visible.origin.x) / visible.width
1076
- let fy = (frame.y - visTop) / visible.height
1077
- let fw = frame.w / visible.width
1078
- let fh = frame.h / visible.height
1079
-
1080
- let tolerance: CGFloat = 0.05
1081
-
1082
- for position in TilePosition.allCases {
1083
- let (px, py, pw, ph) = position.rect
1084
- if abs(fx - CGFloat(px)) < tolerance && abs(fy - CGFloat(py)) < tolerance &&
1085
- abs(fw - CGFloat(pw)) < tolerance && abs(fh - CGFloat(ph)) < tolerance {
1086
- return position
1087
- }
1088
- }
1089
- return nil
1090
- }
1091
-
1092
- /// Infer tile position from a window's current bounds relative to its screen
1093
- private static func inferTilePosition(wid: UInt32) -> TilePosition? {
1094
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
1095
- return nil
1096
- }
1097
-
1098
- // Find the window's bounds
1099
- var windowRect = CGRect.zero
1100
- for info in windowList {
1101
- if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
1102
- let dict = info[kCGWindowBounds as String] as? NSDictionary {
1103
- CGRectMakeWithDictionaryRepresentation(dict, &windowRect)
1104
- break
1105
- }
1106
- }
1107
- guard windowRect.width > 0 else { return nil }
1108
-
1109
- // Find which screen contains the window center
1110
- let centerX = windowRect.midX
1111
- let centerY = windowRect.midY
1112
- guard let primaryScreen = NSScreen.screens.first else { return nil }
1113
- let primaryHeight = primaryScreen.frame.height
1114
-
1115
- // CG uses top-left origin; convert to NS bottom-left for screen matching
1116
- let nsCenterY = primaryHeight - centerY
1117
-
1118
- let screen = NSScreen.screens.first(where: {
1119
- $0.frame.contains(NSPoint(x: centerX, y: nsCenterY))
1120
- }) ?? NSScreen.main ?? primaryScreen
1121
-
1122
- let visible = screen.visibleFrame
1123
- let full = screen.frame
1124
-
1125
- // Convert CG rect to fractional coordinates relative to visible frame
1126
- // CG top-left origin → visible frame top-left origin
1127
- let visTop = full.height - visible.maxY + full.origin.y
1128
- let fx = (windowRect.origin.x - visible.origin.x) / visible.width
1129
- let fy = (windowRect.origin.y - visTop) / visible.height
1130
- let fw = windowRect.width / visible.width
1131
- let fh = windowRect.height / visible.height
1132
-
1133
- let tolerance: CGFloat = 0.05
1134
-
1135
- for position in TilePosition.allCases {
1136
- let (px, py, pw, ph) = position.rect
1137
- if abs(fx - px) < tolerance && abs(fy - py) < tolerance &&
1138
- abs(fw - pw) < tolerance && abs(fh - ph) < tolerance {
1139
- return position
1140
- }
1141
- }
1142
-
1143
- return nil
1144
- }
1145
-
1146
- // MARK: - By-ID Window Operations (Desktop Inventory)
1147
-
1148
- /// Navigate to an arbitrary window by its CG window ID: switch space, raise, highlight
1149
- static func navigateToWindowById(wid: UInt32, pid: Int32) {
1150
- let diag = DiagnosticLog.shared
1151
- diag.info("navigateToWindowById: wid=\(wid) pid=\(pid)")
1152
-
1153
- // Switch to window's space if needed
1154
- let windowSpaces = getSpacesForWindow(wid)
1155
- let currentSpace = getCurrentSpace()
1156
-
1157
- if let windowSpace = windowSpaces.first, windowSpace != currentSpace {
1158
- diag.info("Switching from space \(currentSpace) → \(windowSpace)")
1159
- switchToSpace(spaceId: windowSpace)
1160
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
1161
- raiseWindowById(wid: wid, pid: pid)
1162
- highlightWindowById(wid: wid)
1163
- }
1164
- } else {
1165
- raiseWindowById(wid: wid, pid: pid)
1166
- highlightWindowById(wid: wid)
1167
- }
1168
- }
1169
-
1170
- /// Flash a highlight border on any window by its CG window ID
1171
- static func highlightWindowById(wid: UInt32) {
1172
- guard let frame = cgWindowFrame(wid: wid) else {
1173
- DiagnosticLog.shared.warn("highlightWindowById: no frame for wid=\(wid)")
1174
- return
1175
- }
1176
- DispatchQueue.main.async { WindowHighlight.shared.flash(frame: frame) }
1177
- }
1178
-
1179
- /// Tile any window by its CG window ID to a position.
1180
- /// Delegates to `batchMoveAndRaiseWindows` which is the battle-tested path:
1181
- /// uses `_AXUIElementGetWindow` for direct wid→AX mapping, disables enhanced UI,
1182
- /// freezes screen rendering, and verifies+retries drifted windows.
1183
- /// Tile a window using raw fractional coordinates.
1184
- static func tileWindowById(wid: UInt32, pid: Int32, fractions: (CGFloat, CGFloat, CGFloat, CGFloat), on targetScreen: NSScreen? = nil) {
1185
- let screen = targetScreen ?? NSScreen.main ?? NSScreen.screens[0]
1186
- let frame = tileFrame(fractions: fractions, on: screen)
1187
- DiagnosticLog.shared.info("tileWindowById: wid=\(wid) fractions=\(fractions) frame=(\(Int(frame.origin.x)),\(Int(frame.origin.y))) \(Int(frame.width))x\(Int(frame.height))")
1188
- if let app = NSRunningApplication(processIdentifier: pid) { app.activate() }
1189
- let moves = [(wid: wid, pid: pid, frame: frame)]
1190
- batchMoveAndRaiseWindows(moves)
1191
- let drifted = verifyMoves(moves)
1192
- if !drifted.isEmpty {
1193
- usleep(100_000)
1194
- batchMoveAndRaiseWindows(drifted.map { (wid: $0.wid, pid: $0.pid, frame: $0.frame) })
1195
- }
1196
- }
1197
-
1198
- static func tileWindowById(wid: UInt32, pid: Int32, to position: TilePosition, on targetScreen: NSScreen? = nil) {
1199
- tileWindowById(wid: wid, pid: pid, to: .tile(position), on: targetScreen)
1200
- }
1201
-
1202
- static func tileWindowById(wid: UInt32, pid: Int32, to placement: PlacementSpec, on targetScreen: NSScreen? = nil) {
1203
- let diag = DiagnosticLog.shared
1204
- let screen = targetScreen ?? NSScreen.main ?? NSScreen.screens[0]
1205
- let frame = tileFrame(for: placement, on: screen)
1206
-
1207
- diag.info("tileWindowById: wid=\(wid) pid=\(pid) pos=\(placement.wireValue) screen=\(screen.localizedName) frame=(\(Int(frame.origin.x)),\(Int(frame.origin.y))) \(Int(frame.width))x\(Int(frame.height))")
1208
-
1209
- // Focus the app so windows on other Spaces come to the current one
1210
- if let app = NSRunningApplication(processIdentifier: pid) {
1211
- app.activate()
1212
- }
1213
-
1214
- let moves = [(wid: wid, pid: pid, frame: frame)]
1215
- batchMoveAndRaiseWindows(moves)
1216
-
1217
- // Verify and retry once if needed
1218
- let drifted = verifyMoves(moves)
1219
- if !drifted.isEmpty {
1220
- diag.info("tileWindowById: wid=\(wid) drifted, retrying...")
1221
- usleep(100_000)
1222
- batchMoveAndRaiseWindows(drifted)
1223
- let stillDrifted = verifyMoves(drifted)
1224
- if stillDrifted.isEmpty {
1225
- diag.success("tileWindowById: wid=\(wid) retry succeeded")
1226
- } else {
1227
- diag.warn("tileWindowById: wid=\(wid) still drifted after retry")
1228
- }
1229
- } else {
1230
- diag.success("tileWindowById: tiled wid=\(wid) to \(placement.wireValue)")
1231
- }
1232
- }
1233
-
1234
- /// Distribute windows in a smart grid layout (delegates to batch operation)
1235
- static func tileDistributeHorizontally(windows: [(wid: UInt32, pid: Int32)]) {
1236
- batchRaiseAndDistribute(windows: windows)
1237
- }
1238
-
1239
- /// Distribute ALL visible non-Lattices windows into a smart grid on the screen with the most windows.
1240
- static func distributeVisible(reactivateLattices: Bool = true) {
1241
- let diag = DiagnosticLog.shared
1242
- let t = diag.startTimed("distributeVisible")
1243
-
1244
- let visible = visibleDistributableWindows()
1245
-
1246
- guard !visible.isEmpty else {
1247
- diag.info("distributeVisible: no visible windows to distribute")
1248
- diag.finish(t)
1249
- return
1250
- }
1251
-
1252
- let windows = visible.map { (wid: $0.wid, pid: $0.pid) }
1253
- diag.info("distributeVisible: \(windows.count) windows")
1254
- batchRaiseAndDistribute(windows: windows, reactivateLattices: reactivateLattices)
1255
- diag.finish(t)
1256
- }
1257
-
1258
- /// Distribute visible windows matching the frontmost app's broader type.
1259
- /// Example: when the active app is a terminal, grid all visible terminal windows on that display.
1260
- static func distributeVisibleByFrontmostType(reactivateLattices: Bool = true) {
1261
- let diag = DiagnosticLog.shared
1262
- let t = diag.startTimed("distributeVisibleByFrontmostType")
1263
-
1264
- let visible = visibleDistributableWindows()
1265
- guard !visible.isEmpty else {
1266
- diag.info("distributeVisibleByFrontmostType: no visible windows to distribute")
1267
- diag.finish(t)
1268
- return
1269
- }
1270
-
1271
- let frontmostAppName = NSWorkspace.shared.frontmostApplication?.localizedName
1272
- let anchor = frontmostAppName.flatMap { name in
1273
- visible.first { $0.app.localizedCaseInsensitiveCompare(name) == .orderedSame }
1274
- } ?? visible.first
1275
-
1276
- guard let anchor else {
1277
- diag.info("distributeVisibleByFrontmostType: no anchor window resolved")
1278
- diag.finish(t)
1279
- return
1280
- }
1281
-
1282
- let grouping = AppTypeClassifier.grouping(for: anchor.app)
1283
- let anchorScreen = screenForWindowFrame(anchor.frame)
1284
- let anchorScreenId = screenID(for: anchorScreen)
1285
-
1286
- let sameScreenMatches = visible.filter { entry in
1287
- AppTypeClassifier.matches(entry.app, grouping: grouping) &&
1288
- screenID(for: screenForWindowFrame(entry.frame)) == anchorScreenId
1289
- }
1290
-
1291
- let matches = sameScreenMatches.isEmpty
1292
- ? visible.filter { AppTypeClassifier.matches($0.app, grouping: grouping) }
1293
- : sameScreenMatches
1294
-
1295
- guard !matches.isEmpty else {
1296
- diag.info("distributeVisibleByFrontmostType: no matches for \(grouping.label)")
1297
- diag.finish(t)
1298
- return
1299
- }
1300
-
1301
- let ordered = sortWindowsForGrid(matches)
1302
- diag.info("distributeVisibleByFrontmostType: grouping=\(grouping.label) count=\(ordered.count) screen=\(anchorScreen.localizedName)")
1303
- batchRaiseAndDistribute(
1304
- windows: ordered.map { (wid: $0.wid, pid: $0.pid) },
1305
- reactivateLattices: reactivateLattices
1306
- )
1307
- diag.finish(t)
1308
- }
1309
-
1310
- /// Get NSRect (bottom-left origin) for a known CG window ID
1311
- static func cgWindowFrame(wid: UInt32) -> NSRect? {
1312
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return nil }
1313
- for info in windowList {
1314
- if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
1315
- let dict = info[kCGWindowBounds as String] as? NSDictionary {
1316
- var rect = CGRect.zero
1317
- if CGRectMakeWithDictionaryRepresentation(dict, &rect) {
1318
- guard let primaryScreen = NSScreen.screens.first else { return nil }
1319
- let primaryHeight = primaryScreen.frame.height
1320
- return NSRect(
1321
- x: rect.origin.x,
1322
- y: primaryHeight - rect.origin.y - rect.height,
1323
- width: rect.width,
1324
- height: rect.height
1325
- )
1326
- }
1327
- }
1328
- }
1329
- return nil
1330
- }
1331
-
1332
- /// Raise a window by matching its CG window ID to an AX element via frame comparison
1333
- private static func raiseWindowById(wid: UInt32, pid: Int32) {
1334
- let diag = DiagnosticLog.shared
1335
-
1336
- if let axWindow = findAXWindowByFrame(wid: wid, pid: pid) {
1337
- AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
1338
- AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
1339
- diag.success("raiseWindowById: raised wid=\(wid)")
1340
- } else {
1341
- diag.warn("raiseWindowById: couldn't match AX window for wid=\(wid)")
1342
- }
1343
-
1344
- if let app = NSRunningApplication(processIdentifier: pid) {
1345
- app.activate()
1346
- }
1347
- }
1348
-
1349
- /// Raise multiple windows at once, re-activating our app once at the end
1350
- static func raiseWindowsAndReactivate(windows: [(wid: UInt32, pid: Int32)]) {
1351
- let diag = DiagnosticLog.shared
1352
- var activatedPids = Set<Int32>()
1353
- for win in windows {
1354
- if let axWindow = findAXWindowByFrame(wid: win.wid, pid: win.pid) {
1355
- AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
1356
- AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
1357
- }
1358
- if !activatedPids.contains(win.pid) {
1359
- if let app = NSRunningApplication(processIdentifier: win.pid) {
1360
- app.activate()
1361
- activatedPids.insert(win.pid)
1362
- }
1363
- }
1364
- }
1365
- DesktopModel.shared.markInteraction(wids: windows.map(\.wid))
1366
- diag.success("raiseWindowsAndReactivate: raised \(windows.count) windows")
1367
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
1368
- NSApp.activate(ignoringOtherApps: true)
1369
- }
1370
- }
1371
-
1372
- /// Raise a window to front and then re-activate our own app so the panel stays visible
1373
- static func raiseWindowAndReactivate(wid: UInt32, pid: Int32) {
1374
- let diag = DiagnosticLog.shared
1375
- diag.info("raiseWindowAndReactivate: wid=\(wid) pid=\(pid)")
1376
-
1377
- // Switch to window's space if needed
1378
- let windowSpaces = getSpacesForWindow(wid)
1379
- let currentSpace = getCurrentSpace()
1380
-
1381
- let doRaise = {
1382
- if let axWindow = findAXWindowByFrame(wid: wid, pid: pid) {
1383
- AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString)
1384
- AXUIElementSetAttributeValue(axWindow, kAXMainAttribute as CFString, kCFBooleanTrue)
1385
- diag.success("raiseWindowAndReactivate: raised wid=\(wid)")
1386
- }
1387
- // Activate target app briefly so window comes to front
1388
- if let app = NSRunningApplication(processIdentifier: pid) {
1389
- app.activate()
1390
- }
1391
- // Re-activate our app so the panel stays visible
1392
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
1393
- NSApp.activate(ignoringOtherApps: true)
1394
- }
1395
- }
1396
-
1397
- if let windowSpace = windowSpaces.first, windowSpace != currentSpace {
1398
- diag.info("Switching from space \(currentSpace) → \(windowSpace)")
1399
- switchToSpace(spaceId: windowSpace)
1400
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { doRaise() }
1401
- } else {
1402
- doRaise()
1403
- }
1404
- DesktopModel.shared.markInteraction(wid: wid)
1405
- }
1406
-
1407
- // MARK: - Batch Window Operations
1408
-
1409
- /// Move multiple windows to target frames in one shot.
1410
- /// Single CGWindowList query, single AX query per process, all moves synchronous.
1411
- static func batchMoveWindows(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)]) {
1412
- guard !moves.isEmpty else { return }
1413
- let diag = DiagnosticLog.shared
1414
-
1415
- // Group by pid so we query each app's AX windows once
1416
- var byPid: [Int32: [(wid: UInt32, target: CGRect)]] = [:]
1417
- for move in moves {
1418
- byPid[move.pid, default: []].append((wid: move.wid, target: move.frame))
1419
- }
1420
-
1421
- // For each process: get AX windows, match by CGWindowID, move+resize
1422
- var moved = 0
1423
- var failed = 0
1424
- for (pid, windowMoves) in byPid {
1425
- let appRef = AXUIElementCreateApplication(pid)
1426
- var windowsRef: CFTypeRef?
1427
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1428
- guard err == .success, let axWindows = windowsRef as? [AXUIElement] else {
1429
- diag.info("[batchMove] AX query failed for pid \(pid)")
1430
- failed += windowMoves.count
1431
- continue
1432
- }
1433
-
1434
- // Build wid → AXUIElement map using _AXUIElementGetWindow
1435
- var axByWid: [UInt32: AXUIElement] = [:]
1436
- for axWin in axWindows {
1437
- var windowId: CGWindowID = 0
1438
- if _AXUIElementGetWindow(axWin, &windowId) == .success {
1439
- axByWid[windowId] = axWin
1440
- }
1441
- }
1442
-
1443
- for wm in windowMoves {
1444
- guard let axWin = axByWid[wm.wid] else {
1445
- diag.info("[batchMove] no AX match for wid \(wm.wid)")
1446
- failed += 1
1447
- continue
1448
- }
1449
-
1450
- applyFrameToAXWindow(axWin, wid: wm.wid, target: wm.target)
1451
- moved += 1
1452
- }
1453
- }
1454
- if failed > 0 {
1455
- diag.info("[batchMove] \(failed) windows failed to match")
1456
- }
1457
- diag.success("batchMoveWindows: moved \(moved)/\(moves.count) windows")
1458
- }
1459
-
1460
- /// Apply position+size to a single AX window. No delays, no retries — just set and go.
1461
- private static func applyFrameToAXWindow(_ axWin: AXUIElement, wid: UInt32, target: CGRect) {
1462
- var newPos = CGPoint(x: target.origin.x, y: target.origin.y)
1463
- var newSize = CGSize(width: target.width, height: target.height)
1464
-
1465
- // Size first (avoids clipping at screen edges), then position
1466
- if let sv = AXValueCreate(.cgSize, &newSize) {
1467
- AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
1468
- }
1469
- if let pv = AXValueCreate(.cgPoint, &newPos) {
1470
- AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
1471
- }
1472
- }
1473
-
1474
- /// Read back current AX position+size for a window element.
1475
- static func readAXFrame(_ axWin: AXUIElement) -> CGRect? {
1476
- var posRef: CFTypeRef?
1477
- var sizeRef: CFTypeRef?
1478
- guard AXUIElementCopyAttributeValue(axWin, kAXPositionAttribute as CFString, &posRef) == .success,
1479
- AXUIElementCopyAttributeValue(axWin, kAXSizeAttribute as CFString, &sizeRef) == .success else {
1480
- return nil
1481
- }
1482
- var pos = CGPoint.zero
1483
- var size = CGSize.zero
1484
- guard AXValueGetValue(posRef as! AXValue, .cgPoint, &pos),
1485
- AXValueGetValue(sizeRef as! AXValue, .cgSize, &size) else {
1486
- return nil
1487
- }
1488
- return CGRect(origin: pos, size: size)
1489
- }
1490
-
1491
- /// Verify which windows drifted from their targets using CGWindowList.
1492
- /// Returns array of moves that still need correction.
1493
- static func verifyMoves(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)], tolerance: CGFloat = 4) -> [(wid: UInt32, pid: Int32, frame: CGRect)] {
1494
- guard let rawList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
1495
- return moves // can't verify, return all
1496
- }
1497
-
1498
- var actualByWid: [UInt32: CGRect] = [:]
1499
- for info in rawList {
1500
- guard let wid = info[kCGWindowNumber as String] as? UInt32,
1501
- let bounds = info[kCGWindowBounds as String] as? [String: Any],
1502
- let x = bounds["X"] as? CGFloat, let y = bounds["Y"] as? CGFloat,
1503
- let w = bounds["Width"] as? CGFloat, let h = bounds["Height"] as? CGFloat else { continue }
1504
- actualByWid[wid] = CGRect(x: x, y: y, width: w, height: h)
1505
- }
1506
-
1507
- let diag = DiagnosticLog.shared
1508
- var drifted: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
1509
- for move in moves {
1510
- guard let actual = actualByWid[move.wid] else {
1511
- drifted.append(move)
1512
- continue
1513
- }
1514
- let dx = abs(actual.origin.x - move.frame.origin.x)
1515
- let dy = abs(actual.origin.y - move.frame.origin.y)
1516
- let dw = abs(actual.width - move.frame.width)
1517
- let dh = abs(actual.height - move.frame.height)
1518
- if dx > tolerance || dy > tolerance || dw > tolerance || dh > tolerance {
1519
- diag.info("[verify] wid \(move.wid) drifted: target \(move.frame) actual \(actual) (dx=\(Int(dx)) dy=\(Int(dy)) dw=\(Int(dw)) dh=\(Int(dh)))")
1520
- drifted.append(move)
1521
- }
1522
- }
1523
- return drifted
1524
- }
1525
-
1526
- /// Raise and focus a single window by its CGWindowID.
1527
- @discardableResult
1528
- static func focusWindow(wid: UInt32, pid: Int32) -> Bool {
1529
- return present(wid: wid, pid: pid)
1530
- }
1531
-
1532
- // MARK: - Present
1533
-
1534
- /// Present a window: move it to the current space, bring it to front, optionally position it.
1535
- /// This is the single entry point for "show me this window right now."
1536
- @discardableResult
1537
- static func present(wid: UInt32, pid: Int32, frame: CGRect? = nil) -> Bool {
1538
- let diag = DiagnosticLog.shared
1539
-
1540
- // 1. Move to current space if needed
1541
- let windowSpaces = getSpacesForWindow(wid)
1542
- let currentSpace = getCurrentSpace()
1543
- if currentSpace != 0, !windowSpaces.isEmpty, !windowSpaces.contains(currentSpace) {
1544
- diag.info("present: wid \(wid) on space \(windowSpaces), moving to current space \(currentSpace)")
1545
- _ = moveViaCGS(wid: wid, fromSpaces: windowSpaces, toSpace: currentSpace)
1546
- }
1547
-
1548
- // 2. Position if requested
1549
- if let frame = frame {
1550
- if let mainConn = _SLSMainConnectionID, let moveWindow = _SLSMoveWindow {
1551
- let cid = mainConn()
1552
- var origin = CGPoint(x: frame.origin.x, y: frame.origin.y)
1553
- moveWindow(cid, wid, &origin)
1554
- }
1555
- // Resize via AX
1556
- let appRef = AXUIElementCreateApplication(pid)
1557
- var windowsRef: CFTypeRef?
1558
- if AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef) == .success,
1559
- let axWindows = windowsRef as? [AXUIElement] {
1560
- for axWin in axWindows {
1561
- var windowId: CGWindowID = 0
1562
- if _AXUIElementGetWindow(axWin, &windowId) == .success, windowId == wid {
1563
- var size = CGSize(width: frame.width, height: frame.height)
1564
- let sizeValue = AXValueCreate(.cgSize, &size)!
1565
- AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sizeValue)
1566
- break
1567
- }
1568
- }
1569
- }
1570
- }
1571
-
1572
- // 3. Activate the app first (this may bring the wrong window forward)
1573
- if let app = NSRunningApplication(processIdentifier: pid) {
1574
- app.activate()
1575
- }
1576
-
1577
- // 4. Then order OUR window to front — after activate so we get the last word
1578
- if let mainConn = _SLSMainConnectionID, let orderWindow = _SLSOrderWindow {
1579
- let cid = mainConn()
1580
- let err = orderWindow(cid, wid, 1, 0)
1581
- if err != .success {
1582
- diag.warn("present: SLSOrderWindow failed for wid \(wid): \(err)")
1583
- }
1584
- }
1585
-
1586
- // 5. Set as main window via AX
1587
- let appRef = AXUIElementCreateApplication(pid)
1588
- var windowsRef: CFTypeRef?
1589
- if AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef) == .success,
1590
- let axWindows = windowsRef as? [AXUIElement] {
1591
- for axWin in axWindows {
1592
- var windowId: CGWindowID = 0
1593
- if _AXUIElementGetWindow(axWin, &windowId) == .success, windowId == wid {
1594
- AXUIElementSetAttributeValue(axWin, kAXMainAttribute as CFString, kCFBooleanTrue)
1595
- AXUIElementPerformAction(axWin, kAXRaiseAction as CFString)
1596
- break
1597
- }
1598
- }
1599
- }
1600
-
1601
- // 6. Re-raise after delays to defeat focus stealing from the caller.
1602
- // Two passes: early catch (200ms) and late catch (600ms) for slower responses.
1603
- for delay in [0.2, 0.6] {
1604
- DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
1605
- if let mainConn = _SLSMainConnectionID, let orderWindow = _SLSOrderWindow {
1606
- let cid = mainConn()
1607
- orderWindow(cid, wid, 1, 0)
1608
- }
1609
- }
1610
- }
1611
-
1612
- DesktopModel.shared.markInteraction(wid: wid)
1613
- return true
1614
- }
1615
-
1616
- /// Move AND raise windows in a single CG+AX pass (avoids duplicate lookups).
1617
- /// Does not reactivate lattices at the end — caller controls that.
1618
- static func batchMoveAndRaiseWindows(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)]) {
1619
- guard !moves.isEmpty else { return }
1620
- let diag = DiagnosticLog.shared
1621
-
1622
- var byPid: [Int32: [(wid: UInt32, target: CGRect)]] = [:]
1623
- for move in moves {
1624
- byPid[move.pid, default: []].append((wid: move.wid, target: move.frame))
1625
- }
1626
-
1627
- var processed = 0
1628
- var activatedPids = Set<Int32>()
1629
-
1630
- // Freeze screen rendering for smooth batch moves
1631
- let cid = _SLSMainConnectionID?()
1632
- if let cid { _ = _SLSDisableUpdate?(cid) }
1633
-
1634
- for (pid, windowMoves) in byPid {
1635
- let appRef = AXUIElementCreateApplication(pid)
1636
-
1637
- // Disable enhanced UI — breaks macOS tile lock so resize works
1638
- AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, false as CFTypeRef)
1639
-
1640
- var windowsRef: CFTypeRef?
1641
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1642
- guard err == .success, let axWindows = windowsRef as? [AXUIElement] else { continue }
1643
-
1644
- // Build wid → AXUIElement map using _AXUIElementGetWindow
1645
- var axByWid: [UInt32: AXUIElement] = [:]
1646
- for axWin in axWindows {
1647
- var windowId: CGWindowID = 0
1648
- if _AXUIElementGetWindow(axWin, &windowId) == .success {
1649
- axByWid[windowId] = axWin
1650
- }
1651
- }
1652
-
1653
- for wm in windowMoves {
1654
- guard let axWin = axByWid[wm.wid] else { continue }
1655
-
1656
- var newPos = CGPoint(x: wm.target.origin.x, y: wm.target.origin.y)
1657
- var newSize = CGSize(width: wm.target.width, height: wm.target.height)
1658
-
1659
- // Size → Position → Size (same pattern as single-window tiler)
1660
- if let sv = AXValueCreate(.cgSize, &newSize) {
1661
- AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
1662
- }
1663
- if let pv = AXValueCreate(.cgPoint, &newPos) {
1664
- AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
1665
- }
1666
- if let sv = AXValueCreate(.cgSize, &newSize) {
1667
- AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
1668
- }
1669
-
1670
- // Raise
1671
- AXUIElementPerformAction(axWin, kAXRaiseAction as CFString)
1672
- AXUIElementSetAttributeValue(axWin, kAXMainAttribute as CFString, kCFBooleanTrue)
1673
-
1674
- processed += 1
1675
- }
1676
-
1677
- // Re-enable enhanced UI
1678
- AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, true as CFTypeRef)
1679
-
1680
- // Activate each app once so its windows come to front
1681
- if !activatedPids.contains(pid) {
1682
- if let app = NSRunningApplication(processIdentifier: pid) {
1683
- app.activate()
1684
- activatedPids.insert(pid)
1685
- }
1686
- }
1687
- }
1688
-
1689
- // Unfreeze screen rendering
1690
- if let cid { _ = _SLSReenableUpdate?(cid) }
1691
- DesktopModel.shared.markInteraction(wids: moves.map(\.wid))
1692
- diag.success("batchMoveAndRaiseWindows: processed \(processed)/\(moves.count) windows")
1693
- }
1694
-
1695
- // MARK: - Grid Layout Strategy
1696
-
1697
- /// Optimal grid shapes for common window counts.
1698
- /// Returns array of column counts per row (top row first).
1699
- /// e.g. 5 → [3, 2] means 3 on top row, 2 on bottom row.
1700
- static func gridShape(for count: Int) -> [Int] {
1701
- switch count {
1702
- case 1: return [1]
1703
- case 2: return [2]
1704
- case 3: return [3]
1705
- case 4: return [2, 2]
1706
- case 5: return [3, 2]
1707
- case 6: return [3, 3]
1708
- case 7: return [4, 3]
1709
- case 8: return [4, 4]
1710
- case 9: return [3, 3, 3]
1711
- case 10: return [5, 5]
1712
- case 11: return [4, 4, 3]
1713
- case 12: return [4, 4, 4]
1714
- default:
1715
- // General: bias toward more columns (landscape screens)
1716
- let cols = Int(ceil(sqrt(Double(count) * 1.5)))
1717
- var rows: [Int] = []
1718
- var remaining = count
1719
- while remaining > 0 {
1720
- rows.append(min(cols, remaining))
1721
- remaining -= cols
1722
- }
1723
- return rows
1724
- }
1725
- }
1726
-
1727
- /// Compute grid slot rects in AX coordinates (top-left origin) for N windows.
1728
- /// If `region` is provided (fractional x, y, w, h), slots are constrained to that sub-area of the screen.
1729
- static func computeGridSlots(count: Int, screen: NSScreen, region: (CGFloat, CGFloat, CGFloat, CGFloat)? = nil) -> [CGRect] {
1730
- guard count > 0 else { return [] }
1731
- let visible = screen.visibleFrame
1732
- guard let primaryScreen = NSScreen.screens.first else { return [] }
1733
- let primaryHeight = primaryScreen.frame.height
1734
-
1735
- // Compute the target area — full visible frame or a fractional sub-region
1736
- let targetArea: CGRect
1737
- if let (rx, ry, rw, rh) = region {
1738
- targetArea = CGRect(
1739
- x: visible.origin.x + visible.width * rx,
1740
- y: visible.origin.y + visible.height * (1.0 - ry - rh), // NSRect is bottom-left origin
1741
- width: visible.width * rw,
1742
- height: visible.height * rh
1743
- )
1744
- } else {
1745
- targetArea = visible
1746
- }
1747
-
1748
- // AX Y of target area's top edge
1749
- let axTop = primaryHeight - targetArea.maxY
1750
- let shape = gridShape(for: count)
1751
- let rowCount = shape.count
1752
- let rowH = targetArea.height / CGFloat(rowCount)
1753
-
1754
- var slots: [CGRect] = []
1755
- for (row, cols) in shape.enumerated() {
1756
- let colW = targetArea.width / CGFloat(cols)
1757
- let axY = axTop + CGFloat(row) * rowH
1758
- for col in 0..<cols {
1759
- let x = targetArea.origin.x + CGFloat(col) * colW
1760
- slots.append(CGRect(x: x, y: axY, width: colW, height: rowH))
1761
- }
1762
- }
1763
- return slots
1764
- }
1765
-
1766
- /// Raise multiple windows and arrange in smart grid — single CG query, single AX query per process.
1767
- /// If `region` is provided (fractional x, y, w, h), the grid is constrained to that sub-area.
1768
- static func batchRaiseAndDistribute(
1769
- windows: [(wid: UInt32, pid: Int32)],
1770
- region: (CGFloat, CGFloat, CGFloat, CGFloat)? = nil,
1771
- reactivateLattices: Bool = true
1772
- ) {
1773
- guard !windows.isEmpty else { return }
1774
- let diag = DiagnosticLog.shared
1775
-
1776
- // Find screen from first window
1777
- guard let firstFrame = cgWindowFrame(wid: windows[0].wid) else {
1778
- diag.warn("batchRaiseAndDistribute: no frame for first window wid=\(windows[0].wid)")
1779
- return
1780
- }
1781
- let screen = NSScreen.screens.first(where: {
1782
- $0.frame.contains(NSPoint(x: firstFrame.midX, y: firstFrame.midY))
1783
- }) ?? NSScreen.main ?? NSScreen.screens[0]
1784
-
1785
- let visible = screen.visibleFrame
1786
- let screenFrame = screen.frame
1787
- let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
1788
- let shape = gridShape(for: windows.count)
1789
- let desc = shape.map(String.init).joined(separator: "+")
1790
-
1791
- diag.info("Grid layout: \(windows.count) windows → [\(desc)]")
1792
- diag.info(" Screen: \(screen.localizedName) \(Int(screenFrame.width))x\(Int(screenFrame.height))")
1793
- diag.info(" Visible: origin=(\(Int(visible.origin.x)),\(Int(visible.origin.y))) size=\(Int(visible.width))x\(Int(visible.height))")
1794
- if let region { diag.info(" Region: x=\(region.0) y=\(region.1) w=\(region.2) h=\(region.3)") }
1795
- diag.info(" Primary height: \(Int(primaryHeight))")
1796
-
1797
- // Pre-compute all target slots
1798
- let slots = computeGridSlots(count: windows.count, screen: screen, region: region)
1799
- guard slots.count == windows.count else {
1800
- diag.warn(" Slot count mismatch: \(slots.count) slots for \(windows.count) windows")
1801
- return
1802
- }
1803
-
1804
- for (i, slot) in slots.enumerated() {
1805
- diag.info(" Slot \(i): x=\(Int(slot.origin.x)) y=\(Int(slot.origin.y)) w=\(Int(slot.width)) h=\(Int(slot.height))")
1806
- }
1807
-
1808
- // Single CG query for frame lookup
1809
- let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
1810
- var cgFrames: [UInt32: CGRect] = [:]
1811
- var cgNames: [UInt32: String] = [:]
1812
- for info in windowList {
1813
- guard let num = info[kCGWindowNumber as String] as? UInt32,
1814
- let dict = info[kCGWindowBounds as String] as? NSDictionary else { continue }
1815
- var rect = CGRect.zero
1816
- if CGRectMakeWithDictionaryRepresentation(dict, &rect) { cgFrames[num] = rect }
1817
- cgNames[num] = info[kCGWindowOwnerName as String] as? String
1818
- }
1819
-
1820
- // Log before frames
1821
- for (i, win) in windows.enumerated() {
1822
- let app = cgNames[win.wid] ?? "?"
1823
- if let cg = cgFrames[win.wid] {
1824
- 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))")
1825
- } else {
1826
- diag.warn(" Before[\(i)] wid=\(win.wid) \(app): NO CG FRAME")
1827
- }
1828
- }
1829
-
1830
- // Group by pid for AX queries, keep slot mapping
1831
- var byPid: [Int32: [(slotIdx: Int, wid: UInt32, target: CGRect)]] = [:]
1832
- let moves: [(wid: UInt32, pid: Int32, frame: CGRect)] = windows.enumerated().map { index, win in
1833
- let target = slots[index]
1834
- byPid[win.pid, default: []].append((slotIdx: index, wid: win.wid, target: target))
1835
- return (wid: win.wid, pid: win.pid, frame: target)
1836
- }
1837
-
1838
- // Pass 1: Move all windows using exact wid→AX mapping.
1839
- var moved = 0
1840
- var failed: [UInt32] = []
1841
- var resolvedAXElements: [(slotIdx: Int, el: AXUIElement)] = [] // for raise pass
1842
- var activatedPids = Set<Int32>()
1843
-
1844
- let cid = _SLSMainConnectionID?()
1845
- if let cid { _ = _SLSDisableUpdate?(cid) }
1846
-
1847
- for (pid, windowMoves) in byPid {
1848
- let appRef = AXUIElementCreateApplication(pid)
1849
- AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, false as CFTypeRef)
1850
-
1851
- var windowsRef: CFTypeRef?
1852
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1853
- guard err == .success, let axWindows = windowsRef as? [AXUIElement] else {
1854
- diag.warn(" AX query failed for pid=\(pid) err=\(err.rawValue)")
1855
- failed.append(contentsOf: windowMoves.map(\.wid))
1856
- AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, true as CFTypeRef)
1857
- continue
1858
- }
1859
-
1860
- var axByWid: [UInt32: AXUIElement] = [:]
1861
- for axWin in axWindows {
1862
- var windowId: CGWindowID = 0
1863
- if _AXUIElementGetWindow(axWin, &windowId) == .success {
1864
- axByWid[windowId] = axWin
1865
- }
1866
- }
1867
-
1868
- for wm in windowMoves {
1869
- guard let axWin = axByWid[wm.wid] else {
1870
- if let cgRect = cgFrames[wm.wid] {
1871
- diag.warn(" wid=\(wm.wid): CG frame (\(Int(cgRect.origin.x)),\(Int(cgRect.origin.y)) \(Int(cgRect.width))x\(Int(cgRect.height))) — no AX wid match")
1872
- } else {
1873
- diag.warn(" wid=\(wm.wid): no CG frame and no AX wid match")
1874
- }
1875
- failed.append(wm.wid)
1876
- continue
1877
- }
1878
-
1879
- var newPos = CGPoint(x: wm.target.origin.x, y: wm.target.origin.y)
1880
- var newSize = CGSize(width: wm.target.width, height: wm.target.height)
1881
- let sizeErr1 = AXValueCreate(.cgSize, &newSize).map {
1882
- AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, $0)
1883
- }
1884
- let posErr = AXValueCreate(.cgPoint, &newPos).map {
1885
- AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, $0)
1886
- }
1887
- let sizeErr2 = AXValueCreate(.cgSize, &newSize).map {
1888
- AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, $0)
1889
- }
1890
- diag.info(" Move[\(wm.slotIdx)] wid=\(wm.wid): target=(\(Int(wm.target.origin.x)),\(Int(wm.target.origin.y))) \(Int(wm.target.width))x\(Int(wm.target.height)) sizeErr1=\(sizeErr1?.rawValue ?? -1) posErr=\(posErr?.rawValue ?? -1) sizeErr2=\(sizeErr2?.rawValue ?? -1)")
1891
- resolvedAXElements.append((slotIdx: wm.slotIdx, el: axWin))
1892
- moved += 1
1893
- }
1894
-
1895
- if !activatedPids.contains(pid) {
1896
- if let app = NSRunningApplication(processIdentifier: pid) {
1897
- app.activate()
1898
- activatedPids.insert(pid)
1899
- }
1900
- }
1901
-
1902
- AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, true as CFTypeRef)
1903
- }
1904
-
1905
- if let cid { _ = _SLSReenableUpdate?(cid) }
1906
-
1907
- // Pass 2: Raise all windows in slot order after app activation so final z-order matches the grid.
1908
- resolvedAXElements.sort { $0.slotIdx < $1.slotIdx }
1909
- for item in resolvedAXElements {
1910
- AXUIElementPerformAction(item.el, kAXRaiseAction as CFString)
1911
- AXUIElementSetAttributeValue(item.el, kAXMainAttribute as CFString, kCFBooleanTrue)
1912
- }
1913
- diag.info(" Raised \(resolvedAXElements.count) windows in slot order")
1914
-
1915
- // Verify and retry drifted windows once using the battle-tested batch mover.
1916
- let drifted = verifyMoves(moves)
1917
- if !drifted.isEmpty {
1918
- diag.warn(" Drifted after distribute: \(drifted.map(\.wid)) — retrying exact move path")
1919
- usleep(100_000)
1920
- batchMoveAndRaiseWindows(drifted)
1921
- let stillDrifted = verifyMoves(drifted)
1922
- if !stillDrifted.isEmpty {
1923
- diag.warn(" Still drifted after retry: \(stillDrifted.map(\.wid))")
1924
- }
1925
- }
1926
-
1927
- if !failed.isEmpty {
1928
- diag.warn("batchRaiseAndDistribute: failed wids=\(failed)")
1929
- }
1930
- DesktopModel.shared.markInteraction(wids: windows.map(\.wid))
1931
- diag.success("batchRaiseAndDistribute: moved \(moved)/\(windows.count) [\(desc) grid]")
1932
- if reactivateLattices {
1933
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
1934
- NSApp.activate(ignoringOtherApps: true)
1935
- }
1936
- }
1937
- }
1938
-
1939
- /// Batch restore windows to saved frames (single CG query)
1940
- static func batchRestoreWindows(_ restores: [(wid: UInt32, pid: Int32, frame: WindowFrame)]) {
1941
- let moves = restores.map { (wid: $0.wid, pid: $0.pid,
1942
- frame: CGRect(x: $0.frame.x, y: $0.frame.y,
1943
- width: $0.frame.w, height: $0.frame.h)) }
1944
- batchMoveWindows(moves)
1945
- }
1946
-
1947
- /// Restore a window to a saved frame (CG coordinates: top-left origin)
1948
- static func restoreWindowFrame(wid: UInt32, pid: Int32, frame: WindowFrame) {
1949
- guard let axWindow = findAXWindowByFrame(wid: wid, pid: pid) else {
1950
- DiagnosticLog.shared.warn("restoreWindowFrame: couldn't match AX window for wid=\(wid)")
1951
- return
1952
- }
1953
- var newPos = CGPoint(x: frame.x, y: frame.y)
1954
- var newSize = CGSize(width: frame.w, height: frame.h)
1955
- if let posValue = AXValueCreate(.cgPoint, &newPos) {
1956
- AXUIElementSetAttributeValue(axWindow, kAXPositionAttribute as CFString, posValue)
1957
- }
1958
- if let sizeValue = AXValueCreate(.cgSize, &newSize) {
1959
- AXUIElementSetAttributeValue(axWindow, kAXSizeAttribute as CFString, sizeValue)
1960
- }
1961
- DiagnosticLog.shared.success("restoreWindowFrame: restored wid=\(wid)")
1962
- }
1963
-
1964
- /// Find the AX window element for a given CG window ID by matching frames
1965
- static func findAXWindowByFrame(wid: UInt32, pid: Int32) -> AXUIElement? {
1966
- // Get CG frame for the window
1967
- guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return nil }
1968
-
1969
- var cgRect = CGRect.zero
1970
- for info in windowList {
1971
- if let num = info[kCGWindowNumber as String] as? UInt32, num == wid,
1972
- let dict = info[kCGWindowBounds as String] as? NSDictionary {
1973
- CGRectMakeWithDictionaryRepresentation(dict, &cgRect)
1974
- break
1975
- }
1976
- }
1977
- guard cgRect.width > 0 else { return nil }
1978
-
1979
- // Find AX window with matching frame
1980
- let appRef = AXUIElementCreateApplication(pid)
1981
- var windowsRef: CFTypeRef?
1982
- let err = AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef)
1983
- guard err == .success, let windows = windowsRef as? [AXUIElement] else { return nil }
1984
-
1985
- for win in windows {
1986
- var posRef: CFTypeRef?
1987
- var sizeRef: CFTypeRef?
1988
- AXUIElementCopyAttributeValue(win, kAXPositionAttribute as CFString, &posRef)
1989
- AXUIElementCopyAttributeValue(win, kAXSizeAttribute as CFString, &sizeRef)
1990
- guard let pv = posRef, let sv = sizeRef else { continue }
1991
-
1992
- var pos = CGPoint.zero
1993
- var size = CGSize.zero
1994
- AXValueGetValue(pv as! AXValue, .cgPoint, &pos)
1995
- AXValueGetValue(sv as! AXValue, .cgSize, &size)
1996
-
1997
- if abs(cgRect.origin.x - pos.x) < 2 && abs(cgRect.origin.y - pos.y) < 2 &&
1998
- abs(cgRect.width - size.width) < 2 && abs(cgRect.height - size.height) < 2 {
1999
- return win
2000
- }
2001
- }
2002
- return nil
2003
- }
2004
-
2005
- // MARK: - Any-App Tiling via Accessibility
2006
-
2007
- /// Tile the frontmost window of any app to a position using AX API.
2008
- /// Works for any application (Finder, Chrome, etc.), not just terminals.
2009
- static func tileFrontmostViaAX(to position: TilePosition) {
2010
- tileFrontmostViaAX(to: .tile(position))
2011
- }
2012
-
2013
- static func tileFrontmostViaAX(to placement: PlacementSpec) {
2014
- guard let frontApp = NSWorkspace.shared.frontmostApplication,
2015
- frontApp.bundleIdentifier != "com.arach.lattices" else { return }
2016
-
2017
- let appRef = AXUIElementCreateApplication(frontApp.processIdentifier)
2018
- var focusedRef: CFTypeRef?
2019
- guard AXUIElementCopyAttributeValue(appRef, kAXFocusedWindowAttribute as CFString, &focusedRef) == .success,
2020
- let axWindow = focusedRef else { return }
2021
- let win = axWindow as! AXUIElement
2022
-
2023
- let screen = screenForAXWindow(win)
2024
- let target = tileFrame(for: placement, on: screen)
2025
-
2026
- // Disable enhanced UI on the APP element (not window) — breaks macOS tile lock
2027
- AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, false as CFTypeRef)
2028
-
2029
- // Freeze screen rendering so the size→position→size steps aren't visible
2030
- let cid = _SLSMainConnectionID?()
2031
- if let cid { _ = _SLSDisableUpdate?(cid) }
2032
-
2033
- // Size → Position → Size (same pattern as Rectangle and rift)
2034
- var pos = CGPoint(x: target.origin.x, y: target.origin.y)
2035
- var size = CGSize(width: target.width, height: target.height)
2036
-
2037
- if let sv = AXValueCreate(.cgSize, &size) {
2038
- AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
2039
- }
2040
- if let pv = AXValueCreate(.cgPoint, &pos) {
2041
- AXUIElementSetAttributeValue(win, kAXPositionAttribute as CFString, pv)
2042
- }
2043
- if let sv = AXValueCreate(.cgSize, &size) {
2044
- AXUIElementSetAttributeValue(win, kAXSizeAttribute as CFString, sv)
2045
- }
2046
-
2047
- // Unfreeze screen rendering
2048
- if let cid { _ = _SLSReenableUpdate?(cid) }
2049
-
2050
- // Re-enable enhanced UI
2051
- AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, true as CFTypeRef)
2052
- }
2053
-
2054
- /// Find which NSScreen contains a given AX window (nearest if center is off-screen)
2055
- private static func screenForAXWindow(_ win: AXUIElement) -> NSScreen {
2056
- var posRef: CFTypeRef?
2057
- var sizeRef: CFTypeRef?
2058
- AXUIElementCopyAttributeValue(win, kAXPositionAttribute as CFString, &posRef)
2059
- AXUIElementCopyAttributeValue(win, kAXSizeAttribute as CFString, &sizeRef)
2060
-
2061
- var pos = CGPoint.zero
2062
- var size = CGSize.zero
2063
- if let pv = posRef { AXValueGetValue(pv as! AXValue, .cgPoint, &pos) }
2064
- if let sv = sizeRef { AXValueGetValue(sv as! AXValue, .cgSize, &size) }
2065
-
2066
- let primaryH = NSScreen.screens.first?.frame.height ?? 1080
2067
- let cx = pos.x + size.width / 2
2068
- let cy = primaryH - (pos.y + size.height / 2)
2069
- let pt = NSPoint(x: cx, y: cy)
2070
-
2071
- return NSScreen.screens.first(where: { $0.frame.contains(pt) })
2072
- ?? NSScreen.screens.min(by: {
2073
- hypot(cx - $0.frame.midX, cy - $0.frame.midY) <
2074
- hypot(cx - $1.frame.midX, cy - $1.frame.midY)
2075
- })
2076
- ?? NSScreen.main
2077
- ?? NSScreen.screens[0]
2078
- }
2079
-
2080
- static func screenForWindowFrame(_ frame: WindowFrame) -> NSScreen {
2081
- guard let primaryScreen = NSScreen.screens.first else {
2082
- return NSScreen.main ?? NSScreen.screens[0]
2083
- }
2084
-
2085
- let centerX = frame.x + frame.w / 2
2086
- let centerY = frame.y + frame.h / 2
2087
- let nsCenterY = primaryScreen.frame.height - centerY
2088
-
2089
- return NSScreen.screens.first(where: {
2090
- $0.frame.contains(NSPoint(x: centerX, y: nsCenterY))
2091
- }) ?? NSScreen.main ?? primaryScreen
2092
- }
2093
-
2094
- private static func currentMouseCGPoint() -> CGPoint {
2095
- let mouseLocation = NSEvent.mouseLocation
2096
- let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
2097
- return CGPoint(x: mouseLocation.x, y: primaryHeight - mouseLocation.y)
2098
- }
2099
-
2100
- private static func switchToAdjacentSpaceViaSystemShortcut(offset: Int, displayId: String, initialSpaceId: Int) -> Int? {
2101
- let keyCode: CGKeyCode = offset < 0 ? 123 : 124
2102
- guard let source = CGEventSource(stateID: .combinedSessionState),
2103
- let down = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
2104
- let up = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) else {
2105
- DiagnosticLog.shared.warn("switchToAdjacentSpace: system shortcut event source unavailable for offset \(offset)")
2106
- return nil
2107
- }
2108
- down.flags = .maskControl
2109
- up.flags = .maskControl
2110
- down.post(tap: .cghidEventTap)
2111
- usleep(12_000)
2112
- up.post(tap: .cghidEventTap)
2113
- return waitForSpaceChange(displayId: displayId, initialSpaceId: initialSpaceId, timeout: 1.2)
2114
- }
2115
-
2116
- private static func waitForSpaceChange(displayId: String, initialSpaceId: Int, timeout: TimeInterval) -> Int? {
2117
- let deadline = Date().addingTimeInterval(timeout)
2118
- while Date() < deadline {
2119
- usleep(30_000)
2120
- let current = getDisplaySpaces().first(where: { $0.displayId == displayId })?.currentSpaceId ?? 0
2121
- if current != 0, current != initialSpaceId {
2122
- return current
2123
- }
2124
- }
2125
- return nil
2126
- }
2127
- private static func displaySpaces(containing cgPoint: CGPoint) -> DisplaySpaces? {
2128
- guard let screenIndex = screenIndex(for: cgPoint) else { return nil }
2129
- return getDisplaySpaces().first(where: { $0.displayIndex == screenIndex })
2130
- }
2131
-
2132
- private static func resolvedCurrentSpaceId(for display: DisplaySpaces, activeSpaceId: Int) -> Int {
2133
- if display.spaces.contains(where: { $0.id == activeSpaceId }) {
2134
- return activeSpaceId
2135
- }
2136
- return display.currentSpaceId
2137
- }
2138
-
2139
- private static func formatCGPoint(_ point: CGPoint) -> String {
2140
- "\(Int(point.x)),\(Int(point.y))"
2141
- }
2142
- private static func screenIndex(for cgPoint: CGPoint) -> Int? {
2143
- let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
2144
- let nsPoint = NSPoint(x: cgPoint.x, y: primaryHeight - cgPoint.y)
2145
- return NSScreen.screens.firstIndex(where: { $0.frame.contains(nsPoint) })
2146
- ?? (NSScreen.main != nil ? 0 : nil)
2147
- }
2148
-
2149
- // MARK: - Private
2150
-
2151
- private static func visibleDistributableWindows() -> [WindowEntry] {
2152
- DesktopModel.shared.allWindows().filter { entry in
2153
- entry.isOnScreen &&
2154
- entry.app != "Lattices" &&
2155
- entry.frame.w > 50 &&
2156
- entry.frame.h > 50
2157
- }
2158
- }
2159
-
2160
- private static func sortWindowsForGrid(_ windows: [WindowEntry]) -> [WindowEntry] {
2161
- windows.sorted { lhs, rhs in
2162
- let rowTolerance = 40.0
2163
- let yDelta = lhs.frame.y - rhs.frame.y
2164
- if abs(yDelta) > rowTolerance {
2165
- return lhs.frame.y < rhs.frame.y
2166
- }
2167
-
2168
- let xDelta = lhs.frame.x - rhs.frame.x
2169
- if abs(xDelta) > rowTolerance {
2170
- return lhs.frame.x < rhs.frame.x
2171
- }
2172
-
2173
- return lhs.zIndex < rhs.zIndex
2174
- }
2175
- }
2176
-
2177
- private static func screenID(for screen: NSScreen) -> String {
2178
- let key = NSDeviceDescriptionKey("NSScreenNumber")
2179
- if let number = screen.deviceDescription[key] as? NSNumber {
2180
- return number.stringValue
2181
- }
2182
- return screen.localizedName
2183
- }
2184
-
2185
- private static func tileAppleScript(app: String, tag: String, bounds: (Int, Int, Int, Int)) {
2186
- let (x1, y1, x2, y2) = bounds
2187
- let script = """
2188
- tell application "\(app)"
2189
- repeat with w in windows
2190
- if name of w contains "\(tag)" then
2191
- set bounds of w to {\(x1), \(y1), \(x2), \(y2)}
2192
- set index of w to 1
2193
- exit repeat
2194
- end if
2195
- end repeat
2196
- end tell
2197
- """
2198
- runScript(script)
2199
- }
2200
-
2201
- private static func tileFrontmost(bounds: (Int, Int, Int, Int)) {
2202
- let (x1, y1, x2, y2) = bounds
2203
- let script = """
2204
- tell application "System Events"
2205
- set frontApp to name of first application process whose frontmost is true
2206
- end tell
2207
- tell application frontApp
2208
- set bounds of front window to {\(x1), \(y1), \(x2), \(y2)}
2209
- end tell
2210
- """
2211
- runScript(script)
2212
- }
2213
-
2214
- private static func runScript(_ script: String) {
2215
- let task = Process()
2216
- task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
2217
- task.arguments = ["-e", script]
2218
- task.standardOutput = FileHandle.nullDevice
2219
- task.standardError = FileHandle.nullDevice
2220
- try? task.run()
2221
- }
2222
- }