@lattices/cli 0.4.0 → 0.4.2

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.
@@ -0,0 +1,222 @@
1
+ import AppKit
2
+ import CoreGraphics
3
+
4
+ /// Locates the mouse cursor with an animated sonar pulse overlay.
5
+ /// "Find" shows rings at the current cursor position.
6
+ /// "Summon" warps the cursor to screen center (or a given point).
7
+ final class MouseFinder {
8
+ static let shared = MouseFinder()
9
+
10
+ private var overlayWindows: [NSWindow] = []
11
+ private var dismissTimer: Timer?
12
+ private var animationTimer: Timer?
13
+ private var animationStart: CFTimeInterval = 0
14
+ private let animationDuration: CFTimeInterval = 1.5
15
+
16
+ // MARK: - Find (highlight current position)
17
+
18
+ func find() {
19
+ let pos = NSEvent.mouseLocation
20
+ showSonar(at: pos)
21
+ }
22
+
23
+ // MARK: - Summon (warp to center of the screen the mouse is on, or a specific point)
24
+
25
+ func summon(to point: CGPoint? = nil) {
26
+ let target: NSPoint
27
+ if let point {
28
+ target = point
29
+ } else {
30
+ let screen = mouseScreen()
31
+ let frame = screen.frame
32
+ target = NSPoint(x: frame.midX, y: frame.midY)
33
+ }
34
+
35
+ // CGWarpMouseCursorPosition uses top-left origin
36
+ let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
37
+ let cgPoint = CGPoint(x: target.x, y: primaryHeight - target.y)
38
+ CGWarpMouseCursorPosition(cgPoint)
39
+ // Re-associate mouse with cursor position after warp
40
+ CGAssociateMouseAndMouseCursorPosition(1)
41
+
42
+ showSonar(at: target)
43
+ }
44
+
45
+ // MARK: - Sonar Animation
46
+
47
+ private func showSonar(at nsPoint: NSPoint) {
48
+ dismiss()
49
+
50
+ let screens = NSScreen.screens
51
+ guard !screens.isEmpty else { return }
52
+
53
+ let ringCount = 3
54
+ let maxRadius: CGFloat = 120
55
+ let totalSize = maxRadius * 2 + 20
56
+
57
+ for screen in screens {
58
+ // Only show on screens near the cursor
59
+ let extendedBounds = screen.frame.insetBy(dx: -maxRadius, dy: -maxRadius)
60
+ guard extendedBounds.contains(nsPoint) else { continue }
61
+
62
+ let windowFrame = NSRect(
63
+ x: nsPoint.x - totalSize / 2,
64
+ y: nsPoint.y - totalSize / 2,
65
+ width: totalSize,
66
+ height: totalSize
67
+ )
68
+
69
+ let window = NSWindow(
70
+ contentRect: windowFrame,
71
+ styleMask: .borderless,
72
+ backing: .buffered,
73
+ defer: false
74
+ )
75
+ window.isOpaque = false
76
+ window.backgroundColor = .clear
77
+ window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
78
+ window.hasShadow = false
79
+ window.ignoresMouseEvents = true
80
+ window.collectionBehavior = [.canJoinAllSpaces, .stationary]
81
+
82
+ let sonarView = SonarView(
83
+ frame: NSRect(origin: .zero, size: windowFrame.size),
84
+ ringCount: ringCount,
85
+ maxRadius: maxRadius
86
+ )
87
+ window.contentView = sonarView
88
+
89
+ window.alphaValue = 0
90
+ window.orderFrontRegardless()
91
+ overlayWindows.append(window)
92
+
93
+ // Fade in
94
+ NSAnimationContext.runAnimationGroup { ctx in
95
+ ctx.duration = 0.1
96
+ window.animator().alphaValue = 1.0
97
+ }
98
+ }
99
+
100
+ // Animate the rings expanding using CACurrentMediaTime for state
101
+ animationStart = CACurrentMediaTime()
102
+ let interval = 1.0 / 60.0
103
+
104
+ animationTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in
105
+ guard let self else { timer.invalidate(); return }
106
+ let elapsed = CACurrentMediaTime() - self.animationStart
107
+ let progress = CGFloat(min(elapsed / self.animationDuration, 1.0))
108
+
109
+ for window in self.overlayWindows {
110
+ (window.contentView as? SonarView)?.progress = progress
111
+ window.contentView?.needsDisplay = true
112
+ }
113
+
114
+ if progress >= 1.0 {
115
+ timer.invalidate()
116
+ self.animationTimer = nil
117
+ }
118
+ }
119
+
120
+ // Auto-dismiss after animation + hold
121
+ dismissTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: false) { [weak self] _ in
122
+ self?.fadeOut()
123
+ }
124
+ }
125
+
126
+ private func fadeOut() {
127
+ let windows = overlayWindows
128
+ NSAnimationContext.runAnimationGroup({ ctx in
129
+ ctx.duration = 0.4
130
+ for window in windows {
131
+ window.animator().alphaValue = 0
132
+ }
133
+ }, completionHandler: { [weak self] in
134
+ self?.dismiss()
135
+ })
136
+ }
137
+
138
+ func dismiss() {
139
+ animationTimer?.invalidate()
140
+ animationTimer = nil
141
+ dismissTimer?.invalidate()
142
+ dismissTimer = nil
143
+ for window in overlayWindows {
144
+ window.orderOut(nil)
145
+ }
146
+ overlayWindows.removeAll()
147
+ }
148
+
149
+ private func mouseScreen() -> NSScreen {
150
+ let pos = NSEvent.mouseLocation
151
+ return NSScreen.screens.first(where: { $0.frame.contains(pos) }) ?? NSScreen.screens[0]
152
+ }
153
+ }
154
+
155
+ // MARK: - Sonar Ring View
156
+
157
+ private class SonarView: NSView {
158
+ let ringCount: Int
159
+ let maxRadius: CGFloat
160
+ var progress: CGFloat = 0
161
+
162
+ init(frame: NSRect, ringCount: Int, maxRadius: CGFloat) {
163
+ self.ringCount = ringCount
164
+ self.maxRadius = maxRadius
165
+ super.init(frame: frame)
166
+ }
167
+
168
+ required init?(coder: NSCoder) { fatalError() }
169
+
170
+ override func draw(_ dirtyRect: NSRect) {
171
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
172
+
173
+ let center = CGPoint(x: bounds.midX, y: bounds.midY)
174
+
175
+ // Draw rings from outermost to innermost
176
+ for i in 0..<ringCount {
177
+ let ringDelay = CGFloat(i) * 0.15
178
+ let denom = 1.0 - ringDelay * CGFloat(ringCount - 1) / CGFloat(ringCount)
179
+ let ringProgress = max(0, min(1, (progress - ringDelay) / denom))
180
+
181
+ guard ringProgress > 0 else { continue }
182
+
183
+ // Ease out cubic
184
+ let eased = 1.0 - pow(1.0 - ringProgress, 3)
185
+
186
+ let radius = maxRadius * eased
187
+ let alpha = (1.0 - eased) * 0.8
188
+
189
+ // Ring stroke
190
+ ctx.setStrokeColor(NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: alpha).cgColor)
191
+ ctx.setLineWidth(2.5 - CGFloat(i) * 0.5)
192
+ ctx.addEllipse(in: CGRect(
193
+ x: center.x - radius,
194
+ y: center.y - radius,
195
+ width: radius * 2,
196
+ height: radius * 2
197
+ ))
198
+ ctx.strokePath()
199
+ }
200
+
201
+ // Center dot — stays visible
202
+ let dotRadius: CGFloat = 6
203
+ let dotAlpha = max(0.3, 1.0 - progress * 0.5)
204
+ ctx.setFillColor(NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: dotAlpha).cgColor)
205
+ ctx.fillEllipse(in: CGRect(
206
+ x: center.x - dotRadius,
207
+ y: center.y - dotRadius,
208
+ width: dotRadius * 2,
209
+ height: dotRadius * 2
210
+ ))
211
+
212
+ // Outer glow on center dot
213
+ ctx.setFillColor(NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: dotAlpha * 0.2).cgColor)
214
+ let glowRadius: CGFloat = 12
215
+ ctx.fillEllipse(in: CGRect(
216
+ x: center.x - glowRadius,
217
+ y: center.y - glowRadius,
218
+ width: glowRadius * 2,
219
+ height: glowRadius * 2
220
+ ))
221
+ }
222
+ }
@@ -15,53 +15,66 @@ class ProjectScanner: ObservableObject {
15
15
  self.scanRoot = root
16
16
  }
17
17
 
18
+ private static let scanQueue = DispatchQueue(label: "com.lattices.project-scanner", qos: .userInitiated)
19
+ private var scanInFlight = false
20
+
18
21
  func scan() {
19
- let diag = DiagnosticLog.shared
22
+ guard !scanInFlight else { return }
23
+ scanInFlight = true
24
+ let root = scanRoot
25
+
26
+ Self.scanQueue.async { [weak self] in
27
+ guard let self else { return }
28
+ let diag = DiagnosticLog.shared
29
+
30
+ // Use find to locate all .lattices.json files — no manual directory walking
31
+ let tFind = diag.startTimed("ProjectScanner: find .lattices.json")
32
+ let task = Process()
33
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/find")
34
+ task.arguments = [root, "-name", ".lattices.json", "-maxdepth", "3", "-not", "-path", "*/.git/*", "-not", "-path", "*/node_modules/*"]
35
+ let pipe = Pipe()
36
+ task.standardOutput = pipe
37
+ task.standardError = FileHandle.nullDevice
38
+ try? task.run()
39
+ task.waitUntilExit()
40
+ diag.finish(tFind)
41
+
42
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
43
+ let output = String(data: data, encoding: .utf8) ?? ""
44
+ let configPaths = output.split(separator: "\n").map(String.init).filter { !$0.isEmpty }
45
+
46
+ let tParse = diag.startTimed("ProjectScanner: parse \(configPaths.count) configs")
47
+ var found: [Project] = []
48
+
49
+ for configPath in configPaths.sorted() {
50
+ let projectPath = (configPath as NSString).deletingLastPathComponent
51
+ let name = (projectPath as NSString).lastPathComponent
52
+ let (devCmd, pm) = self.detectDevCommand(at: projectPath)
53
+ let paneInfo = self.readPaneInfo(at: configPath)
54
+
55
+ var project = Project(
56
+ id: projectPath,
57
+ path: projectPath,
58
+ name: name,
59
+ devCommand: devCmd,
60
+ packageManager: pm,
61
+ hasConfig: true,
62
+ paneCount: paneInfo.count,
63
+ paneNames: paneInfo.names,
64
+ paneSummary: paneInfo.summary,
65
+ isRunning: false
66
+ )
67
+ project.isRunning = self.isSessionRunning(project.sessionName)
68
+ found.append(project)
69
+ }
70
+ diag.finish(tParse)
20
71
 
21
- // Use find to locate all .lattices.json files — no manual directory walking
22
- let tFind = diag.startTimed("ProjectScanner: find .lattices.json")
23
- let task = Process()
24
- task.executableURL = URL(fileURLWithPath: "/usr/bin/find")
25
- task.arguments = [scanRoot, "-name", ".lattices.json", "-maxdepth", "3", "-not", "-path", "*/.git/*", "-not", "-path", "*/node_modules/*"]
26
- let pipe = Pipe()
27
- task.standardOutput = pipe
28
- task.standardError = FileHandle.nullDevice
29
- try? task.run()
30
- task.waitUntilExit()
31
- diag.finish(tFind)
32
-
33
- let data = pipe.fileHandleForReading.readDataToEndOfFile()
34
- let output = String(data: data, encoding: .utf8) ?? ""
35
- let configPaths = output.split(separator: "\n").map(String.init).filter { !$0.isEmpty }
36
-
37
- let tParse = diag.startTimed("ProjectScanner: parse \(configPaths.count) configs")
38
- var found: [Project] = []
39
-
40
- for configPath in configPaths.sorted() {
41
- let projectPath = (configPath as NSString).deletingLastPathComponent
42
- let name = (projectPath as NSString).lastPathComponent
43
- let (devCmd, pm) = detectDevCommand(at: projectPath)
44
- let paneInfo = readPaneInfo(at: configPath)
45
-
46
- var project = Project(
47
- id: projectPath,
48
- path: projectPath,
49
- name: name,
50
- devCommand: devCmd,
51
- packageManager: pm,
52
- hasConfig: true,
53
- paneCount: paneInfo.count,
54
- paneNames: paneInfo.names,
55
- paneSummary: paneInfo.summary,
56
- isRunning: false
57
- )
58
- project.isRunning = isSessionRunning(project.sessionName)
59
- found.append(project)
72
+ diag.info("ProjectScanner: found \(found.count) projects (\(found.filter(\.isRunning).count) running)")
73
+ DispatchQueue.main.async {
74
+ self.projects = found
75
+ self.scanInFlight = false
76
+ }
60
77
  }
61
- diag.finish(tParse)
62
-
63
- diag.info("ProjectScanner: found \(found.count) projects (\(found.filter(\.isRunning).count) running)")
64
- DispatchQueue.main.async { self.projects = found }
65
78
  }
66
79
 
67
80
  func refreshStatus() {
@@ -0,0 +1,333 @@
1
+ import XCTest
2
+ import CoreGraphics
3
+ import AppKit
4
+
5
+ /// Attempt to create stages by simulating drag gestures from the strip.
6
+ ///
7
+ /// When a user drags a strip thumbnail into the center stage, SM joins them.
8
+ /// Can we replicate this with synthetic mouse events?
9
+ final class StageDragTests: XCTestCase {
10
+
11
+ // MARK: - Helpers
12
+
13
+ struct LiveWindow {
14
+ let wid: UInt32
15
+ let app: String
16
+ let pid: Int32
17
+ let title: String
18
+ let bounds: CGRect
19
+ let isOnScreen: Bool
20
+ }
21
+
22
+ func getRealWindows() -> [LiveWindow] {
23
+ guard let list = CGWindowListCopyWindowInfo(
24
+ [.optionAll, .excludeDesktopElements],
25
+ kCGNullWindowID
26
+ ) as? [[String: Any]] else { return [] }
27
+
28
+ let skip: Set<String> = [
29
+ "Window Server", "Dock", "Control Center", "SystemUIServer",
30
+ "Notification Center", "Spotlight", "WindowManager", "Lattices",
31
+ ]
32
+
33
+ return list.compactMap { info in
34
+ guard let wid = info[kCGWindowNumber as String] as? UInt32,
35
+ let owner = info[kCGWindowOwnerName as String] as? String,
36
+ let pid = info[kCGWindowOwnerPID as String] as? Int32,
37
+ let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
38
+ else { return nil }
39
+
40
+ var rect = CGRect.zero
41
+ guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { return nil }
42
+ let title = info[kCGWindowName as String] as? String ?? ""
43
+ let layer = info[kCGWindowLayer as String] as? Int ?? 0
44
+ let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? false
45
+
46
+ guard layer == 0, rect.width >= 50, rect.height >= 50 else { return nil }
47
+ guard !skip.contains(owner) else { return nil }
48
+
49
+ return LiveWindow(wid: wid, app: owner, pid: pid, title: title,
50
+ bounds: rect, isOnScreen: isOnScreen)
51
+ }
52
+ }
53
+
54
+ /// Find strip thumbnails (small onscreen windows on the left edge)
55
+ func getStripThumbnails() -> [LiveWindow] {
56
+ getRealWindows().filter {
57
+ $0.isOnScreen && $0.bounds.width < 250 && $0.bounds.height < 250
58
+ && $0.bounds.origin.x < 220 && $0.bounds.origin.x >= 0
59
+ }
60
+ }
61
+
62
+ /// Find active stage windows (large onscreen windows)
63
+ func getActiveStage() -> [LiveWindow] {
64
+ getRealWindows().filter { $0.isOnScreen && $0.bounds.width > 250 }
65
+ }
66
+
67
+ func printStageState(label: String) {
68
+ let active = getActiveStage()
69
+ let strip = getStripThumbnails()
70
+ print("\n[\(label)]")
71
+ print(" Active: \(active.map { "\($0.app)(\($0.wid))" }.joined(separator: ", "))")
72
+ print(" Strip: \(Set(strip.map(\.app)).sorted().joined(separator: ", "))")
73
+ }
74
+
75
+ // MARK: - Synthetic mouse event helpers
76
+
77
+ /// Post a mouse event at a screen coordinate
78
+ func postMouse(_ type: CGEventType, at point: CGPoint, button: CGMouseButton = .left) {
79
+ guard let event = CGEvent(
80
+ mouseEventSource: nil,
81
+ mouseType: type,
82
+ mouseCursorPosition: point,
83
+ mouseButton: button
84
+ ) else { return }
85
+ event.post(tap: .cghidEventTap)
86
+ }
87
+
88
+ /// Simulate a smooth drag from point A to point B
89
+ func simulateDrag(from start: CGPoint, to end: CGPoint, steps: Int = 30, duration: TimeInterval = 0.4) {
90
+ // Mouse down at start
91
+ postMouse(.leftMouseDown, at: start)
92
+ Thread.sleep(forTimeInterval: 0.05)
93
+
94
+ // Interpolate drag path
95
+ for i in 1...steps {
96
+ let t = CGFloat(i) / CGFloat(steps)
97
+ let x = start.x + (end.x - start.x) * t
98
+ let y = start.y + (end.y - start.y) * t
99
+ postMouse(.leftMouseDragged, at: CGPoint(x: x, y: y))
100
+ Thread.sleep(forTimeInterval: duration / TimeInterval(steps))
101
+ }
102
+
103
+ // Mouse up at end
104
+ postMouse(.leftMouseUp, at: end)
105
+ }
106
+
107
+ // MARK: - Approach 5: Drag strip thumbnail to center
108
+
109
+ func testJoinByDragFromStrip() throws {
110
+ let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
111
+ try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
112
+
113
+ let thumbnails = getStripThumbnails()
114
+ let active = getActiveStage()
115
+
116
+ guard !thumbnails.isEmpty else {
117
+ print("No strip thumbnails found")
118
+ return
119
+ }
120
+ guard let anchor = active.first else {
121
+ print("No active stage window")
122
+ return
123
+ }
124
+
125
+ // Target specific apps: Chrome, iTerm2, Vox
126
+ let activeApps = Set(active.map(\.app))
127
+ let preferred = ["Google Chrome", "iTerm2", "Vox"]
128
+ guard let thumb = thumbnails.first(where: { preferred.contains($0.app) && !activeApps.contains($0.app) })
129
+ ?? thumbnails.first(where: { !activeApps.contains($0.app) }) else {
130
+ print("No suitable strip thumbnail found")
131
+ return
132
+ }
133
+
134
+ let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
135
+ let stageCenter = CGPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
136
+
137
+ print("Dragging \(thumb.app) thumbnail from strip (\(Int(thumbCenter.x)),\(Int(thumbCenter.y))) to center (\(Int(stageCenter.x)),\(Int(stageCenter.y)))")
138
+ printStageState(label: "BEFORE")
139
+
140
+ simulateDrag(from: thumbCenter, to: stageCenter, steps: 40, duration: 0.6)
141
+
142
+ Thread.sleep(forTimeInterval: 0.8)
143
+ printStageState(label: "After drag to center")
144
+
145
+ let finalActive = getActiveStage()
146
+ let finalApps = Set(finalActive.map(\.app))
147
+ let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
148
+ print("\nResult: \(thumb.app) joined stage? \(joined)")
149
+ print("Active apps: \(finalApps.sorted())")
150
+ }
151
+
152
+ // MARK: - Approach 6: Drag thumbnail to top half (join zone)
153
+
154
+ func testJoinByDragToTopHalf() throws {
155
+ let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
156
+ try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
157
+
158
+ let thumbnails = getStripThumbnails()
159
+ let active = getActiveStage()
160
+
161
+ guard !thumbnails.isEmpty, let anchor = active.first else { return }
162
+
163
+ let activeApps = Set(active.map(\.app))
164
+ let preferred6 = ["Google Chrome", "iTerm2", "Vox"]
165
+ guard let thumb = thumbnails.first(where: { preferred6.contains($0.app) && !activeApps.contains($0.app) })
166
+ ?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
167
+
168
+ // SM has specific drop zones. Try dragging to the top half of the active
169
+ // stage area — this might be the "join" zone vs "replace" zone.
170
+ let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
171
+ let topHalf = CGPoint(x: anchor.bounds.midX, y: anchor.bounds.origin.y + 100)
172
+
173
+ print("Dragging \(thumb.app) to top-half of active area (\(Int(topHalf.x)),\(Int(topHalf.y)))")
174
+ printStageState(label: "BEFORE")
175
+
176
+ simulateDrag(from: thumbCenter, to: topHalf, steps: 50, duration: 0.8)
177
+
178
+ Thread.sleep(forTimeInterval: 0.8)
179
+ printStageState(label: "After drag to top half")
180
+
181
+ let finalApps = Set(getActiveStage().map(\.app))
182
+ let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
183
+ print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
184
+ }
185
+
186
+ // MARK: - Approach 7: Long press on thumbnail, then drag
187
+
188
+ func testJoinByLongPressDrag() throws {
189
+ let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
190
+ try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
191
+
192
+ let thumbnails = getStripThumbnails()
193
+ let active = getActiveStage()
194
+
195
+ guard !thumbnails.isEmpty, let anchor = active.first else { return }
196
+
197
+ let activeApps = Set(active.map(\.app))
198
+ let preferred7 = ["Google Chrome", "iTerm2", "Vox"]
199
+ guard let thumb = thumbnails.first(where: { preferred7.contains($0.app) && !activeApps.contains($0.app) })
200
+ ?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
201
+
202
+ let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
203
+ let stageCenter = CGPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
204
+
205
+ print("Long-press + drag \(thumb.app) from strip to center")
206
+ printStageState(label: "BEFORE")
207
+
208
+ // Long press: mouse down + wait
209
+ postMouse(.leftMouseDown, at: thumbCenter)
210
+ Thread.sleep(forTimeInterval: 0.8) // Hold for long press recognition
211
+
212
+ // Slow drag out of strip area
213
+ let steps = 60
214
+ let duration: TimeInterval = 1.0
215
+ for i in 1...steps {
216
+ let t = CGFloat(i) / CGFloat(steps)
217
+ let x = thumbCenter.x + (stageCenter.x - thumbCenter.x) * t
218
+ let y = thumbCenter.y + (stageCenter.y - thumbCenter.y) * t
219
+ postMouse(.leftMouseDragged, at: CGPoint(x: x, y: y))
220
+ Thread.sleep(forTimeInterval: duration / TimeInterval(steps))
221
+ }
222
+
223
+ // Hold at destination briefly
224
+ Thread.sleep(forTimeInterval: 0.3)
225
+ postMouse(.leftMouseUp, at: stageCenter)
226
+
227
+ Thread.sleep(forTimeInterval: 1.0)
228
+ printStageState(label: "After long-press drag")
229
+
230
+ let finalApps = Set(getActiveStage().map(\.app))
231
+ let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
232
+ print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
233
+ }
234
+
235
+ // MARK: - Approach 8: Click thumbnail while holding Option key
236
+
237
+ func testJoinByOptionClick() throws {
238
+ let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
239
+ try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
240
+
241
+ let thumbnails = getStripThumbnails()
242
+ let active = getActiveStage()
243
+
244
+ guard !thumbnails.isEmpty else { return }
245
+
246
+ let activeApps = Set(active.map(\.app))
247
+ let preferred8 = ["Google Chrome", "iTerm2", "Vox"]
248
+ guard let thumb = thumbnails.first(where: { preferred8.contains($0.app) && !activeApps.contains($0.app) })
249
+ ?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
250
+
251
+ let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
252
+
253
+ print("Option-clicking \(thumb.app) thumbnail at (\(Int(thumbCenter.x)),\(Int(thumbCenter.y)))")
254
+ printStageState(label: "BEFORE")
255
+
256
+ // Option + click on strip thumbnail
257
+ guard let downEvent = CGEvent(
258
+ mouseEventSource: nil,
259
+ mouseType: .leftMouseDown,
260
+ mouseCursorPosition: thumbCenter,
261
+ mouseButton: .left
262
+ ) else { return }
263
+ downEvent.flags = .maskAlternate // Option key
264
+ downEvent.post(tap: .cghidEventTap)
265
+
266
+ Thread.sleep(forTimeInterval: 0.05)
267
+
268
+ guard let upEvent = CGEvent(
269
+ mouseEventSource: nil,
270
+ mouseType: .leftMouseUp,
271
+ mouseCursorPosition: thumbCenter,
272
+ mouseButton: .left
273
+ ) else { return }
274
+ upEvent.flags = .maskAlternate
275
+ upEvent.post(tap: .cghidEventTap)
276
+
277
+ Thread.sleep(forTimeInterval: 0.8)
278
+ printStageState(label: "After Option-click")
279
+
280
+ let finalApps = Set(getActiveStage().map(\.app))
281
+ let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
282
+ print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
283
+ }
284
+
285
+ // MARK: - Approach 9: Shift-click (common modifier for "add to selection")
286
+
287
+ func testJoinByShiftClick() throws {
288
+ let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
289
+ try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
290
+
291
+ let thumbnails = getStripThumbnails()
292
+ let active = getActiveStage()
293
+
294
+ guard !thumbnails.isEmpty else { return }
295
+
296
+ let activeApps = Set(active.map(\.app))
297
+ let preferred9 = ["Google Chrome", "iTerm2", "Vox"]
298
+ guard let thumb = thumbnails.first(where: { preferred9.contains($0.app) && !activeApps.contains($0.app) })
299
+ ?? thumbnails.first(where: { !activeApps.contains($0.app) }) else { return }
300
+
301
+ let thumbCenter = CGPoint(x: thumb.bounds.midX, y: thumb.bounds.midY)
302
+
303
+ print("Shift-clicking \(thumb.app) thumbnail")
304
+ printStageState(label: "BEFORE")
305
+
306
+ guard let downEvent = CGEvent(
307
+ mouseEventSource: nil,
308
+ mouseType: .leftMouseDown,
309
+ mouseCursorPosition: thumbCenter,
310
+ mouseButton: .left
311
+ ) else { return }
312
+ downEvent.flags = .maskShift
313
+ downEvent.post(tap: .cghidEventTap)
314
+
315
+ Thread.sleep(forTimeInterval: 0.05)
316
+
317
+ guard let upEvent = CGEvent(
318
+ mouseEventSource: nil,
319
+ mouseType: .leftMouseUp,
320
+ mouseCursorPosition: thumbCenter,
321
+ mouseButton: .left
322
+ ) else { return }
323
+ upEvent.flags = .maskShift
324
+ upEvent.post(tap: .cghidEventTap)
325
+
326
+ Thread.sleep(forTimeInterval: 0.8)
327
+ printStageState(label: "After Shift-click")
328
+
329
+ let finalApps = Set(getActiveStage().map(\.app))
330
+ let joined = finalApps.contains(thumb.app) && activeApps.isSubset(of: finalApps)
331
+ print("\nResult: joined? \(joined) — active: \(finalApps.sorted())")
332
+ }
333
+ }