@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.
- package/app/Info.plist +30 -0
- package/app/Lattices.app/Contents/Info.plist +8 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
- package/app/Lattices.entitlements +15 -0
- package/app/Resources/tap.wav +0 -0
- package/app/Sources/ActionRow.swift +43 -26
- package/app/Sources/AppDelegate.swift +13 -7
- package/app/Sources/DesktopModel.swift +26 -2
- package/app/Sources/HandsOffSession.swift +121 -26
- package/app/Sources/HotkeyStore.swift +5 -1
- package/app/Sources/IntentEngine.swift +37 -0
- package/app/Sources/LatticesApi.swift +40 -0
- package/app/Sources/MainView.swift +73 -21
- package/app/Sources/MouseFinder.swift +222 -0
- package/app/Sources/ProjectScanner.swift +57 -44
- package/app/Tests/StageDragTests.swift +333 -0
- package/app/Tests/StageJoinTests.swift +313 -0
- package/app/Tests/StageManagerTests.swift +280 -0
- package/app/Tests/StageTileTests.swift +353 -0
- package/assets/AppIcon.icns +0 -0
- package/bin/handsoff-worker.ts +10 -1
- package/bin/lattices-app.ts +123 -39
- package/bin/lattices-dev +51 -3
- package/bin/lattices.ts +181 -7
- package/docs/agent-layer-guide.md +207 -0
- package/package.json +12 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
}
|