agentix-cli 0.57.0 → 0.59.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/apps/mac-voice/Sources/AgentXVoice/Config.swift +19 -5
- package/apps/mac-voice/Sources/AgentXVoice/HistoryClient.swift +55 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryModel.swift +153 -0
- package/apps/mac-voice/Sources/AgentXVoice/HistoryWindow.swift +301 -0
- package/apps/mac-voice/Sources/AgentXVoice/LocalSTT.swift +79 -0
- package/apps/mac-voice/Sources/AgentXVoice/ModelStore.swift +184 -0
- package/apps/mac-voice/Sources/AgentXVoice/Orb.swift +131 -169
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +174 -35
- package/apps/mac-voice/Sources/AgentXVoice/Parakeet.swift +264 -0
- package/apps/mac-voice/Sources/AgentXVoice/PillPlacement.swift +70 -0
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +81 -3
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +26 -22
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +10 -2
- package/apps/mac-voice/Sources/AgentXVoice/SileroVAD.swift +92 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +7 -5
- package/apps/mac-voice/Sources/AgentXVoice/StatusMenu.swift +23 -6
- package/apps/mac-voice/Sources/AgentXVoice/TurnEnd.swift +110 -0
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +23 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +47 -70
- package/apps/mac-voice/build.sh +10 -1
- package/dist/{App-Z5VOAUXB.js → App-QEJGXZTY.js} +2 -2
- package/dist/{ChatApp-BE7ONQKS.js → ChatApp-VKWQN5GM.js} +2 -2
- package/dist/{agent-Q74EISKP.js → agent-MJKLI2L5.js} +2 -2
- package/dist/{board-dashboard-A7PQOI6C.js → board-dashboard-2KVT72J7.js} +221 -138
- package/dist/board-dashboard-2KVT72J7.js.map +1 -0
- package/dist/{chunk-I2LYULR4.js → chunk-2XZGHIVP.js} +5 -5
- package/dist/{chunk-5LLGIX5M.js → chunk-3EVCHRJF.js} +2 -2
- package/dist/{chunk-6QNPCVRN.js → chunk-4ZTV6JV7.js} +3 -3
- package/dist/chunk-4ZTV6JV7.js.map +1 -0
- package/dist/{chunk-VI7GBDKV.js → chunk-5OF3EGCZ.js} +2 -2
- package/dist/{chunk-RXEAACVH.js → chunk-6DUTIPTY.js} +2 -2
- package/dist/{chunk-CQUVUVXR.js → chunk-6ZV2QFXE.js} +2 -2
- package/dist/{chunk-2MIOMYT2.js → chunk-DAAWUFO4.js} +2 -2
- package/dist/{chunk-547G3HYQ.js → chunk-EJQOKSKP.js} +2 -2
- package/dist/{chunk-POL7RAJQ.js → chunk-ILXW3ERG.js} +117 -117
- package/dist/chunk-ILXW3ERG.js.map +1 -0
- package/dist/{chunk-ERL6AS3O.js → chunk-IW4XIZAZ.js} +8 -8
- package/dist/{chunk-ERL6AS3O.js.map → chunk-IW4XIZAZ.js.map} +1 -1
- package/dist/{chunk-Q6SMLSRO.js → chunk-JUY77L7C.js} +2 -2
- package/dist/{chunk-KW7TIOS3.js → chunk-K62U5EJJ.js} +2 -2
- package/dist/{chunk-KKGFZEXS.js → chunk-L5OCF33B.js} +37 -36
- package/dist/chunk-L5OCF33B.js.map +1 -0
- package/dist/{chunk-XTD3SZIP.js → chunk-N5GOBLUI.js} +2 -2
- package/dist/{chunk-VVBGPT6W.js → chunk-OIDMPPV5.js} +2 -2
- package/dist/{chunk-OZZPJ2YG.js → chunk-SDBU6747.js} +190 -177
- package/dist/chunk-SDBU6747.js.map +1 -0
- package/dist/{chunk-NKMFHEGV.js → chunk-WFPRVZBA.js} +2 -2
- package/dist/{chunk-6OABTCRA.js → chunk-WI5R64WO.js} +2 -2
- package/dist/{chunk-5W7NJHCS.js → chunk-YCIZ2WHX.js} +2 -2
- package/dist/{chunk-U5YBQJ5Q.js → chunk-YQU74AOL.js} +2 -2
- package/dist/{chunk-V7ZK6ZIO.js → chunk-YRB75GMN.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/client-X2PTYKVX.js +2 -0
- package/dist/{config-P6YXC6XZ.js → config-XB2ORKWS.js} +2 -2
- package/dist/config-mutator-KMXR3XDG.js +2 -0
- package/dist/doctor-6BIMNEGU.js +2 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1 -1
- package/dist/program-DHDC6XYB.js +2 -0
- package/dist/{promote-KVPLDSKT.js → promote-GBR6YSQW.js} +2 -2
- package/dist/query-672RGMXO.js +2 -0
- package/dist/{registry-AUOI4TW2.js → registry-P535FNLB.js} +2 -2
- package/dist/{request-planner-LMZ5ZAXC.js → request-planner-KHTCHF7N.js} +2 -2
- package/dist/{review-candidates-WCBXLLDS.js → review-candidates-Y5KCRUNU.js} +2 -2
- package/dist/{routing-FBASYKUL.js → routing-RWCCDJ4D.js} +2 -2
- package/dist/schedule-tool-H46AK53H.js +2 -0
- package/dist/scheduler-3XLWBCX7.js +2 -0
- package/dist/{seat-DBFW3Z5P.js → seat-PJPKLH6L.js} +2 -2
- package/dist/{select-6CNVVFA5.js → select-VMTJAX5X.js} +2 -2
- package/dist/{set-enabled-AYZH3KSB.js → set-enabled-LPXH3ZQP.js} +2 -2
- package/dist/{tool-PKS4NBZ6.js → tool-MWXG44OT.js} +2 -2
- package/dist/wiki-QHMHVFWU.js +2 -0
- package/package.json +1 -1
- package/dist/board-dashboard-A7PQOI6C.js.map +0 -1
- package/dist/chunk-6QNPCVRN.js.map +0 -1
- package/dist/chunk-KKGFZEXS.js.map +0 -1
- package/dist/chunk-OZZPJ2YG.js.map +0 -1
- package/dist/chunk-POL7RAJQ.js.map +0 -1
- package/dist/client-EFP2PKA3.js +0 -2
- package/dist/config-mutator-3GKXLTHH.js +0 -2
- package/dist/doctor-JEWMHXYH.js +0 -2
- package/dist/program-7DUQJDSQ.js +0 -2
- package/dist/query-AGOMCYTT.js +0 -2
- package/dist/schedule-tool-RJRJ63SS.js +0 -2
- package/dist/scheduler-ATA7Z44S.js +0 -2
- package/dist/wiki-QW6BDNEQ.js +0 -2
- /package/dist/{App-Z5VOAUXB.js.map → App-QEJGXZTY.js.map} +0 -0
- /package/dist/{ChatApp-BE7ONQKS.js.map → ChatApp-VKWQN5GM.js.map} +0 -0
- /package/dist/{agent-Q74EISKP.js.map → agent-MJKLI2L5.js.map} +0 -0
- /package/dist/{chunk-I2LYULR4.js.map → chunk-2XZGHIVP.js.map} +0 -0
- /package/dist/{chunk-5LLGIX5M.js.map → chunk-3EVCHRJF.js.map} +0 -0
- /package/dist/{chunk-VI7GBDKV.js.map → chunk-5OF3EGCZ.js.map} +0 -0
- /package/dist/{chunk-RXEAACVH.js.map → chunk-6DUTIPTY.js.map} +0 -0
- /package/dist/{chunk-CQUVUVXR.js.map → chunk-6ZV2QFXE.js.map} +0 -0
- /package/dist/{chunk-2MIOMYT2.js.map → chunk-DAAWUFO4.js.map} +0 -0
- /package/dist/{chunk-547G3HYQ.js.map → chunk-EJQOKSKP.js.map} +0 -0
- /package/dist/{chunk-Q6SMLSRO.js.map → chunk-JUY77L7C.js.map} +0 -0
- /package/dist/{chunk-KW7TIOS3.js.map → chunk-K62U5EJJ.js.map} +0 -0
- /package/dist/{chunk-XTD3SZIP.js.map → chunk-N5GOBLUI.js.map} +0 -0
- /package/dist/{chunk-VVBGPT6W.js.map → chunk-OIDMPPV5.js.map} +0 -0
- /package/dist/{chunk-NKMFHEGV.js.map → chunk-WFPRVZBA.js.map} +0 -0
- /package/dist/{chunk-6OABTCRA.js.map → chunk-WI5R64WO.js.map} +0 -0
- /package/dist/{chunk-5W7NJHCS.js.map → chunk-YCIZ2WHX.js.map} +0 -0
- /package/dist/{chunk-U5YBQJ5Q.js.map → chunk-YQU74AOL.js.map} +0 -0
- /package/dist/{chunk-V7ZK6ZIO.js.map → chunk-YRB75GMN.js.map} +0 -0
- /package/dist/{client-EFP2PKA3.js.map → client-X2PTYKVX.js.map} +0 -0
- /package/dist/{config-P6YXC6XZ.js.map → config-XB2ORKWS.js.map} +0 -0
- /package/dist/{config-mutator-3GKXLTHH.js.map → config-mutator-KMXR3XDG.js.map} +0 -0
- /package/dist/{doctor-JEWMHXYH.js.map → doctor-6BIMNEGU.js.map} +0 -0
- /package/dist/{program-7DUQJDSQ.js.map → program-DHDC6XYB.js.map} +0 -0
- /package/dist/{promote-KVPLDSKT.js.map → promote-GBR6YSQW.js.map} +0 -0
- /package/dist/{query-AGOMCYTT.js.map → query-672RGMXO.js.map} +0 -0
- /package/dist/{registry-AUOI4TW2.js.map → registry-P535FNLB.js.map} +0 -0
- /package/dist/{request-planner-LMZ5ZAXC.js.map → request-planner-KHTCHF7N.js.map} +0 -0
- /package/dist/{review-candidates-WCBXLLDS.js.map → review-candidates-Y5KCRUNU.js.map} +0 -0
- /package/dist/{routing-FBASYKUL.js.map → routing-RWCCDJ4D.js.map} +0 -0
- /package/dist/{schedule-tool-RJRJ63SS.js.map → schedule-tool-H46AK53H.js.map} +0 -0
- /package/dist/{scheduler-ATA7Z44S.js.map → scheduler-3XLWBCX7.js.map} +0 -0
- /package/dist/{seat-DBFW3Z5P.js.map → seat-PJPKLH6L.js.map} +0 -0
- /package/dist/{select-6CNVVFA5.js.map → select-VMTJAX5X.js.map} +0 -0
- /package/dist/{set-enabled-AYZH3KSB.js.map → set-enabled-LPXH3ZQP.js.map} +0 -0
- /package/dist/{tool-PKS4NBZ6.js.map → tool-MWXG44OT.js.map} +0 -0
- /package/dist/{wiki-QW6BDNEQ.js.map → wiki-QHMHVFWU.js.map} +0 -0
|
@@ -6,9 +6,24 @@ import AppKit
|
|
|
6
6
|
/// focus out of whatever you were doing — the entire point of a voice
|
|
7
7
|
/// assistant is that you don't stop working to use it. It floats above
|
|
8
8
|
/// normal windows, joins every Space, and is deliberately small.
|
|
9
|
+
///
|
|
10
|
+
/// It is the one floating widget: a small live orb at its head (Orb.swift)
|
|
11
|
+
/// in the answering agent's colour, the state or the words beside it, and
|
|
12
|
+
/// a close button that shows on hover. Drag it anywhere; the place is
|
|
13
|
+
/// remembered across launches.
|
|
9
14
|
final class Panel: NSPanel {
|
|
15
|
+
static let size = NSSize(width: 284, height: 54)
|
|
16
|
+
/// The orb's diameter, and the square it draws in with room for its glow.
|
|
17
|
+
private static let orbDiameter: CGFloat = 36
|
|
18
|
+
private static let orbFrame: CGFloat = 52
|
|
19
|
+
|
|
10
20
|
private let label = NSTextField(labelWithString: "")
|
|
11
|
-
|
|
21
|
+
let orb = PillOrb(diameter: Panel.orbDiameter, frameSize: Panel.orbFrame)
|
|
22
|
+
private let closeButton = NSButton()
|
|
23
|
+
/// Hidden by close, Esc or the menu until the next talk key.
|
|
24
|
+
private(set) var dismissed = false
|
|
25
|
+
/// Set while the app moves the pill, so only a drag is remembered.
|
|
26
|
+
private var placing = false
|
|
12
27
|
|
|
13
28
|
/// Scrolls text too long for the pill instead of truncating it.
|
|
14
29
|
///
|
|
@@ -48,16 +63,24 @@ final class Panel: NSPanel {
|
|
|
48
63
|
/// The target agent's name, shown while listening or answering.
|
|
49
64
|
var agentName: () -> String = { "" }
|
|
50
65
|
|
|
66
|
+
/// The colour of the agent shown, for the orb.
|
|
67
|
+
var agentTint: () -> NSColor = { Brand.accent }
|
|
68
|
+
|
|
51
69
|
/// Stay on screen when idle. Off: the pill shows only while active.
|
|
52
70
|
var alwaysVisible = false
|
|
53
71
|
|
|
54
|
-
/// The orb overlay shows listening, thinking and speaking, so the
|
|
55
|
-
/// pill stays out of the way then; it still shows idle and errors.
|
|
56
|
-
var yieldsActiveStates = false
|
|
57
|
-
|
|
58
72
|
/// Told of every state rendered, so the menu-bar icon can follow.
|
|
59
73
|
var onRender: ((State) -> Void)?
|
|
60
74
|
|
|
75
|
+
/// Close button or Esc. The app stops speech and calls `dismiss()`.
|
|
76
|
+
var onDismiss: (() -> Void)?
|
|
77
|
+
|
|
78
|
+
/// The pill moved, by a drag or a reset. The answer card follows.
|
|
79
|
+
var onMove: (() -> Void)?
|
|
80
|
+
|
|
81
|
+
/// The last state rendered, to draw again after a dismiss or a move.
|
|
82
|
+
private var current = State.idle
|
|
83
|
+
|
|
61
84
|
override func mouseUp(with event: NSEvent) {
|
|
62
85
|
defer { pressedAt = nil }
|
|
63
86
|
if let start = pressedAt {
|
|
@@ -112,11 +135,14 @@ final class Panel: NSPanel {
|
|
|
112
135
|
/// is a running commentary and belongs in body type.
|
|
113
136
|
var isMeta: Bool { if case .idle = self { return true }; return false }
|
|
114
137
|
|
|
115
|
-
///
|
|
116
|
-
|
|
138
|
+
/// What the orb does: follows the voice, turns a ring, or pulses.
|
|
139
|
+
/// Idle and errors hold it still.
|
|
140
|
+
var orbPhase: OrbModel.Phase {
|
|
117
141
|
switch self {
|
|
118
|
-
case .idle, .error: return
|
|
119
|
-
|
|
142
|
+
case .idle, .error: return .idle
|
|
143
|
+
case .listening: return .listening
|
|
144
|
+
case .thinking, .working: return .thinking
|
|
145
|
+
case .speaking, .saying: return .speaking
|
|
120
146
|
}
|
|
121
147
|
}
|
|
122
148
|
}
|
|
@@ -130,7 +156,7 @@ final class Panel: NSPanel {
|
|
|
130
156
|
var onClick: (() -> Void)?
|
|
131
157
|
|
|
132
158
|
init() {
|
|
133
|
-
super.init(contentRect: NSRect(
|
|
159
|
+
super.init(contentRect: NSRect(origin: .zero, size: Self.size),
|
|
134
160
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
135
161
|
backing: .buffered, defer: false)
|
|
136
162
|
isFloatingPanel = true
|
|
@@ -142,7 +168,7 @@ final class Panel: NSPanel {
|
|
|
142
168
|
isMovableByWindowBackground = true
|
|
143
169
|
hidesOnDeactivate = false
|
|
144
170
|
|
|
145
|
-
let blur = NSVisualEffectView(frame: NSRect(origin: .zero, size:
|
|
171
|
+
let blur = NSVisualEffectView(frame: NSRect(origin: .zero, size: Self.size))
|
|
146
172
|
blur.material = .hudWindow
|
|
147
173
|
blur.blendingMode = .behindWindow
|
|
148
174
|
blur.state = .active
|
|
@@ -170,16 +196,19 @@ final class Panel: NSPanel {
|
|
|
170
196
|
tint.autoresizingMask = [.width, .height]
|
|
171
197
|
blur.addSubview(tint)
|
|
172
198
|
|
|
173
|
-
orb
|
|
174
|
-
orb
|
|
175
|
-
|
|
199
|
+
// The orb, centred 28 points from the left edge; its square is
|
|
200
|
+
// larger than the orb so the glow is not cut off.
|
|
201
|
+
let h = Self.size.height
|
|
202
|
+
orb.frame = NSRect(x: 28 - Self.orbFrame / 2, y: (h - Self.orbFrame) / 2,
|
|
203
|
+
width: Self.orbFrame, height: Self.orbFrame)
|
|
176
204
|
blur.addSubview(orb)
|
|
177
205
|
|
|
178
|
-
// A clipping window the label slides behind.
|
|
179
|
-
|
|
206
|
+
// A clipping window the label slides behind. Clicks pass through
|
|
207
|
+
// it, so pressing on the words drags the pill too.
|
|
208
|
+
let clipView = PassThroughView(frame: NSRect(x: 54, y: 17, width: 196, height: 20))
|
|
180
209
|
clipView.wantsLayer = true
|
|
181
210
|
clipView.layer?.masksToBounds = true
|
|
182
|
-
label.frame = NSRect(x: 0, y: 0, width:
|
|
211
|
+
label.frame = NSRect(x: 0, y: 0, width: 196, height: 20)
|
|
183
212
|
label.font = Brand.body(size: 12)
|
|
184
213
|
label.lineBreakMode = .byClipping
|
|
185
214
|
label.wantsLayer = true
|
|
@@ -187,15 +216,115 @@ final class Panel: NSPanel {
|
|
|
187
216
|
blur.addSubview(clipView)
|
|
188
217
|
clip = clipView
|
|
189
218
|
|
|
219
|
+
// Close, shown while the pointer is over the pill. Its space is
|
|
220
|
+
// kept when hidden, so the words never jump under it.
|
|
221
|
+
closeButton.frame = NSRect(x: Self.size.width - 28, y: (h - 18) / 2, width: 18, height: 18)
|
|
222
|
+
closeButton.isBordered = false
|
|
223
|
+
closeButton.bezelStyle = .regularSquare
|
|
224
|
+
closeButton.imagePosition = .imageOnly
|
|
225
|
+
closeButton.image = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Hide")
|
|
226
|
+
closeButton.contentTintColor = .secondaryLabelColor
|
|
227
|
+
closeButton.toolTip = "Hide (Esc)"
|
|
228
|
+
closeButton.setAccessibilityLabel("Hide the pill")
|
|
229
|
+
closeButton.target = self
|
|
230
|
+
closeButton.action = #selector(closeClicked)
|
|
231
|
+
closeButton.isHidden = true
|
|
232
|
+
blur.addSubview(closeButton)
|
|
233
|
+
blur.addTrackingArea(NSTrackingArea(rect: .zero,
|
|
234
|
+
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
|
|
235
|
+
owner: self, userInfo: nil))
|
|
236
|
+
|
|
190
237
|
render(.idle)
|
|
191
|
-
|
|
238
|
+
restorePosition()
|
|
239
|
+
|
|
240
|
+
NotificationCenter.default.addObserver(self, selector: #selector(moved),
|
|
241
|
+
name: NSWindow.didMoveNotification, object: self)
|
|
242
|
+
// A monitor unplugged or rearranged: bring the pill back on screen.
|
|
243
|
+
NotificationCenter.default.addObserver(self, selector: #selector(screensChanged),
|
|
244
|
+
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// MARK: Place
|
|
248
|
+
|
|
249
|
+
/// Where it was left, kept on a screen that still exists; the
|
|
250
|
+
/// bottom-right corner the first time.
|
|
251
|
+
@MainActor
|
|
252
|
+
func restorePosition() {
|
|
253
|
+
let screens = NSScreen.screens.map(\.visibleFrame)
|
|
254
|
+
let fallback = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame ?? .zero
|
|
255
|
+
place(PillPlacement.clamp(saved: Config.pillOrigin, size: frame.size, screens: screens, fallback: fallback))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// Back to the bottom-right corner, forgetting where it was dragged.
|
|
259
|
+
@MainActor
|
|
260
|
+
func resetPosition() {
|
|
261
|
+
Config.pillOrigin = nil
|
|
262
|
+
restorePosition()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
@MainActor
|
|
266
|
+
private func place(_ origin: NSPoint) {
|
|
267
|
+
placing = true
|
|
268
|
+
setFrameOrigin(origin)
|
|
269
|
+
placing = false
|
|
270
|
+
onMove?()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// Remember a drag. Moves the app makes itself are not saved, so a
|
|
274
|
+
/// reset or a clamp never overwrites where the user put it.
|
|
275
|
+
@objc private func moved() {
|
|
276
|
+
MainActor.assumeIsolated {
|
|
277
|
+
guard !placing else { return }
|
|
278
|
+
Config.pillOrigin = frame.origin
|
|
279
|
+
onMove?()
|
|
280
|
+
}
|
|
192
281
|
}
|
|
193
282
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
283
|
+
@objc private func screensChanged() {
|
|
284
|
+
MainActor.assumeIsolated { restorePosition() }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// MARK: Show and hide
|
|
288
|
+
|
|
289
|
+
/// Close, Esc or the menu: hidden until `summon()`, whatever is
|
|
290
|
+
/// rendered meanwhile.
|
|
291
|
+
@MainActor
|
|
292
|
+
func dismiss() {
|
|
293
|
+
dismissed = true
|
|
294
|
+
hide()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// The talk key: allowed on screen again.
|
|
298
|
+
@MainActor
|
|
299
|
+
func summon() {
|
|
300
|
+
guard dismissed else { return }
|
|
301
|
+
dismissed = false
|
|
302
|
+
render(current)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@MainActor
|
|
306
|
+
private func show() {
|
|
307
|
+
orb.setOnScreen(true)
|
|
308
|
+
if !isVisible { orderFrontRegardless() }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
@MainActor
|
|
312
|
+
private func hide() {
|
|
313
|
+
closeButton.isHidden = true
|
|
314
|
+
orb.setOnScreen(false)
|
|
315
|
+
if isVisible { orderOut(nil) }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
@objc private func closeClicked() { onDismiss?() }
|
|
319
|
+
|
|
320
|
+
override func mouseEntered(with event: NSEvent) { closeButton.isHidden = false }
|
|
321
|
+
override func mouseExited(with event: NSEvent) { closeButton.isHidden = true }
|
|
322
|
+
|
|
323
|
+
/// Esc, once the pill has been clicked and so holds the keyboard.
|
|
324
|
+
override func cancelOperation(_ sender: Any?) { onDismiss?() }
|
|
325
|
+
|
|
326
|
+
override func keyDown(with event: NSEvent) {
|
|
327
|
+
if event.keyCode == 53 { onDismiss?() } else { super.keyDown(with: event) }
|
|
199
328
|
}
|
|
200
329
|
|
|
201
330
|
/// AppKit is not thread-safe. Marking this explicitly means a stray
|
|
@@ -203,25 +332,29 @@ final class Panel: NSPanel {
|
|
|
203
332
|
/// corruption that traps somewhere unrelated an hour later.
|
|
204
333
|
@MainActor
|
|
205
334
|
func render(_ state: State) {
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
335
|
+
current = state
|
|
336
|
+
// The agent's colour, except where the state is the message: an
|
|
337
|
+
// error, or notifications held.
|
|
338
|
+
let tint: NSColor
|
|
339
|
+
switch state {
|
|
340
|
+
case .error: tint = Brand.alert
|
|
341
|
+
case .idle where Hold.isOn: tint = Brand.warn
|
|
342
|
+
default: tint = agentTint()
|
|
343
|
+
}
|
|
344
|
+
orb.show(state.orbPhase, tint: tint)
|
|
213
345
|
|
|
214
346
|
if state.isMeta {
|
|
215
347
|
stopMarquee()
|
|
216
348
|
label.attributedStringValue = Brand.metaString(state.text, color: state.color)
|
|
217
349
|
label.frame.origin.x = 0
|
|
218
350
|
} else {
|
|
219
|
-
|
|
351
|
+
// The orb carries the state now, so the words are plain text
|
|
352
|
+
// that reads in both themes. Errors keep their colour.
|
|
353
|
+
if case .error = state { label.textColor = state.color } else { label.textColor = .labelColor }
|
|
220
354
|
setText(named(state))
|
|
221
355
|
}
|
|
222
356
|
|
|
223
|
-
|
|
224
|
-
if (state.isMeta && !alwaysVisible) || yield { orderOut(nil) } else { orderFrontRegardless() }
|
|
357
|
+
if dismissed || (state.isMeta && !alwaysVisible) { hide() } else { show() }
|
|
225
358
|
onRender?(state)
|
|
226
359
|
}
|
|
227
360
|
|
|
@@ -283,7 +416,13 @@ final class Panel: NSPanel {
|
|
|
283
416
|
marqueeText = ""
|
|
284
417
|
}
|
|
285
418
|
|
|
286
|
-
// Borderless panels refuse key status unless told otherwise
|
|
287
|
-
//
|
|
419
|
+
// Borderless panels refuse key status unless told otherwise. The pill
|
|
420
|
+
// takes it only when clicked (showing it never does: that is
|
|
421
|
+
// orderFrontRegardless, not makeKey), so Esc can reach it then.
|
|
288
422
|
override var canBecomeKey: Bool { true }
|
|
289
423
|
}
|
|
424
|
+
|
|
425
|
+
/// A view clicks go through, to the pill behind it.
|
|
426
|
+
private final class PassThroughView: NSView {
|
|
427
|
+
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
|
428
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import CoreML
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// NVIDIA Parakeet TDT 0.6B v3 on Core ML: on-device speech to text with
|
|
5
|
+
/// no Python, from the model files ModelStore.parakeet fetches.
|
|
6
|
+
///
|
|
7
|
+
/// Four Core ML models, run in turn: the preprocessor turns 15 s of audio
|
|
8
|
+
/// into a mel spectrogram, the encoder turns that into one vector per
|
|
9
|
+
/// 80 ms, and the decoder (an LSTM over the words so far) and the joint
|
|
10
|
+
/// network walk those vectors greedily. The joint picks a token and how
|
|
11
|
+
/// many frames to skip (the "TDT" part), which is why it is fast.
|
|
12
|
+
///
|
|
13
|
+
/// v3 covers 25 European languages and detects which one on its own. It
|
|
14
|
+
/// has NO Arabic; see the benchmark in docs/dashboard/voice.md.
|
|
15
|
+
final class Parakeet: @unchecked Sendable {
|
|
16
|
+
enum ParakeetError: LocalizedError {
|
|
17
|
+
case notInstalled
|
|
18
|
+
case badOutput(String)
|
|
19
|
+
var errorDescription: String? {
|
|
20
|
+
switch self {
|
|
21
|
+
case .notInstalled: return "the Parakeet model is not downloaded yet"
|
|
22
|
+
case .badOutput(let what): return "Parakeet returned no \(what)"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static let sampleRate = 16_000
|
|
28
|
+
/// The Core ML export takes exactly 15 s.
|
|
29
|
+
static let window = 240_000
|
|
30
|
+
/// One encoder frame covers 80 ms.
|
|
31
|
+
static let samplesPerFrame = 1_280
|
|
32
|
+
static let blank = 8_192
|
|
33
|
+
static let durations = [0, 1, 2, 3, 4]
|
|
34
|
+
static let maxSymbolsPerFrame = 10
|
|
35
|
+
|
|
36
|
+
private let preprocessor: MLModel
|
|
37
|
+
private let encoder: MLModel
|
|
38
|
+
private let decoder: MLModel
|
|
39
|
+
private let joint: MLModel
|
|
40
|
+
private let vocabulary: [Int: String]
|
|
41
|
+
|
|
42
|
+
private static let lock = NSLock()
|
|
43
|
+
nonisolated(unsafe) private static var shared: Parakeet?
|
|
44
|
+
|
|
45
|
+
/// The loaded engine, kept for the life of the app: loading costs
|
|
46
|
+
/// seconds (Core ML prepares the encoder for the Neural Engine), a
|
|
47
|
+
/// transcription well under one.
|
|
48
|
+
static func loaded() throws -> Parakeet {
|
|
49
|
+
lock.lock(); defer { lock.unlock() }
|
|
50
|
+
if let shared { return shared }
|
|
51
|
+
let p = try Parakeet()
|
|
52
|
+
shared = p
|
|
53
|
+
return p
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
init() throws {
|
|
57
|
+
let store = ModelStore.parakeet
|
|
58
|
+
guard store.isInstalled else { throw ParakeetError.notInstalled }
|
|
59
|
+
func load(_ name: String, _ units: MLComputeUnits) throws -> MLModel {
|
|
60
|
+
let config = MLModelConfiguration()
|
|
61
|
+
config.computeUnits = units
|
|
62
|
+
return try MLModel(contentsOf: store.url(name), configuration: config)
|
|
63
|
+
}
|
|
64
|
+
// The same placement FluidAudio uses: the mel front end on the CPU,
|
|
65
|
+
// the rest wherever Core ML runs it best (the Neural Engine).
|
|
66
|
+
preprocessor = try load("Preprocessor.mlmodelc", .cpuOnly)
|
|
67
|
+
encoder = try load("Encoder.mlmodelc", .cpuAndNeuralEngine)
|
|
68
|
+
decoder = try load("Decoder.mlmodelc", .cpuAndNeuralEngine)
|
|
69
|
+
joint = try load("JointDecisionv3.mlmodelc", .cpuAndNeuralEngine)
|
|
70
|
+
|
|
71
|
+
let data = try Data(contentsOf: store.url("parakeet_vocab.json"))
|
|
72
|
+
let raw = try JSONSerialization.jsonObject(with: data) as? [String: String] ?? [:]
|
|
73
|
+
var vocab: [Int: String] = [:]
|
|
74
|
+
for (k, v) in raw { if let id = Int(k) { vocab[id] = v } }
|
|
75
|
+
vocabulary = vocab
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// A 16 kHz mono 16-bit WAV, as Recorder makes.
|
|
79
|
+
func transcribe(wav: Data) throws -> String {
|
|
80
|
+
try transcribe(samples: Self.samples(wav: wav))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func transcribe(samples: [Float]) throws -> String {
|
|
84
|
+
var tokens: [Int] = []
|
|
85
|
+
var state = try DecoderState(decoder: decoder)
|
|
86
|
+
for range in Self.windows(samples) {
|
|
87
|
+
tokens += try decode(Array(samples[range]), state: &state)
|
|
88
|
+
}
|
|
89
|
+
return Self.text(tokens, vocabulary: vocabulary)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// MARK: Pure helpers (tested)
|
|
93
|
+
|
|
94
|
+
/// Samples of a 16-bit PCM WAV as -1…1, found by its "data" chunk.
|
|
95
|
+
static func samples(wav: Data) -> [Float] {
|
|
96
|
+
let bytes = [UInt8](wav)
|
|
97
|
+
var offset = 12
|
|
98
|
+
while offset + 8 <= bytes.count {
|
|
99
|
+
let id = String(decoding: bytes[offset..<offset + 4], as: UTF8.self)
|
|
100
|
+
let size = Int(UInt32(bytes[offset + 4]) | UInt32(bytes[offset + 5]) << 8
|
|
101
|
+
| UInt32(bytes[offset + 6]) << 16 | UInt32(bytes[offset + 7]) << 24)
|
|
102
|
+
let body = offset + 8
|
|
103
|
+
if id == "data" {
|
|
104
|
+
let end = min(bytes.count, body + size)
|
|
105
|
+
var out = [Float]()
|
|
106
|
+
out.reserveCapacity((end - body) / 2)
|
|
107
|
+
var i = body
|
|
108
|
+
while i + 1 < end {
|
|
109
|
+
out.append(Float(Int16(bitPattern: UInt16(bytes[i]) | UInt16(bytes[i + 1]) << 8)) / 32768)
|
|
110
|
+
i += 2
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
offset = body + size + (size & 1)
|
|
115
|
+
}
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Splits audio longer than the 15 s window, each cut at the quietest
|
|
120
|
+
/// 20 ms of the window's last 3 s, so a cut rarely lands in a word.
|
|
121
|
+
static func windows(_ samples: [Float], window: Int = Parakeet.window) -> [Range<Int>] {
|
|
122
|
+
var out: [Range<Int>] = []
|
|
123
|
+
var start = 0
|
|
124
|
+
let step = sampleRate / 50
|
|
125
|
+
while samples.count - start > window {
|
|
126
|
+
let searchFrom = start + window - 3 * sampleRate
|
|
127
|
+
var best = start + window, bestEnergy = Float.greatestFiniteMagnitude
|
|
128
|
+
var at = searchFrom
|
|
129
|
+
while at + step <= start + window {
|
|
130
|
+
var e: Float = 0
|
|
131
|
+
for i in at..<at + step { e += samples[i] * samples[i] }
|
|
132
|
+
if e < bestEnergy { bestEnergy = e; best = at + step / 2 }
|
|
133
|
+
at += step
|
|
134
|
+
}
|
|
135
|
+
out.append(start..<best)
|
|
136
|
+
start = best
|
|
137
|
+
}
|
|
138
|
+
if start < samples.count { out.append(start..<samples.count) }
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// SentencePiece pieces to text: "▁" marks the start of a word.
|
|
143
|
+
static func text(_ tokens: [Int], vocabulary: [Int: String]) -> String {
|
|
144
|
+
var s = ""
|
|
145
|
+
for t in tokens {
|
|
146
|
+
guard let piece = vocabulary[t], !(piece.hasPrefix("<") && piece.hasSuffix(">")) else { continue }
|
|
147
|
+
s += piece.replacingOccurrences(of: "\u{2581}", with: " ")
|
|
148
|
+
}
|
|
149
|
+
return s.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// MARK: Inference
|
|
153
|
+
|
|
154
|
+
private struct DecoderState {
|
|
155
|
+
var hidden: MLMultiArray
|
|
156
|
+
var cell: MLMultiArray
|
|
157
|
+
/// The decoder's output for the last token emitted (blank at first).
|
|
158
|
+
var output: MLMultiArray?
|
|
159
|
+
let target: MLMultiArray
|
|
160
|
+
let targetLength: MLMultiArray
|
|
161
|
+
|
|
162
|
+
init(decoder: MLModel) throws {
|
|
163
|
+
hidden = try MLMultiArray(shape: [2, 1, 640], dataType: .float32)
|
|
164
|
+
cell = try MLMultiArray(shape: [2, 1, 640], dataType: .float32)
|
|
165
|
+
for i in 0..<hidden.count { hidden[i] = 0; cell[i] = 0 }
|
|
166
|
+
target = try MLMultiArray(shape: [1, 1], dataType: .int32)
|
|
167
|
+
targetLength = try MLMultiArray(shape: [1], dataType: .int32)
|
|
168
|
+
targetLength[0] = 1
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private func step(_ token: Int, _ state: inout DecoderState) throws {
|
|
173
|
+
state.target[0] = NSNumber(value: token)
|
|
174
|
+
let out = try decoder.prediction(from: MLDictionaryFeatureProvider(dictionary: [
|
|
175
|
+
"targets": MLFeatureValue(multiArray: state.target),
|
|
176
|
+
"target_length": MLFeatureValue(multiArray: state.targetLength),
|
|
177
|
+
"h_in": MLFeatureValue(multiArray: state.hidden),
|
|
178
|
+
"c_in": MLFeatureValue(multiArray: state.cell),
|
|
179
|
+
]))
|
|
180
|
+
guard let d = out.featureValue(for: "decoder")?.multiArrayValue,
|
|
181
|
+
let h = out.featureValue(for: "h_out")?.multiArrayValue,
|
|
182
|
+
let c = out.featureValue(for: "c_out")?.multiArrayValue else { throw ParakeetError.badOutput("decoder state") }
|
|
183
|
+
state.output = d; state.hidden = h; state.cell = c
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// One window: preprocessor, encoder, then greedy TDT decoding.
|
|
187
|
+
private func decode(_ chunk: [Float], state: inout DecoderState) throws -> [Int] {
|
|
188
|
+
guard chunk.count >= Self.samplesPerFrame else { return [] }
|
|
189
|
+
let audio = try MLMultiArray(shape: [1, NSNumber(value: Self.window)], dataType: .float32)
|
|
190
|
+
let ptr = audio.dataPointer.bindMemory(to: Float.self, capacity: Self.window)
|
|
191
|
+
ptr.initialize(repeating: 0, count: Self.window)
|
|
192
|
+
chunk.withUnsafeBufferPointer { src in ptr.update(from: src.baseAddress!, count: min(chunk.count, Self.window)) }
|
|
193
|
+
let length = try MLMultiArray(shape: [1], dataType: .int32)
|
|
194
|
+
length[0] = NSNumber(value: chunk.count)
|
|
195
|
+
|
|
196
|
+
let mel = try preprocessor.prediction(from: MLDictionaryFeatureProvider(dictionary: [
|
|
197
|
+
"audio_signal": MLFeatureValue(multiArray: audio),
|
|
198
|
+
"audio_length": MLFeatureValue(multiArray: length),
|
|
199
|
+
]))
|
|
200
|
+
guard let melArray = mel.featureValue(for: "mel")?.multiArrayValue,
|
|
201
|
+
let melLength = mel.featureValue(for: "mel_length")?.multiArrayValue else { throw ParakeetError.badOutput("mel") }
|
|
202
|
+
let enc = try encoder.prediction(from: MLDictionaryFeatureProvider(dictionary: [
|
|
203
|
+
"mel": MLFeatureValue(multiArray: melArray),
|
|
204
|
+
"mel_length": MLFeatureValue(multiArray: melLength),
|
|
205
|
+
]))
|
|
206
|
+
guard let frames = enc.featureValue(for: "encoder")?.multiArrayValue,
|
|
207
|
+
let encLength = enc.featureValue(for: "encoder_length")?.multiArrayValue else { throw ParakeetError.badOutput("encoder output") }
|
|
208
|
+
|
|
209
|
+
let hiddenSize = frames.shape[1].intValue
|
|
210
|
+
let available = frames.shape[2].intValue
|
|
211
|
+
let actual = (chunk.count + Self.samplesPerFrame - 1) / Self.samplesPerFrame
|
|
212
|
+
let count = min(encLength[0].intValue, available, actual)
|
|
213
|
+
let hStride = frames.strides[1].intValue, tStride = frames.strides[2].intValue
|
|
214
|
+
|
|
215
|
+
let encStep = try MLMultiArray(shape: [1, NSNumber(value: hiddenSize), 1], dataType: .float32)
|
|
216
|
+
let encStride = encStep.strides[1].intValue
|
|
217
|
+
let encDest = encStep.dataPointer.bindMemory(to: Float.self, capacity: encStep.count)
|
|
218
|
+
func load(frame t: Int) {
|
|
219
|
+
switch frames.dataType {
|
|
220
|
+
case .float16:
|
|
221
|
+
let src = frames.dataPointer.bindMemory(to: Float16.self, capacity: frames.count)
|
|
222
|
+
for h in 0..<hiddenSize { encDest[h * encStride] = Float(src[h * hStride + t * tStride]) }
|
|
223
|
+
default:
|
|
224
|
+
let src = frames.dataPointer.bindMemory(to: Float.self, capacity: frames.count)
|
|
225
|
+
for h in 0..<hiddenSize { encDest[h * encStride] = src[h * hStride + t * tStride] }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if state.output == nil { try step(Self.blank, &state) }
|
|
230
|
+
var tokens: [Int] = []
|
|
231
|
+
var t = 0
|
|
232
|
+
var symbolsHere = 0
|
|
233
|
+
var loaded = -1
|
|
234
|
+
while t < count {
|
|
235
|
+
if loaded != t { load(frame: t); loaded = t }
|
|
236
|
+
guard let decoderOut = state.output else { throw ParakeetError.badOutput("decoder output") }
|
|
237
|
+
let out = try joint.prediction(from: MLDictionaryFeatureProvider(dictionary: [
|
|
238
|
+
"encoder_step": MLFeatureValue(multiArray: encStep),
|
|
239
|
+
"decoder_step": MLFeatureValue(multiArray: decoderOut),
|
|
240
|
+
]))
|
|
241
|
+
guard let tokenArray = out.featureValue(for: "token_id")?.multiArrayValue,
|
|
242
|
+
let durArray = out.featureValue(for: "duration")?.multiArrayValue else { throw ParakeetError.badOutput("joint decision") }
|
|
243
|
+
let token = tokenArray[0].intValue
|
|
244
|
+
let bin = durArray[0].intValue
|
|
245
|
+
let duration = Self.durations.indices.contains(bin) ? Self.durations[bin] : 1
|
|
246
|
+
|
|
247
|
+
if token == Self.blank {
|
|
248
|
+
t += max(duration, 1)
|
|
249
|
+
symbolsHere = 0
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
tokens.append(token)
|
|
253
|
+
try step(token, &state)
|
|
254
|
+
if duration > 0 {
|
|
255
|
+
t += duration
|
|
256
|
+
symbolsHere = 0
|
|
257
|
+
} else {
|
|
258
|
+
symbolsHere += 1
|
|
259
|
+
if symbolsHere >= Self.maxSymbolsPerFrame { t += 1; symbolsHere = 0 }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return tokens
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
|
|
3
|
+
/// Where the pill and its answer card go on screen, kept free of AppKit so
|
|
4
|
+
/// the tests can check it without a window. Frames are in screen
|
|
5
|
+
/// coordinates, origin bottom-left, as AppKit uses them.
|
|
6
|
+
enum PillPlacement {
|
|
7
|
+
/// Distance from the screen's edges for the default corner.
|
|
8
|
+
static let inset: CGFloat = 24
|
|
9
|
+
/// Space between the pill and the answer card.
|
|
10
|
+
static let cardGap: CGFloat = 8
|
|
11
|
+
|
|
12
|
+
/// Bottom-right of `visible` (a screen's frame minus the menu bar and
|
|
13
|
+
/// Dock), clear of both.
|
|
14
|
+
static func defaultOrigin(size: CGSize, visible: CGRect) -> CGPoint {
|
|
15
|
+
CGPoint(x: visible.maxX - size.width - inset, y: visible.minY + inset)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// A saved position made safe: the pill lands fully on the screen it
|
|
19
|
+
/// overlaps most. When it overlaps none (the monitor it was on has been
|
|
20
|
+
/// unplugged, or the saved value is nonsense) it goes back to the
|
|
21
|
+
/// default corner of `fallback`. Nil `saved`: the default corner.
|
|
22
|
+
static func clamp(saved: CGPoint?, size: CGSize, screens: [CGRect], fallback: CGRect) -> CGPoint {
|
|
23
|
+
guard let saved, saved.x.isFinite, saved.y.isFinite else {
|
|
24
|
+
return defaultOrigin(size: size, visible: fallback)
|
|
25
|
+
}
|
|
26
|
+
let frame = CGRect(origin: saved, size: size)
|
|
27
|
+
var best: CGRect?
|
|
28
|
+
var bestArea: CGFloat = 0
|
|
29
|
+
for screen in screens {
|
|
30
|
+
let overlap = screen.intersection(frame)
|
|
31
|
+
guard !overlap.isNull else { continue }
|
|
32
|
+
let area = overlap.width * overlap.height
|
|
33
|
+
if area > bestArea { bestArea = area; best = screen }
|
|
34
|
+
}
|
|
35
|
+
guard let screen = best, bestArea > 0 else {
|
|
36
|
+
return defaultOrigin(size: size, visible: fallback)
|
|
37
|
+
}
|
|
38
|
+
return inside(frame, screen).origin
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Which side of the pill the answer card opens on, and where.
|
|
42
|
+
struct Card: Equatable {
|
|
43
|
+
let origin: CGPoint
|
|
44
|
+
/// True: above the pill. False: below it.
|
|
45
|
+
let above: Bool
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Above the pill when there is at least as much room above it as
|
|
49
|
+
/// below, else below. Right edges line up with the pill's, and the
|
|
50
|
+
/// card is kept on the screen.
|
|
51
|
+
static func card(size: CGSize, pill: CGRect, visible: CGRect) -> Card {
|
|
52
|
+
let roomAbove = visible.maxY - pill.maxY
|
|
53
|
+
let roomBelow = pill.minY - visible.minY
|
|
54
|
+
let above = roomAbove >= roomBelow
|
|
55
|
+
let y = above ? pill.maxY + cardGap : pill.minY - cardGap - size.height
|
|
56
|
+
let frame = CGRect(x: pill.maxX - size.width, y: y, width: size.width, height: size.height)
|
|
57
|
+
return Card(origin: inside(frame, visible).origin, above: above)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// `frame` moved the least distance that puts it inside `bounds`. A
|
|
61
|
+
/// frame larger than `bounds` keeps its top-left corner in view.
|
|
62
|
+
static func inside(_ frame: CGRect, _ bounds: CGRect) -> CGRect {
|
|
63
|
+
var f = frame
|
|
64
|
+
if f.maxX > bounds.maxX { f.origin.x = bounds.maxX - f.width }
|
|
65
|
+
if f.minX < bounds.minX { f.origin.x = bounds.minX }
|
|
66
|
+
if f.minY < bounds.minY { f.origin.y = bounds.minY }
|
|
67
|
+
if f.maxY > bounds.maxY { f.origin.y = bounds.maxY - f.height }
|
|
68
|
+
return f
|
|
69
|
+
}
|
|
70
|
+
}
|