@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.
- package/README.md +5 -7
- package/apps/mac/Info.plist +2 -2
- package/apps/mac/Lattices.app/Contents/Info.plist +4 -12
- package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/bin/lattices-app.ts +110 -17
- package/bin/lattices-build +125 -0
- package/bin/lattices-dev +89 -16
- package/bin/lattices.ts +977 -16
- package/docs/agents.md +81 -4
- package/docs/ai-chat-ux-review.md +416 -0
- package/docs/api.md +135 -3
- package/docs/app.md +30 -8
- package/docs/config.md +4 -0
- package/docs/mouse-gestures.md +191 -63
- package/docs/proposals/LAT-004-interactive-overlay-actors.md +1 -1
- package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
- package/docs/proposals/LAT-006-mira-in-lattices.md +553 -0
- package/docs/reference/dewey.config.ts +2 -2
- package/docs/release.md +171 -0
- package/docs/repo-structure.md +4 -5
- package/docs/voice.md +11 -27
- package/package.json +9 -10
- package/apps/mac/Package.swift +0 -27
- package/apps/mac/Sources/AppShell/App.swift +0 -26
- package/apps/mac/Sources/AppShell/AppActivationCoordinator.swift +0 -27
- package/apps/mac/Sources/AppShell/AppDelegate.swift +0 -189
- package/apps/mac/Sources/AppShell/AppServicesBootstrap.swift +0 -25
- package/apps/mac/Sources/AppShell/AppShellView.swift +0 -171
- package/apps/mac/Sources/AppShell/AppUpdater.swift +0 -305
- package/apps/mac/Sources/AppShell/CliActionLauncher.swift +0 -50
- package/apps/mac/Sources/AppShell/HomeDashboardView.swift +0 -133
- package/apps/mac/Sources/AppShell/HotkeyBootstrap.swift +0 -87
- package/apps/mac/Sources/AppShell/KeyRecorderView.swift +0 -210
- package/apps/mac/Sources/AppShell/LatticesRuntime.swift +0 -104
- package/apps/mac/Sources/AppShell/MainView.swift +0 -847
- package/apps/mac/Sources/AppShell/MainWindow.swift +0 -83
- package/apps/mac/Sources/AppShell/MenuBarController.swift +0 -177
- package/apps/mac/Sources/AppShell/OnboardingView.swift +0 -483
- package/apps/mac/Sources/AppShell/PermissionsAssistantView.swift +0 -366
- package/apps/mac/Sources/AppShell/PermissionsAssistantWindow.swift +0 -70
- package/apps/mac/Sources/AppShell/Preferences.swift +0 -297
- package/apps/mac/Sources/AppShell/SettingsView.swift +0 -3163
- package/apps/mac/Sources/AppShell/SettingsWindow.swift +0 -34
- package/apps/mac/Sources/AppShell/WorkspaceInspectorPresenter.swift +0 -13
- package/apps/mac/Sources/Core/Actions/HotkeyManager.swift +0 -256
- package/apps/mac/Sources/Core/Actions/HotkeyStore.swift +0 -399
- package/apps/mac/Sources/Core/Actions/IntentEngine.swift +0 -988
- package/apps/mac/Sources/Core/Actions/IntentSchema.swift +0 -94
- package/apps/mac/Sources/Core/Actions/Intents/CreateLayerIntent.swift +0 -54
- package/apps/mac/Sources/Core/Actions/Intents/DistributeIntent.swift +0 -56
- package/apps/mac/Sources/Core/Actions/Intents/FocusIntent.swift +0 -69
- package/apps/mac/Sources/Core/Actions/Intents/HelpIntent.swift +0 -41
- package/apps/mac/Sources/Core/Actions/Intents/KillIntent.swift +0 -47
- package/apps/mac/Sources/Core/Actions/Intents/LatticeIntent.swift +0 -53
- package/apps/mac/Sources/Core/Actions/Intents/LaunchIntent.swift +0 -67
- package/apps/mac/Sources/Core/Actions/Intents/ListSessionsIntent.swift +0 -32
- package/apps/mac/Sources/Core/Actions/Intents/ListWindowsIntent.swift +0 -30
- package/apps/mac/Sources/Core/Actions/Intents/ScanIntent.swift +0 -52
- package/apps/mac/Sources/Core/Actions/Intents/SearchIntent.swift +0 -190
- package/apps/mac/Sources/Core/Actions/Intents/SwitchLayerIntent.swift +0 -50
- package/apps/mac/Sources/Core/Actions/Intents/TileIntent.swift +0 -61
- package/apps/mac/Sources/Core/Actions/PaletteCommand.swift +0 -439
- package/apps/mac/Sources/Core/Actions/VoiceIntentResolver.swift +0 -713
- package/apps/mac/Sources/Core/Companion/CompanionActivityLog.swift +0 -70
- package/apps/mac/Sources/Core/Companion/CompanionKeyboardController.swift +0 -141
- package/apps/mac/Sources/Core/Companion/LatticesCompanionBridgeServer.swift +0 -454
- package/apps/mac/Sources/Core/Companion/LatticesCompanionCockpit.swift +0 -555
- package/apps/mac/Sources/Core/Companion/LatticesCompanionSecurityCoordinator.swift +0 -629
- package/apps/mac/Sources/Core/Companion/LatticesCompanionTrackpadController.swift +0 -204
- package/apps/mac/Sources/Core/Companion/LatticesDeckHost.swift +0 -1463
- package/apps/mac/Sources/Core/Daemon/DaemonProtocol.swift +0 -114
- package/apps/mac/Sources/Core/Daemon/DaemonServer.swift +0 -427
- package/apps/mac/Sources/Core/Daemon/LatticesApi.swift +0 -2965
- package/apps/mac/Sources/Core/Desktop/AccessibilityTextExtractor.swift +0 -111
- package/apps/mac/Sources/Core/Desktop/AppTypeClassifier.swift +0 -106
- package/apps/mac/Sources/Core/Desktop/DesktopModel.swift +0 -331
- package/apps/mac/Sources/Core/Desktop/DesktopModelTypes.swift +0 -73
- package/apps/mac/Sources/Core/Desktop/InventoryManager.swift +0 -35
- package/apps/mac/Sources/Core/Desktop/InventoryPath.swift +0 -43
- package/apps/mac/Sources/Core/Desktop/MouseFinder.swift +0 -527
- package/apps/mac/Sources/Core/Desktop/OcrModel.swift +0 -467
- package/apps/mac/Sources/Core/Desktop/OcrStore.swift +0 -329
- package/apps/mac/Sources/Core/Desktop/PlacementSpec.swift +0 -195
- package/apps/mac/Sources/Core/Desktop/SessionWindowLocator.swift +0 -139
- package/apps/mac/Sources/Core/Desktop/TilePickerView.swift +0 -209
- package/apps/mac/Sources/Core/Desktop/WindowCapture.swift +0 -33
- package/apps/mac/Sources/Core/Desktop/WindowDragSnapController.swift +0 -429
- package/apps/mac/Sources/Core/Desktop/WindowPreviewCard.swift +0 -100
- package/apps/mac/Sources/Core/Desktop/WindowPreviewStore.swift +0 -112
- package/apps/mac/Sources/Core/Desktop/WindowSelectionStore.swift +0 -76
- package/apps/mac/Sources/Core/Desktop/WindowTiler.swift +0 -2222
- package/apps/mac/Sources/Core/Input/EventTapBreaker.swift +0 -124
- package/apps/mac/Sources/Core/Input/EventTapThread.swift +0 -54
- package/apps/mac/Sources/Core/Input/InputCaptureResetCenter.swift +0 -20
- package/apps/mac/Sources/Core/Input/KeyboardRemapConfig.swift +0 -69
- package/apps/mac/Sources/Core/Input/KeyboardRemapController.swift +0 -346
- package/apps/mac/Sources/Core/Input/KeyboardRemapStore.swift +0 -141
- package/apps/mac/Sources/Core/Input/MouseGestureConfig.swift +0 -499
- package/apps/mac/Sources/Core/Input/MouseGestureController.swift +0 -2271
- package/apps/mac/Sources/Core/Input/MouseInputDeviceStore.swift +0 -98
- package/apps/mac/Sources/Core/Input/MouseInputEventViewer.swift +0 -272
- package/apps/mac/Sources/Core/Input/MouseShortcutStore.swift +0 -170
- package/apps/mac/Sources/Core/Input/SecureEventInputMonitor.swift +0 -39
- package/apps/mac/Sources/Core/Input/ShapeRecognizer.swift +0 -624
- package/apps/mac/Sources/Core/Input/TapBudgetMeter.swift +0 -56
- package/apps/mac/Sources/Core/Overlays/AppWindowShell.swift +0 -63
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeState.swift +0 -1566
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeView.swift +0 -1927
- package/apps/mac/Sources/Core/Overlays/CommandMode/CommandModeWindow.swift +0 -196
- package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteView.swift +0 -307
- package/apps/mac/Sources/Core/Overlays/CommandPalette/CommandPaletteWindow.swift +0 -67
- package/apps/mac/Sources/Core/Overlays/HUD/CheatSheetHUD.swift +0 -576
- package/apps/mac/Sources/Core/Overlays/HUD/HUDBottomBar.swift +0 -279
- package/apps/mac/Sources/Core/Overlays/HUD/HUDController.swift +0 -1158
- package/apps/mac/Sources/Core/Overlays/HUD/HUDLeftBar.swift +0 -849
- package/apps/mac/Sources/Core/Overlays/HUD/HUDMinimap.swift +0 -179
- package/apps/mac/Sources/Core/Overlays/HUD/HUDRightBar.swift +0 -596
- package/apps/mac/Sources/Core/Overlays/HUD/HUDState.swift +0 -367
- package/apps/mac/Sources/Core/Overlays/HUD/HUDTopBar.swift +0 -243
- package/apps/mac/Sources/Core/Overlays/HUD/LauncherHUD.swift +0 -334
- package/apps/mac/Sources/Core/Overlays/HUD/LayerBezel.swift +0 -203
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchState.swift +0 -280
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchView.swift +0 -422
- package/apps/mac/Sources/Core/Overlays/OmniSearch/OmniSearchWindow.swift +0 -94
- package/apps/mac/Sources/Core/Overlays/OverlayPanelShell.swift +0 -241
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapState.swift +0 -3135
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapView.swift +0 -3977
- package/apps/mac/Sources/Core/Overlays/ScreenMap/ScreenMapWindowController.swift +0 -119
- package/apps/mac/Sources/Core/Overlays/ScreenOverlayCanvasController.swift +0 -1217
- package/apps/mac/Sources/Core/Overlays/Voice/VoiceCommandWindow.swift +0 -1575
- package/apps/mac/Sources/Core/Pi/PiAuthNextStepCard.swift +0 -148
- package/apps/mac/Sources/Core/Pi/PiAuthPromptCard.swift +0 -90
- package/apps/mac/Sources/Core/Pi/PiChatDock.swift +0 -564
- package/apps/mac/Sources/Core/Pi/PiChatSession.swift +0 -1948
- package/apps/mac/Sources/Core/Pi/PiInstallCallout.swift +0 -86
- package/apps/mac/Sources/Core/Pi/PiProviderSetupCallout.swift +0 -99
- package/apps/mac/Sources/Core/Pi/PiWorkspaceView.swift +0 -510
- package/apps/mac/Sources/Core/System/Capability.swift +0 -79
- package/apps/mac/Sources/Core/System/DiagnosticLog.swift +0 -373
- package/apps/mac/Sources/Core/System/EventBus.swift +0 -31
- package/apps/mac/Sources/Core/System/PermissionChecker.swift +0 -224
- package/apps/mac/Sources/Core/System/ProcessModel.swift +0 -199
- package/apps/mac/Sources/Core/System/ProcessQuery.swift +0 -151
- package/apps/mac/Sources/Core/System/SystemTelemetryMonitor.swift +0 -273
- package/apps/mac/Sources/Core/Voice/AdvisorLearningStore.swift +0 -90
- package/apps/mac/Sources/Core/Voice/AgentSession.swift +0 -377
- package/apps/mac/Sources/Core/Voice/AudioProvider.swift +0 -555
- package/apps/mac/Sources/Core/Voice/HandsOffSession.swift +0 -839
- package/apps/mac/Sources/Core/Voice/VoiceChatView.swift +0 -192
- package/apps/mac/Sources/Core/Voice/VoxClient.swift +0 -454
- package/apps/mac/Sources/Core/Workspace/Project.swift +0 -28
- package/apps/mac/Sources/Core/Workspace/ProjectScanner.swift +0 -141
- package/apps/mac/Sources/Core/Workspace/SessionLayerStore.swift +0 -285
- package/apps/mac/Sources/Core/Workspace/SessionManager.swift +0 -75
- package/apps/mac/Sources/Core/Workspace/Terminal/Terminal.swift +0 -259
- package/apps/mac/Sources/Core/Workspace/Terminal/TerminalQuery.swift +0 -156
- package/apps/mac/Sources/Core/Workspace/Terminal/TerminalSynthesizer.swift +0 -200
- package/apps/mac/Sources/Core/Workspace/Tmux/TmuxModel.swift +0 -60
- package/apps/mac/Sources/Core/Workspace/Tmux/TmuxQuery.swift +0 -105
- package/apps/mac/Sources/Core/Workspace/WorkspaceManager.swift +0 -1027
- package/apps/mac/Sources/UI/ActionRow.swift +0 -78
- package/apps/mac/Sources/UI/OrphanRow.swift +0 -129
- package/apps/mac/Sources/UI/ProjectRow.swift +0 -368
- package/apps/mac/Sources/UI/TabGroupRow.swift +0 -178
- package/apps/mac/Sources/UI/Theme.swift +0 -164
- package/apps/mac/Tests/StageDragTests.swift +0 -333
- package/apps/mac/Tests/StageJoinTests.swift +0 -313
- package/apps/mac/Tests/StageManagerTests.swift +0 -280
- package/apps/mac/Tests/StageTileTests.swift +0 -353
- package/swift/Package.swift +0 -20
- package/swift/Sources/DeckKit/DeckAction.swift +0 -51
- package/swift/Sources/DeckKit/DeckBridgeSecurity.swift +0 -152
- package/swift/Sources/DeckKit/DeckCockpit.swift +0 -82
- package/swift/Sources/DeckKit/DeckHost.swift +0 -7
- package/swift/Sources/DeckKit/DeckManifest.swift +0 -145
- package/swift/Sources/DeckKit/DeckRuntimeSnapshot.swift +0 -533
- package/swift/Sources/DeckKit/DeckTrackpad.swift +0 -63
- package/swift/Sources/DeckKit/DeckValue.swift +0 -93
- package/swift/Sources/DeckKit/DeckVoiceError.swift +0 -88
- 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
|
-
}
|