agentix-cli 0.27.0 → 0.29.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 +93 -118
- package/apps/mac-helper/Sources/AgentXHelper/AXTree.swift +159 -0
- package/apps/mac-helper/Sources/AgentXHelper/Brand.swift +63 -0
- package/apps/mac-helper/Sources/AgentXHelper/Focus.swift +97 -0
- package/apps/mac-helper/Sources/AgentXHelper/HUD.swift +179 -0
- package/apps/mac-helper/Sources/AgentXHelper/OCR.swift +117 -0
- package/apps/mac-helper/Sources/AgentXHelper/Pointer.swift +309 -0
- package/apps/mac-helper/Sources/AgentXHelper/Screens.swift +67 -0
- package/apps/mac-helper/Sources/AgentXHelper/Typer.swift +185 -0
- package/apps/mac-helper/Sources/AgentXHelper/Vision.swift +201 -0
- package/apps/mac-helper/Sources/AgentXHelper/main.swift +265 -0
- package/apps/mac-helper/build.sh +39 -0
- package/apps/mac-voice/Resources/Info.plist +18 -0
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +108 -0
- package/apps/mac-voice/Sources/AgentXVoice/Brand.swift +123 -0
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +68 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hold.swift +74 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +76 -0
- package/apps/mac-voice/Sources/AgentXVoice/Markdown.swift +140 -0
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +275 -0
- package/apps/mac-voice/Sources/AgentXVoice/Progress.swift +147 -0
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +139 -0
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +162 -0
- package/apps/mac-voice/Sources/AgentXVoice/SmartPaste.swift +72 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +192 -0
- package/apps/mac-voice/Sources/AgentXVoice/StepLabel.swift +159 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +380 -0
- package/apps/mac-voice/build.sh +32 -0
- package/dist/{App-U52IPUK2.js → App-IEQXWJ7B.js} +2 -2
- package/dist/{ChatApp-XP4OMHPL.js → ChatApp-NWOSAP5U.js} +2 -2
- package/dist/{agent-V2CA4TXD.js → agent-LS4CZPJ6.js} +2 -2
- package/dist/{board-dashboard-TTMAYRLG.js → board-dashboard-6QQCKFB3.js} +8 -10
- package/dist/board-dashboard-6QQCKFB3.js.map +1 -0
- package/dist/{chunk-DCDQZR7I.js → chunk-3QVOWDHZ.js} +6 -6
- package/dist/chunk-3QVOWDHZ.js.map +1 -0
- package/dist/{chunk-FIEJDZVC.js → chunk-4KXBUORV.js} +2 -2
- package/dist/{chunk-UFAGYX73.js → chunk-6F2IKJ2F.js} +8 -8
- package/dist/chunk-6F2IKJ2F.js.map +1 -0
- package/dist/chunk-AE2LDMZU.js +2 -0
- package/dist/chunk-AE2LDMZU.js.map +1 -0
- package/dist/{chunk-VTE7L6LO.js → chunk-DI7FMCDK.js} +2 -2
- package/dist/{chunk-AX2PWSRT.js → chunk-F5AEXRPR.js} +2 -2
- package/dist/{chunk-CJQQIRPD.js → chunk-GMNKJXTR.js} +7 -7
- package/dist/chunk-GMNKJXTR.js.map +1 -0
- package/dist/{chunk-L2IZPR6P.js → chunk-JIOLJIBM.js} +2 -2
- package/dist/{chunk-IB3F7VBE.js → chunk-JKYMXLYL.js} +3 -3
- package/dist/chunk-JKYMXLYL.js.map +1 -0
- package/dist/{chunk-LNRB5MCU.js → chunk-LNWX4MPX.js} +151 -134
- package/dist/chunk-LNWX4MPX.js.map +1 -0
- package/dist/{chunk-WZ72HX5C.js → chunk-OTXDKKEU.js} +145 -138
- package/dist/chunk-OTXDKKEU.js.map +1 -0
- package/dist/{chunk-IBUSGCLQ.js → chunk-SBCZLZG3.js} +2 -2
- package/dist/{chunk-2T5W3ETS.js → chunk-SC5DIKBB.js} +2 -2
- package/dist/{chunk-BPIRO4YD.js → chunk-UYSNGBDT.js} +2 -2
- package/dist/chunk-XPXO5K4L.js +164 -0
- package/dist/chunk-XPXO5K4L.js.map +1 -0
- package/dist/{chunk-GDWJUGB5.js → chunk-Z2Y24ETP.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client-5Z3URGPY.js +2 -0
- package/dist/config-ORSGLVRW.js +2 -0
- package/dist/config-mutator-JB3S2E7H.js +2 -0
- package/dist/doctor-FS2BX6H4.js +2 -0
- package/dist/index.d.ts +63 -42
- package/dist/index.js +12 -19
- package/dist/index.js.map +1 -1
- package/dist/program-I4J63BQ7.js +2 -0
- package/dist/query-R7EI3VGX.js +2 -0
- package/dist/registry-UJIJBLUR.js +2 -0
- package/dist/request-planner-BKT6RZKX.js +2 -0
- package/dist/request-planner-BKT6RZKX.js.map +1 -0
- package/dist/{review-candidates-AEUXRQQC.js → review-candidates-F3OFYTCZ.js} +2 -2
- package/dist/routing-S5WSFIAD.js +2 -0
- package/dist/scheduler-DSAOMV4O.js +2 -0
- package/dist/{seat-VZ7AVO27.js → seat-ZVHXNAEN.js} +2 -2
- package/dist/{select-F5I5PO2O.js → select-L7C52URW.js} +2 -2
- package/dist/wiki-5YQI4YKM.js +2 -0
- package/dist/wiki-5YQI4YKM.js.map +1 -0
- package/package.json +14 -3
- package/scripts/postinstall.mjs +80 -0
- package/dist/board-dashboard-TTMAYRLG.js.map +0 -1
- package/dist/chunk-CJQQIRPD.js.map +0 -1
- package/dist/chunk-DCDQZR7I.js.map +0 -1
- package/dist/chunk-IB3F7VBE.js.map +0 -1
- package/dist/chunk-LNRB5MCU.js.map +0 -1
- package/dist/chunk-OPHIXXGI.js +0 -157
- package/dist/chunk-OPHIXXGI.js.map +0 -1
- package/dist/chunk-UFAGYX73.js.map +0 -1
- package/dist/chunk-WZ72HX5C.js.map +0 -1
- package/dist/client-PX25MD47.js +0 -2
- package/dist/config-HL5F36V4.js +0 -2
- package/dist/config-mutator-HBX53OMJ.js +0 -2
- package/dist/doctor-PBAH6XL3.js +0 -2
- package/dist/program-YGXVCV2Y.js +0 -2
- package/dist/query-ZKTFJMNE.js +0 -2
- package/dist/registry-PSZDPFZK.js +0 -2
- package/dist/routing-4PV66BK6.js +0 -2
- package/dist/routing-4PV66BK6.js.map +0 -1
- package/dist/scheduler-GZZAN6ZG.js +0 -2
- package/dist/wiki-N5SMP3DT.js +0 -2
- /package/dist/{App-U52IPUK2.js.map → App-IEQXWJ7B.js.map} +0 -0
- /package/dist/{ChatApp-XP4OMHPL.js.map → ChatApp-NWOSAP5U.js.map} +0 -0
- /package/dist/{agent-V2CA4TXD.js.map → agent-LS4CZPJ6.js.map} +0 -0
- /package/dist/{chunk-FIEJDZVC.js.map → chunk-4KXBUORV.js.map} +0 -0
- /package/dist/{chunk-VTE7L6LO.js.map → chunk-DI7FMCDK.js.map} +0 -0
- /package/dist/{chunk-AX2PWSRT.js.map → chunk-F5AEXRPR.js.map} +0 -0
- /package/dist/{chunk-L2IZPR6P.js.map → chunk-JIOLJIBM.js.map} +0 -0
- /package/dist/{chunk-IBUSGCLQ.js.map → chunk-SBCZLZG3.js.map} +0 -0
- /package/dist/{chunk-2T5W3ETS.js.map → chunk-SC5DIKBB.js.map} +0 -0
- /package/dist/{chunk-BPIRO4YD.js.map → chunk-UYSNGBDT.js.map} +0 -0
- /package/dist/{chunk-GDWJUGB5.js.map → chunk-Z2Y24ETP.js.map} +0 -0
- /package/dist/{client-PX25MD47.js.map → client-5Z3URGPY.js.map} +0 -0
- /package/dist/{config-HL5F36V4.js.map → config-ORSGLVRW.js.map} +0 -0
- /package/dist/{config-mutator-HBX53OMJ.js.map → config-mutator-JB3S2E7H.js.map} +0 -0
- /package/dist/{doctor-PBAH6XL3.js.map → doctor-FS2BX6H4.js.map} +0 -0
- /package/dist/{program-YGXVCV2Y.js.map → program-I4J63BQ7.js.map} +0 -0
- /package/dist/{query-ZKTFJMNE.js.map → query-R7EI3VGX.js.map} +0 -0
- /package/dist/{registry-PSZDPFZK.js.map → registry-UJIJBLUR.js.map} +0 -0
- /package/dist/{review-candidates-AEUXRQQC.js.map → review-candidates-F3OFYTCZ.js.map} +0 -0
- /package/dist/{scheduler-GZZAN6ZG.js.map → routing-S5WSFIAD.js.map} +0 -0
- /package/dist/{seat-VZ7AVO27.js.map → scheduler-DSAOMV4O.js.map} +0 -0
- /package/dist/{wiki-N5SMP3DT.js.map → seat-ZVHXNAEN.js.map} +0 -0
- /package/dist/{select-F5I5PO2O.js.map → select-L7C52URW.js.map} +0 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// A callout that stays on screen and updates while the agent talks.
|
|
4
|
+
///
|
|
5
|
+
/// The pointer's label vanishes with its highlight, which is fine for
|
|
6
|
+
/// "here is the button" and useless for "here is what we are doing and
|
|
7
|
+
/// why". A lesson needs somewhere the current instruction persists —
|
|
8
|
+
/// something you can glance back at after listening, and that still says
|
|
9
|
+
/// what is happening when the speech has moved on.
|
|
10
|
+
///
|
|
11
|
+
/// Driven over stdin rather than as one-shot calls, because the point is
|
|
12
|
+
/// that it UPDATES: a fresh window per step would flash, lose its
|
|
13
|
+
/// position, and steal focus. One window, many lines, closed on EOF.
|
|
14
|
+
///
|
|
15
|
+
/// {"title":"Step 2 of 9","body":"Type from:naval","state":"typing",
|
|
16
|
+
/// "avoid":{"x":218,"y":145,"w":420,"h":40}}
|
|
17
|
+
///
|
|
18
|
+
/// `avoid` is the rectangle the lesson is about to point at. The callout
|
|
19
|
+
/// moves out of its way.
|
|
20
|
+
///
|
|
21
|
+
/// This is not the same problem as click occlusion, and a hit test does
|
|
22
|
+
/// not catch it: the panel sets ignoresMouseEvents, so a click passes
|
|
23
|
+
/// straight through and the window server correctly reports whatever is
|
|
24
|
+
/// underneath. It is invisible to a click and very visible to a person —
|
|
25
|
+
/// which is exactly how this window ended up sitting on top of the search
|
|
26
|
+
/// field a lesson was pointing at, with nothing able to notice.
|
|
27
|
+
///
|
|
28
|
+
/// Anything unparseable is ignored rather than fatal — a malformed line
|
|
29
|
+
/// from a caller should not take down the display mid-lesson.
|
|
30
|
+
enum HUD {
|
|
31
|
+
|
|
32
|
+
private final class Window: NSPanel {
|
|
33
|
+
let titleField = NSTextField(labelWithString: "")
|
|
34
|
+
let bodyField = NSTextField(wrappingLabelWithString: "")
|
|
35
|
+
let dot = NSView()
|
|
36
|
+
|
|
37
|
+
init() {
|
|
38
|
+
super.init(contentRect: NSRect(x: 0, y: 0, width: 460, height: 96),
|
|
39
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
40
|
+
backing: .buffered, defer: false)
|
|
41
|
+
level = .screenSaver
|
|
42
|
+
isOpaque = false
|
|
43
|
+
backgroundColor = .clear
|
|
44
|
+
hasShadow = true
|
|
45
|
+
ignoresMouseEvents = true
|
|
46
|
+
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
47
|
+
|
|
48
|
+
let blur = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: 460, height: 96))
|
|
49
|
+
blur.material = .hudWindow
|
|
50
|
+
blur.blendingMode = .behindWindow
|
|
51
|
+
blur.state = .active
|
|
52
|
+
blur.wantsLayer = true
|
|
53
|
+
blur.layer?.cornerRadius = Brand.Radius.lg
|
|
54
|
+
blur.layer?.masksToBounds = true
|
|
55
|
+
blur.autoresizingMask = [.width, .height]
|
|
56
|
+
contentView = blur
|
|
57
|
+
|
|
58
|
+
dot.wantsLayer = true
|
|
59
|
+
dot.layer?.cornerRadius = 4
|
|
60
|
+
dot.frame = NSRect(x: 20, y: 63, width: 8, height: 8)
|
|
61
|
+
blur.addSubview(dot)
|
|
62
|
+
|
|
63
|
+
titleField.frame = NSRect(x: 38, y: 58, width: 400, height: 18)
|
|
64
|
+
// Eyebrow: mono, uppercase, tracked — "STEP 2 OF 9".
|
|
65
|
+
titleField.font = .monospacedSystemFont(ofSize: 10, weight: .semibold)
|
|
66
|
+
titleField.textColor = .secondaryLabelColor
|
|
67
|
+
blur.addSubview(titleField)
|
|
68
|
+
|
|
69
|
+
bodyField.frame = NSRect(x: 20, y: 14, width: 420, height: 40)
|
|
70
|
+
bodyField.font = Brand.body(size: 14)
|
|
71
|
+
bodyField.textColor = .labelColor
|
|
72
|
+
bodyField.maximumNumberOfLines = 2
|
|
73
|
+
blur.addSubview(bodyField)
|
|
74
|
+
|
|
75
|
+
moveToDefault()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Top centre: out of the way of most content, and where a person
|
|
79
|
+
/// already looks for system feedback.
|
|
80
|
+
func moveToDefault() {
|
|
81
|
+
guard let screen = NSScreen.main else { return }
|
|
82
|
+
let v = screen.visibleFrame
|
|
83
|
+
setFrameOrigin(NSPoint(x: v.midX - frame.width / 2, y: v.maxY - frame.height - 34))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Move below the target when the default position would cover it.
|
|
87
|
+
///
|
|
88
|
+
/// `rect` arrives in accessibility coordinates (top-left origin);
|
|
89
|
+
/// windows use bottom-left, so it is flipped before comparing.
|
|
90
|
+
func avoid(_ rect: CGRect) {
|
|
91
|
+
guard let screen = NSScreen.main else { return }
|
|
92
|
+
let v = screen.visibleFrame
|
|
93
|
+
let flipped = CGRect(x: rect.origin.x,
|
|
94
|
+
y: screen.frame.maxY - rect.origin.y - rect.height,
|
|
95
|
+
width: rect.width, height: rect.height)
|
|
96
|
+
moveToDefault()
|
|
97
|
+
// A margin, so the callout does not merely touch the target's
|
|
98
|
+
// edge and still crowd it.
|
|
99
|
+
guard frame.insetBy(dx: -16, dy: -16).intersects(flipped) else { return }
|
|
100
|
+
|
|
101
|
+
// Prefer just below the target; fall back to just above when
|
|
102
|
+
// there is no room, and give up rather than push it off screen.
|
|
103
|
+
let below = flipped.minY - frame.height - 20
|
|
104
|
+
if below > v.minY {
|
|
105
|
+
setFrameOrigin(NSPoint(x: frame.origin.x, y: below))
|
|
106
|
+
} else {
|
|
107
|
+
let above = flipped.maxY + 20
|
|
108
|
+
if above + frame.height < v.maxY {
|
|
109
|
+
setFrameOrigin(NSPoint(x: frame.origin.x, y: above))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
override var canBecomeKey: Bool { false }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Teal for the agent acting on the screen, blue while it speaks,
|
|
118
|
+
// amber when it is blocked. One accent family, not a traffic light.
|
|
119
|
+
private static let colours: [String: NSColor] = [
|
|
120
|
+
"talking": Brand.primaryBright,
|
|
121
|
+
"pointing": Brand.accent,
|
|
122
|
+
"typing": Brand.accentDeep,
|
|
123
|
+
"waiting": Brand.warn,
|
|
124
|
+
"done": Brand.accent,
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
/// Read update lines until stdin closes.
|
|
128
|
+
static func run() {
|
|
129
|
+
let app = NSApplication.shared
|
|
130
|
+
app.setActivationPolicy(.accessory)
|
|
131
|
+
let window = Window()
|
|
132
|
+
window.alphaValue = 0
|
|
133
|
+
window.orderFrontRegardless()
|
|
134
|
+
NSAnimationContext.runAnimationGroup { $0.duration = 0.2; window.animator().alphaValue = 1 }
|
|
135
|
+
|
|
136
|
+
// stdin on a background thread: the main thread has to keep
|
|
137
|
+
// pumping the run loop or nothing renders and the blur never
|
|
138
|
+
// resolves.
|
|
139
|
+
let queue = DispatchQueue(label: "tn.acme.agentx.hud.stdin")
|
|
140
|
+
queue.async {
|
|
141
|
+
while let line = readLine(strippingNewline: true) {
|
|
142
|
+
guard let data = line.data(using: .utf8),
|
|
143
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
144
|
+
else { continue }
|
|
145
|
+
DispatchQueue.main.async {
|
|
146
|
+
if let t = obj["title"] as? String {
|
|
147
|
+
window.titleField.attributedStringValue =
|
|
148
|
+
Brand.metaString(t, size: 10, color: .secondaryLabelColor)
|
|
149
|
+
}
|
|
150
|
+
if let b = obj["body"] as? String { window.bodyField.stringValue = b }
|
|
151
|
+
if let a = obj["avoid"] as? [String: Any],
|
|
152
|
+
let x = a["x"] as? Double, let y = a["y"] as? Double,
|
|
153
|
+
let w = a["w"] as? Double, let h = a["h"] as? Double {
|
|
154
|
+
window.avoid(CGRect(x: x, y: y, width: w, height: h))
|
|
155
|
+
} else {
|
|
156
|
+
window.moveToDefault()
|
|
157
|
+
}
|
|
158
|
+
let state = (obj["state"] as? String) ?? "talking"
|
|
159
|
+
let c = colours[state] ?? Brand.primaryBright
|
|
160
|
+
window.dot.layer?.backgroundColor = c.cgColor
|
|
161
|
+
window.dot.layer?.shadowColor = c.cgColor
|
|
162
|
+
window.dot.layer?.shadowOpacity = 0.6
|
|
163
|
+
window.dot.layer?.shadowRadius = 5
|
|
164
|
+
window.dot.layer?.shadowOffset = .zero
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// EOF: the lesson ended.
|
|
168
|
+
DispatchQueue.main.async {
|
|
169
|
+
NSAnimationContext.runAnimationGroup { ctx in
|
|
170
|
+
ctx.duration = 0.25
|
|
171
|
+
window.animator().alphaValue = 0
|
|
172
|
+
} completionHandler: {
|
|
173
|
+
app.terminate(nil)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
app.run()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Vision
|
|
3
|
+
|
|
4
|
+
/// Reading the screen when the accessibility tree has nothing to say.
|
|
5
|
+
///
|
|
6
|
+
/// Electron apps are the reason this exists. VS Code exposes three
|
|
7
|
+
/// controls and labels none of them; Screen Studio's own recording picker
|
|
8
|
+
/// exposes no children at all. To the tree those screens look empty, which
|
|
9
|
+
/// is indistinguishable from an app with genuinely nothing on it — so a
|
|
10
|
+
/// lesson or an agent asked to act there has no candidates and can only
|
|
11
|
+
/// refuse.
|
|
12
|
+
///
|
|
13
|
+
/// Apple's Vision framework reads the pixels instead. Entirely local: the
|
|
14
|
+
/// image never leaves the machine, which is the same posture third-hand
|
|
15
|
+
/// takes and the opposite of shipping screenshots to a vision model.
|
|
16
|
+
///
|
|
17
|
+
/// What this buys and what it does not. OCR returns TEXT AND WHERE IT IS.
|
|
18
|
+
/// It does not return affordances — it cannot tell a button from its own
|
|
19
|
+
/// caption, or a disabled control from an enabled one. So an OCR
|
|
20
|
+
/// candidate is a weaker thing than a tree element and is marked as such,
|
|
21
|
+
/// rather than quietly mixed in as though the two were equivalent.
|
|
22
|
+
enum OCR {
|
|
23
|
+
|
|
24
|
+
struct TextHit: Codable {
|
|
25
|
+
let text: String
|
|
26
|
+
/// Screen coordinates, top-left origin — the same convention the
|
|
27
|
+
/// accessibility tree uses, so callers need no second mapping.
|
|
28
|
+
let x: Double
|
|
29
|
+
let y: Double
|
|
30
|
+
let width: Double
|
|
31
|
+
let height: Double
|
|
32
|
+
let confidence: Double
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Read text from a screen rectangle.
|
|
36
|
+
///
|
|
37
|
+
/// `rect` is in screen coordinates with a TOP-LEFT origin, matching
|
|
38
|
+
/// AXTree. Everything about coordinates here is a conversion between
|
|
39
|
+
/// three conventions and it is the part most likely to be wrong, so
|
|
40
|
+
/// each step is named:
|
|
41
|
+
///
|
|
42
|
+
/// 1. CGWindowListCreateImage takes top-left screen coords. Good.
|
|
43
|
+
/// 2. The image comes back at backing scale — 2x on Retina — so its
|
|
44
|
+
/// pixel size is not the rect's point size.
|
|
45
|
+
/// 3. Vision reports NORMALISED coordinates (0…1) with a BOTTOM-LEFT
|
|
46
|
+
/// origin, relative to the image.
|
|
47
|
+
///
|
|
48
|
+
/// So a hit is un-normalised against the image, flipped vertically,
|
|
49
|
+
/// and finally offset by the rect's own origin.
|
|
50
|
+
static func read(rect: CGRect, languages: [String] = ["fr-FR", "en-US"]) -> [TextHit] {
|
|
51
|
+
guard rect.width >= 8, rect.height >= 8,
|
|
52
|
+
let image = CGWindowListCreateImage(
|
|
53
|
+
rect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution])
|
|
54
|
+
else { return [] }
|
|
55
|
+
|
|
56
|
+
let request = VNRecognizeTextRequest()
|
|
57
|
+
request.recognitionLevel = .accurate
|
|
58
|
+
// The machine under test is French and pages render in French;
|
|
59
|
+
// an English-only recogniser drops accented words entirely.
|
|
60
|
+
request.recognitionLanguages = languages
|
|
61
|
+
request.usesLanguageCorrection = true
|
|
62
|
+
|
|
63
|
+
let handler = VNImageRequestHandler(cgImage: image, options: [:])
|
|
64
|
+
guard (try? handler.perform([request])) != nil,
|
|
65
|
+
let observations = request.results
|
|
66
|
+
else { return [] }
|
|
67
|
+
|
|
68
|
+
var hits: [TextHit] = []
|
|
69
|
+
for obs in observations {
|
|
70
|
+
guard let candidate = obs.topCandidates(1).first else { continue }
|
|
71
|
+
let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
72
|
+
// Single stray characters are noise from borders and icons.
|
|
73
|
+
guard text.count > 1 else { continue }
|
|
74
|
+
|
|
75
|
+
// Vision box: normalised, bottom-left origin, relative to image.
|
|
76
|
+
let b = obs.boundingBox
|
|
77
|
+
let w = b.width * rect.width
|
|
78
|
+
let h = b.height * rect.height
|
|
79
|
+
let x = rect.minX + b.minX * rect.width
|
|
80
|
+
// Flip: Vision's y grows upward from the image bottom; screen
|
|
81
|
+
// coordinates grow downward from the rect top.
|
|
82
|
+
let y = rect.minY + (1.0 - b.maxY) * rect.height
|
|
83
|
+
|
|
84
|
+
hits.append(TextHit(text: text, x: x, y: y, width: w, height: h,
|
|
85
|
+
confidence: Double(candidate.confidence)))
|
|
86
|
+
}
|
|
87
|
+
// Reading order: top to bottom, then left to right. Matches how a
|
|
88
|
+
// person would enumerate what is on screen.
|
|
89
|
+
return hits.sorted { a, b in
|
|
90
|
+
abs(a.y - b.y) > 6 ? a.y < b.y : a.x < b.x
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// The focused window's frame, in top-left screen coordinates.
|
|
95
|
+
///
|
|
96
|
+
/// Capturing the whole screen would work and is worse: more to read,
|
|
97
|
+
/// slower, and it pulls in every other application's content.
|
|
98
|
+
static func focusedWindowFrame() -> CGRect? {
|
|
99
|
+
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
|
|
100
|
+
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
|
101
|
+
var windowRef: CFTypeRef?
|
|
102
|
+
guard AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &windowRef) == .success,
|
|
103
|
+
CFGetTypeID(windowRef) == AXUIElementGetTypeID()
|
|
104
|
+
else { return nil }
|
|
105
|
+
let window = unsafeBitCast(windowRef, to: AXUIElement.self)
|
|
106
|
+
|
|
107
|
+
var posRef: CFTypeRef?, sizeRef: CFTypeRef?
|
|
108
|
+
guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posRef) == .success,
|
|
109
|
+
AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeRef) == .success
|
|
110
|
+
else { return nil }
|
|
111
|
+
var p = CGPoint.zero, s = CGSize.zero
|
|
112
|
+
AXValueGetValue(posRef as! AXValue, .cgPoint, &p)
|
|
113
|
+
AXValueGetValue(sizeRef as! AXValue, .cgSize, &s)
|
|
114
|
+
guard s.width > 0, s.height > 0 else { return nil }
|
|
115
|
+
return CGRect(origin: p, size: s)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// Shows where a control is, at a speed a person can follow. Does not click.
|
|
4
|
+
///
|
|
5
|
+
/// The first version warped the cursor, then eased it over a flat 280ms.
|
|
6
|
+
/// Both read as machine motion: a teleport is missed entirely, and a
|
|
7
|
+
/// constant-duration slide is the same speed whether it crosses the screen
|
|
8
|
+
/// or nudges thirty points. Neither looks like a hand.
|
|
9
|
+
///
|
|
10
|
+
/// Real pointing has three properties worth copying, all cheap:
|
|
11
|
+
///
|
|
12
|
+
/// 1. Duration follows Fitts's law — far targets and small targets take
|
|
13
|
+
/// longer. A fixed duration is the single biggest tell.
|
|
14
|
+
/// 2. Velocity is minimum-jerk: accelerate, cruise, decelerate. Linear
|
|
15
|
+
/// motion reads as a glitch; abrupt stops read as a snap.
|
|
16
|
+
/// 3. The path bows slightly. Hands do not travel in straight lines, and
|
|
17
|
+
/// a perfectly straight traverse is conspicuous on a long move.
|
|
18
|
+
///
|
|
19
|
+
/// There is deliberately no click here. The point tier exists so a model's
|
|
20
|
+
/// choice is visible and reviewable BEFORE anything irreversible happens.
|
|
21
|
+
enum Pointer {
|
|
22
|
+
|
|
23
|
+
static func point(x: Double, y: Double, width: Double, height: Double, label: String,
|
|
24
|
+
holdSeconds: Double = 2.5, instant: Bool = false) {
|
|
25
|
+
let target = CGPoint(x: x + width / 2, y: y + height / 2)
|
|
26
|
+
if instant {
|
|
27
|
+
CGWarpMouseCursorPosition(target)
|
|
28
|
+
} else {
|
|
29
|
+
humanMove(to: target, targetSize: min(width, height))
|
|
30
|
+
}
|
|
31
|
+
CGAssociateMouseAndMouseCursorPosition(1)
|
|
32
|
+
annotate(CGRect(x: x, y: y, width: width, height: height),
|
|
33
|
+
label: label, holdSeconds: holdSeconds)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// MARK: Movement
|
|
37
|
+
|
|
38
|
+
/// Move the way a hand would.
|
|
39
|
+
private static func humanMove(to target: CGPoint, targetSize: Double) {
|
|
40
|
+
let from = CGEvent(source: nil)?.location ?? target
|
|
41
|
+
let dx = target.x - from.x, dy = target.y - from.y
|
|
42
|
+
let distance = (dx * dx + dy * dy).squareRoot()
|
|
43
|
+
if distance < 2 { CGWarpMouseCursorPosition(target); return }
|
|
44
|
+
|
|
45
|
+
// Fitts's law: time ∝ log2(distance / width + 1). The constants put
|
|
46
|
+
// a short hop near 180ms and a full-screen traverse near 700ms,
|
|
47
|
+
// which is roughly what a person does — fast enough not to feel
|
|
48
|
+
// like waiting, slow enough to follow.
|
|
49
|
+
let width = max(targetSize, 12)
|
|
50
|
+
let difficulty = log2(distance / width + 1)
|
|
51
|
+
let duration = min(0.95, max(0.18, 0.09 + 0.13 * difficulty))
|
|
52
|
+
|
|
53
|
+
// Bow the path perpendicular to the direction of travel, scaled to
|
|
54
|
+
// distance and with a stable sign so it does not wobble.
|
|
55
|
+
let bow = min(distance * 0.08, 55.0) * (dx * dy >= 0 ? 1.0 : -1.0)
|
|
56
|
+
let nx = -dy / distance, ny = dx / distance
|
|
57
|
+
let control = CGPoint(x: from.x + dx * 0.5 + nx * bow,
|
|
58
|
+
y: from.y + dy * 0.5 + ny * bow)
|
|
59
|
+
|
|
60
|
+
let steps = max(24, Int(duration * 110))
|
|
61
|
+
for i in 1...steps {
|
|
62
|
+
let t = Double(i) / Double(steps)
|
|
63
|
+
// Minimum-jerk: 10t³ − 15t⁴ + 6t⁵. Starts and ends at zero
|
|
64
|
+
// velocity AND zero acceleration, which is why it looks settled
|
|
65
|
+
// rather than braked.
|
|
66
|
+
let e = 10 * pow(t, 3) - 15 * pow(t, 4) + 6 * pow(t, 5)
|
|
67
|
+
let p = quadratic(from, control, target, e)
|
|
68
|
+
|
|
69
|
+
// A trace of tremor, fading to nothing as it arrives. Without
|
|
70
|
+
// it the path is mathematically perfect and reads as drawn.
|
|
71
|
+
let tremor = (1 - e) * 0.7
|
|
72
|
+
let jitter = CGPoint(x: Double.random(in: -tremor...tremor),
|
|
73
|
+
y: Double.random(in: -tremor...tremor))
|
|
74
|
+
CGWarpMouseCursorPosition(CGPoint(x: p.x + jitter.x, y: p.y + jitter.y))
|
|
75
|
+
Thread.sleep(forTimeInterval: duration / Double(steps))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Long moves overshoot slightly and correct — the correction is
|
|
79
|
+
// what makes an arrival look human rather than magnetic.
|
|
80
|
+
if distance > 320 {
|
|
81
|
+
let over = CGPoint(x: target.x + dx / distance * 4,
|
|
82
|
+
y: target.y + dy / distance * 4)
|
|
83
|
+
CGWarpMouseCursorPosition(over)
|
|
84
|
+
Thread.sleep(forTimeInterval: 0.045)
|
|
85
|
+
}
|
|
86
|
+
CGWarpMouseCursorPosition(target)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private static func quadratic(_ a: CGPoint, _ b: CGPoint, _ c: CGPoint, _ t: Double) -> CGPoint {
|
|
90
|
+
let u = 1 - t
|
|
91
|
+
return CGPoint(x: u * u * a.x + 2 * u * t * b.x + t * t * c.x,
|
|
92
|
+
y: u * u * a.y + 2 * u * t * b.y + t * t * c.y)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// A click, made visible.
|
|
96
|
+
///
|
|
97
|
+
/// A synthetic click is otherwise invisible — the UI reacts, but
|
|
98
|
+
/// nothing shows that the press came from here rather than from the
|
|
99
|
+
/// person watching. In a lesson that ambiguity is the whole problem:
|
|
100
|
+
/// you cannot learn a gesture you did not see performed.
|
|
101
|
+
///
|
|
102
|
+
/// So the cursor position gets a ring that snaps inward and rebounds,
|
|
103
|
+
/// which is how a fingertip reads — compression then release, not a
|
|
104
|
+
/// flash. Runs before the event so the visual leads the reaction by a
|
|
105
|
+
/// frame or two, the way a real press does.
|
|
106
|
+
static func clickFlourish(durationSeconds: Double = 0.42) {
|
|
107
|
+
guard let p = CGEvent(source: nil)?.location,
|
|
108
|
+
let screen = NSScreen.screens.first(where: {
|
|
109
|
+
$0.frame.contains(CGPoint(x: p.x, y: $0.frame.maxY - p.y))
|
|
110
|
+
}) ?? NSScreen.main else { return }
|
|
111
|
+
|
|
112
|
+
let r: CGFloat = 46
|
|
113
|
+
let flipped = CGPoint(x: p.x, y: screen.frame.maxY - p.y)
|
|
114
|
+
let panel = NSPanel(contentRect: NSRect(x: flipped.x - r, y: flipped.y - r,
|
|
115
|
+
width: r * 2, height: r * 2),
|
|
116
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
117
|
+
backing: .buffered, defer: false)
|
|
118
|
+
panel.level = .screenSaver
|
|
119
|
+
panel.isOpaque = false
|
|
120
|
+
panel.backgroundColor = .clear
|
|
121
|
+
panel.ignoresMouseEvents = true
|
|
122
|
+
panel.hasShadow = false
|
|
123
|
+
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
124
|
+
|
|
125
|
+
let host = NSView(frame: panel.contentView!.bounds)
|
|
126
|
+
host.wantsLayer = true
|
|
127
|
+
panel.contentView = host
|
|
128
|
+
|
|
129
|
+
let ring = CAShapeLayer()
|
|
130
|
+
let box = CGRect(x: r - 17, y: r - 17, width: 34, height: 34)
|
|
131
|
+
ring.path = CGPath(ellipseIn: box, transform: nil)
|
|
132
|
+
ring.fillColor = Brand.accent.withAlphaComponent(0.22).cgColor
|
|
133
|
+
ring.strokeColor = Brand.accent.cgColor
|
|
134
|
+
ring.lineWidth = 2.5
|
|
135
|
+
ring.frame = host.bounds
|
|
136
|
+
ring.anchorPoint = CGPoint(x: 0.5, y: 0.5)
|
|
137
|
+
host.layer?.addSublayer(ring)
|
|
138
|
+
|
|
139
|
+
// Squash then rebound past rest, then settle — the shape of a
|
|
140
|
+
// press. A single scale-down reads as the UI shrinking, not as a
|
|
141
|
+
// finger landing.
|
|
142
|
+
let squash = CAKeyframeAnimation(keyPath: "transform.scale")
|
|
143
|
+
squash.values = [1.0, 0.55, 1.22, 1.0]
|
|
144
|
+
squash.keyTimes = [0, 0.28, 0.62, 1.0]
|
|
145
|
+
squash.timingFunctions = [
|
|
146
|
+
CAMediaTimingFunction(name: .easeIn),
|
|
147
|
+
CAMediaTimingFunction(name: .easeOut),
|
|
148
|
+
CAMediaTimingFunction(name: .easeOut),
|
|
149
|
+
]
|
|
150
|
+
squash.duration = durationSeconds
|
|
151
|
+
|
|
152
|
+
let fade = CAKeyframeAnimation(keyPath: "opacity")
|
|
153
|
+
fade.values = [0.0, 1.0, 0.9, 0.0]
|
|
154
|
+
fade.keyTimes = [0, 0.18, 0.55, 1.0]
|
|
155
|
+
fade.duration = durationSeconds
|
|
156
|
+
|
|
157
|
+
let group = CAAnimationGroup()
|
|
158
|
+
group.animations = [squash, fade]
|
|
159
|
+
group.duration = durationSeconds
|
|
160
|
+
group.isRemovedOnCompletion = false
|
|
161
|
+
group.fillMode = .forwards
|
|
162
|
+
ring.add(group, forKey: "click")
|
|
163
|
+
|
|
164
|
+
panel.orderFrontRegardless()
|
|
165
|
+
let deadline = Date().addingTimeInterval(durationSeconds)
|
|
166
|
+
while Date() < deadline {
|
|
167
|
+
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.012))
|
|
168
|
+
}
|
|
169
|
+
panel.orderOut(nil)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// MARK: Annotation
|
|
173
|
+
|
|
174
|
+
/// A radar ping and a marching-ants border.
|
|
175
|
+
///
|
|
176
|
+
/// Presentation tools converge on these two for the same reason: an
|
|
177
|
+
/// expanding ring draws the eye to a point without hiding what is
|
|
178
|
+
/// underneath, and a moving dash says "this one" in a way a static box
|
|
179
|
+
/// does not on a busy screen. Both are Core Animation, so they run on
|
|
180
|
+
/// the render thread and stay smooth while the process is otherwise
|
|
181
|
+
/// blocked.
|
|
182
|
+
private static func annotate(_ rect: CGRect, label: String, holdSeconds: Double) {
|
|
183
|
+
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(
|
|
184
|
+
CGPoint(x: rect.midX, y: $0.frame.maxY - rect.midY)) }) ?? NSScreen.main else { return }
|
|
185
|
+
|
|
186
|
+
// Accessibility gives top-left origin; NSWindow wants bottom-left.
|
|
187
|
+
let flipped = CGRect(x: rect.origin.x,
|
|
188
|
+
y: screen.frame.maxY - rect.origin.y - rect.height,
|
|
189
|
+
width: rect.width, height: rect.height)
|
|
190
|
+
let pad: CGFloat = 60 // room for the ping to expand past the target
|
|
191
|
+
let panel = NSPanel(contentRect: flipped.insetBy(dx: -pad, dy: -pad),
|
|
192
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
193
|
+
backing: .buffered, defer: false)
|
|
194
|
+
panel.level = .screenSaver
|
|
195
|
+
panel.isOpaque = false
|
|
196
|
+
panel.backgroundColor = .clear
|
|
197
|
+
panel.ignoresMouseEvents = true
|
|
198
|
+
panel.hasShadow = false
|
|
199
|
+
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
200
|
+
|
|
201
|
+
let host = NSView(frame: panel.contentView!.bounds)
|
|
202
|
+
host.wantsLayer = true
|
|
203
|
+
panel.contentView = host
|
|
204
|
+
|
|
205
|
+
let box = CGRect(x: pad, y: pad, width: rect.width, height: rect.height)
|
|
206
|
+
let accent = Brand.accent
|
|
207
|
+
|
|
208
|
+
// Marching ants.
|
|
209
|
+
let border = CAShapeLayer()
|
|
210
|
+
border.path = CGPath(roundedRect: box.insetBy(dx: -3, dy: -3),
|
|
211
|
+
cornerWidth: Brand.Radius.base, cornerHeight: Brand.Radius.base, transform: nil)
|
|
212
|
+
border.fillColor = accent.withAlphaComponent(0.10).cgColor
|
|
213
|
+
border.strokeColor = accent.cgColor
|
|
214
|
+
border.lineWidth = 2.5
|
|
215
|
+
border.lineDashPattern = [7, 4]
|
|
216
|
+
host.layer?.addSublayer(border)
|
|
217
|
+
|
|
218
|
+
let ants = CABasicAnimation(keyPath: "lineDashPhase")
|
|
219
|
+
ants.fromValue = 0; ants.toValue = 11
|
|
220
|
+
ants.duration = 0.55
|
|
221
|
+
ants.repeatCount = .infinity
|
|
222
|
+
border.add(ants, forKey: "ants")
|
|
223
|
+
|
|
224
|
+
// Radar ping, twice, offset so the second starts as the first fades.
|
|
225
|
+
for delay in [0.0, 0.5] {
|
|
226
|
+
let ping = CAShapeLayer()
|
|
227
|
+
ping.path = CGPath(roundedRect: box.insetBy(dx: -3, dy: -3),
|
|
228
|
+
cornerWidth: Brand.Radius.base, cornerHeight: Brand.Radius.base, transform: nil)
|
|
229
|
+
ping.fillColor = NSColor.clear.cgColor
|
|
230
|
+
ping.strokeColor = accent.cgColor
|
|
231
|
+
ping.lineWidth = 2
|
|
232
|
+
ping.opacity = 0
|
|
233
|
+
host.layer?.addSublayer(ping)
|
|
234
|
+
|
|
235
|
+
let grow = CABasicAnimation(keyPath: "transform.scale")
|
|
236
|
+
grow.fromValue = 1.0; grow.toValue = 1.9
|
|
237
|
+
let fade = CABasicAnimation(keyPath: "opacity")
|
|
238
|
+
fade.fromValue = 0.85; fade.toValue = 0.0
|
|
239
|
+
|
|
240
|
+
let group = CAAnimationGroup()
|
|
241
|
+
group.animations = [grow, fade]
|
|
242
|
+
group.duration = 1.0
|
|
243
|
+
group.beginTime = CACurrentMediaTime() + delay
|
|
244
|
+
group.repeatCount = 2
|
|
245
|
+
group.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
246
|
+
group.isRemovedOnCompletion = false
|
|
247
|
+
// Scale about the target, not the layer's corner.
|
|
248
|
+
ping.anchorPoint = CGPoint(x: 0.5, y: 0.5)
|
|
249
|
+
ping.frame = host.bounds
|
|
250
|
+
ping.add(group, forKey: "ping")
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if !label.isEmpty { addLabel(label, to: host, above: box, accent: accent) }
|
|
254
|
+
|
|
255
|
+
panel.alphaValue = 0
|
|
256
|
+
panel.orderFrontRegardless()
|
|
257
|
+
NSAnimationContext.runAnimationGroup { ctx in
|
|
258
|
+
ctx.duration = 0.16
|
|
259
|
+
panel.animator().alphaValue = 1
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The process is a one-shot CLI: without pumping the run loop the
|
|
263
|
+
// window would never render before it exits.
|
|
264
|
+
let deadline = Date().addingTimeInterval(holdSeconds)
|
|
265
|
+
while Date() < deadline {
|
|
266
|
+
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02))
|
|
267
|
+
}
|
|
268
|
+
NSAnimationContext.runAnimationGroup { ctx in
|
|
269
|
+
ctx.duration = 0.22
|
|
270
|
+
panel.animator().alphaValue = 0
|
|
271
|
+
}
|
|
272
|
+
let fadeEnd = Date().addingTimeInterval(0.26)
|
|
273
|
+
while Date() < fadeEnd {
|
|
274
|
+
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02))
|
|
275
|
+
}
|
|
276
|
+
panel.orderOut(nil)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// A pill above the target naming what was chosen, so a wrong pick is
|
|
280
|
+
/// obvious rather than merely mysterious.
|
|
281
|
+
private static func addLabel(_ text: String, to host: NSView, above box: CGRect, accent: NSColor) {
|
|
282
|
+
// Meta type — mono, uppercase, tracked — because this names a
|
|
283
|
+
// thing rather than saying something about it.
|
|
284
|
+
let field = NSTextField(labelWithString: "")
|
|
285
|
+
field.attributedStringValue = Brand.metaString(text, size: 10.5, color: .white)
|
|
286
|
+
field.alignment = .center
|
|
287
|
+
field.sizeToFit()
|
|
288
|
+
|
|
289
|
+
let w = field.frame.width + 18, h = field.frame.height + 8
|
|
290
|
+
// Below the target when there is no room above, so a control near
|
|
291
|
+
// the top of the screen still gets a readable label.
|
|
292
|
+
let above = box.maxY + 8 + h < host.bounds.height
|
|
293
|
+
let y = above ? box.maxY + 8 : box.minY - h - 8
|
|
294
|
+
let pill = NSView(frame: NSRect(x: box.midX - w / 2, y: y, width: w, height: h))
|
|
295
|
+
pill.wantsLayer = true
|
|
296
|
+
pill.layer?.backgroundColor = accent.cgColor
|
|
297
|
+
pill.layer?.cornerRadius = Brand.Radius.sm
|
|
298
|
+
field.frame = NSRect(x: 9, y: 4, width: field.frame.width, height: field.frame.height)
|
|
299
|
+
pill.addSubview(field)
|
|
300
|
+
host.addSubview(pill)
|
|
301
|
+
|
|
302
|
+
let rise = CABasicAnimation(keyPath: "position.y")
|
|
303
|
+
rise.fromValue = (pill.layer?.position.y ?? 0) + (above ? -6 : 6)
|
|
304
|
+
rise.toValue = pill.layer?.position.y ?? 0
|
|
305
|
+
rise.duration = 0.22
|
|
306
|
+
rise.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
307
|
+
pill.layer?.add(rise, forKey: "rise")
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// Display geometry, in the same top-left coordinate space as everything
|
|
4
|
+
/// else in this helper.
|
|
5
|
+
///
|
|
6
|
+
/// AppKit reports screens in a global space whose origin is the PRIMARY
|
|
7
|
+
/// screen's bottom-left and whose y grows upward. The accessibility tree,
|
|
8
|
+
/// OCR and the pointer all use top-left origins with y growing downward.
|
|
9
|
+
/// Mixing the two puts the cursor on the wrong monitor — and on a single
|
|
10
|
+
/// display it looks correct, so the bug only appears on someone else's
|
|
11
|
+
/// desk. Converting once, here, is cheaper than remembering not to.
|
|
12
|
+
enum Screens {
|
|
13
|
+
|
|
14
|
+
struct Info: Codable {
|
|
15
|
+
let index: Int
|
|
16
|
+
let x: Double
|
|
17
|
+
let y: Double
|
|
18
|
+
let width: Double
|
|
19
|
+
let height: Double
|
|
20
|
+
/// The screen with the menu bar.
|
|
21
|
+
let primary: Bool
|
|
22
|
+
/// The screen the active window is on.
|
|
23
|
+
let active: Bool
|
|
24
|
+
/// 2.0 on Retina. A capture comes back at this multiple of the
|
|
25
|
+
/// point size, which is why anything sent to a model is scaled.
|
|
26
|
+
let scale: Double
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static func list() -> [Info] {
|
|
30
|
+
let screens = NSScreen.screens
|
|
31
|
+
guard let primary = screens.first else { return [] }
|
|
32
|
+
// Every y flips about the primary screen's top edge.
|
|
33
|
+
let top = primary.frame.maxY
|
|
34
|
+
return screens.enumerated().map { index, screen in
|
|
35
|
+
Info(index: index,
|
|
36
|
+
x: screen.frame.minX,
|
|
37
|
+
y: top - screen.frame.maxY,
|
|
38
|
+
width: screen.frame.width,
|
|
39
|
+
height: screen.frame.height,
|
|
40
|
+
primary: index == 0,
|
|
41
|
+
active: screen == NSScreen.main,
|
|
42
|
+
scale: screen.backingScaleFactor)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// The screen to capture when no rectangle is given: the one holding
|
|
47
|
+
/// the active window, not blindly the primary.
|
|
48
|
+
static func active() -> Info? {
|
|
49
|
+
let all = list()
|
|
50
|
+
return all.first(where: { $0.active }) ?? all.first
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Full bounds of one screen, in top-left coordinates.
|
|
54
|
+
static func rect(_ info: Info) -> CGRect {
|
|
55
|
+
CGRect(x: info.x, y: info.y, width: info.width, height: info.height)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// The menu bar strip of a screen.
|
|
59
|
+
///
|
|
60
|
+
/// Its own region because it is where macOS puts the truth about
|
|
61
|
+
/// background state: recording indicators, capture pills, sync and VPN
|
|
62
|
+
/// status. None of it is in any app's accessibility tree, and reading
|
|
63
|
+
/// a 24-point strip costs a fraction of a full screen.
|
|
64
|
+
static func menuBar(_ info: Info) -> CGRect {
|
|
65
|
+
CGRect(x: info.x, y: info.y, width: info.width, height: 26)
|
|
66
|
+
}
|
|
67
|
+
}
|