agentix-cli 0.92.4 → 0.94.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.
@@ -2,14 +2,14 @@ import AppKit
2
2
 
3
3
  /// The character (#458): the orb grown into a small creature, in the
4
4
  /// agent's palette, with two eyes, no mouth and no legs. It hovers above
5
- /// the bottom edge of the screen, shows what the assistant is doing, and
6
- /// gets out of the pointer's way. Chosen with `voice.look`; the orb stays
7
- /// the default.
5
+ /// the bottom edge of the screen, or where it was dragged to (#502),
6
+ /// shows what the assistant is doing, and gets out of the pointer's way.
7
+ /// Chosen with `voice.look`; the orb stays the default.
8
8
  ///
9
- /// It reacts and never interrupts: its window takes no clicks and no
10
- /// keys, and never comes forward by itself. With Reduce Motion on, or
11
- /// "Animated orb" off in the menu, it is a still picture that changes
12
- /// between states and stays where it rests.
9
+ /// It reacts and never interrupts: its window takes no keys, no clicks
10
+ /// but a drag of its body with ⌘ held, and never comes forward by itself.
11
+ /// With Reduce Motion on, or "Animated orb" off in the menu, it is a
12
+ /// still picture that changes between states and stays where it rests.
13
13
  @MainActor
14
14
  final class CharacterHost {
15
15
  /// The body's width in points, and the room around it for its marks
@@ -18,6 +18,9 @@ final class CharacterHost {
18
18
  private static let size = NSSize(width: 440, height: 150)
19
19
  /// The edge it hovers above, from the bottom of its window.
20
20
  private static let ground: CGFloat = 8
21
+ /// Above the edge, clear of its body at the top of a hop: where its
22
+ /// speech bubble's tail ends.
23
+ private static let head: CGFloat = 78
21
24
 
22
25
  private let window: NSPanel
23
26
  private let view = CharacterView(frame: NSRect(origin: .zero, size: CharacterHost.size))
@@ -26,6 +29,8 @@ final class CharacterHost {
26
29
  private var motionObserver: NSObjectProtocol?
27
30
 
28
31
  private var shown = false
32
+ /// Hidden with its bubble, until the talk key or the menu (#502).
33
+ private var hidden = false
29
34
  private var animated = true
30
35
  private var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
31
36
  private var activity = CharacterMath.Activity.idle
@@ -34,6 +39,25 @@ final class CharacterHost {
34
39
  var levelSource: (() -> Float)?
35
40
  /// Where the pointer is, in screen coordinates.
36
41
  var pointerSource: () -> NSPoint = { NSEvent.mouseLocation }
42
+ /// Its speech bubble (#491), the pill: told every frame where the
43
+ /// character's head is and on which screen, it answers with where the
44
+ /// bubble is, or nil while it is hidden. Set by the app.
45
+ var bubble: ((NSPoint, NSRect) -> NSRect?)?
46
+ private var bubbleFrame: NSRect?
47
+
48
+ /// Where it was dragged to: the point under it. Nil: its corner.
49
+ private var place = Config.characterPlace
50
+ /// Where it rests now, `place` made safe for the screens there are.
51
+ private var rest = CGPoint.zero
52
+ /// Its body on screen: where it can be taken hold of.
53
+ private var body = NSRect.zero
54
+ /// Carried by the pointer: where the pointer took hold, and where it
55
+ /// stood then, which is not where it rests once it has stepped aside.
56
+ private var carried: (from: NSPoint, rest: CGPoint)?
57
+ /// A still picture: Reduce Motion, or "Animated orb" off.
58
+ private var still: Bool { !animated || reduceMotion }
59
+ /// The look is the character: whether it is on screen. Nil with the orb.
60
+ var onScreen: Bool? { shown ? !hidden : nil }
37
61
 
38
62
  init() {
39
63
  window = NSPanel(contentRect: NSRect(origin: .zero, size: Self.size),
@@ -47,6 +71,7 @@ final class CharacterHost {
47
71
  window.hidesOnDeactivate = false
48
72
  window.ignoresMouseEvents = true
49
73
  window.contentView = view
74
+ view.onDrag = { [weak self] phase in self?.drag(phase) }
50
75
  view.unit = Self.diameter / 100
51
76
  view.edge = CGPoint(x: Self.size.width / 2, y: Self.size.height - Self.ground)
52
77
  motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
@@ -68,6 +93,22 @@ final class CharacterHost {
68
93
  run()
69
94
  }
70
95
 
96
+ /// Hide it, as its bubble is hidden: gone until it is asked back.
97
+ func setHidden(_ on: Bool) {
98
+ guard hidden != on else { return }
99
+ hidden = on
100
+ run()
101
+ }
102
+
103
+ /// "Reset position" in the menu: back to its corner, forgetting where
104
+ /// it was dragged. Not with the orb look, whose place is the pill's.
105
+ func resetPosition() {
106
+ guard shown else { return }
107
+ place = nil
108
+ Config.characterPlace = nil
109
+ redraw()
110
+ }
111
+
71
112
  /// "Animated orb" in the menu.
72
113
  func setAnimated(_ on: Bool) {
73
114
  guard animated != on else { return }
@@ -79,36 +120,83 @@ final class CharacterHost {
79
120
  func show(_ activity: CharacterMath.Activity, tint: NSColor, colors: [NSColor]?) {
80
121
  self.activity = activity
81
122
  view.stops = CharacterDraw.stops(tint: tint, colors: colors)
82
- if shown && timer == nil { tick() }
123
+ redraw()
124
+ }
125
+
126
+ /// Standing still, it is drawn again when something changes: its
127
+ /// state, or its bubble showing or hiding.
128
+ func redraw() {
129
+ if shown && !hidden && still { tick() }
83
130
  }
84
131
 
85
132
  /// The frames run only while it is on screen and may move.
86
133
  private func run() {
87
134
  timer?.invalidate()
88
135
  timer = nil
89
- guard shown else { window.orderOut(nil); return }
136
+ guard shown, !hidden else { carried = nil; window.orderOut(nil); return }
90
137
  tick()
91
138
  window.orderFrontRegardless()
92
- guard animated, !reduceMotion else { return }
93
- timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30, repeats: true) { [weak self] _ in
94
- MainActor.assumeIsolated { self?.tick() }
139
+ // Still, it only watches for the hand that moves it.
140
+ let still = still
141
+ let timer = Timer.scheduledTimer(withTimeInterval: still ? 0.1 : 1.0 / 30, repeats: true) { [weak self] _ in
142
+ MainActor.assumeIsolated { if still { self?.aim() } else { self?.tick() } }
95
143
  }
96
144
  // Keeps moving while a menu is open or the pill is dragged.
97
- if let timer { RunLoop.main.add(timer, forMode: .common) }
145
+ RunLoop.main.add(timer, forMode: .common)
146
+ self.timer = timer
147
+ }
148
+
149
+ /// It takes the pointer only to be moved: on its body, with ⌘ held.
150
+ /// Any other click goes through it to the window behind.
151
+ private func aim() {
152
+ // The button came up where no event told of it.
153
+ if carried != nil && NSEvent.pressedMouseButtons & 1 == 0 { drop() }
154
+ let take = carried != nil || (NSEvent.modifierFlags.contains(.command) && body.contains(pointerSource()))
155
+ if window.ignoresMouseEvents == take { window.ignoresMouseEvents = !take }
156
+ }
157
+
158
+ private func drag(_ phase: CharacterView.Drag) {
159
+ switch phase {
160
+ case .began:
161
+ let mouse = pointerSource()
162
+ if body.contains(mouse) { carried = (mouse, CGPoint(x: body.midX, y: rest.y)) }
163
+ case .moved:
164
+ if carried != nil { tick() }
165
+ case .ended:
166
+ if carried != nil { tick() }
167
+ drop()
168
+ }
169
+ }
170
+
171
+ /// Let go: where it was carried to is kept. A click alone moves nothing.
172
+ private func drop() {
173
+ guard let was = carried else { return }
174
+ carried = nil
175
+ guard pointerSource() != was.from else { return }
176
+ place = rest
177
+ Config.characterPlace = rest
98
178
  }
99
179
 
100
180
  private func tick() {
101
- // The screen with the menu bar, less the menu bar and the Dock.
102
- guard let screen = NSScreen.screens.first else { return }
103
- let visible = screen.visibleFrame
104
- // It rests beside the pill's own corner, so they read as one, and
105
- // goes no further right: stepping aside never puts it on the pill.
106
- let left = Double(visible.minX + 44)
107
- let home = max(Double(visible.maxX - PillPlacement.inset - Panel.size.width - 52), left)
108
- let range = left...home
181
+ // Where nobody put it: the screen with the menu bar, less the
182
+ // menu bar and the Dock; under the right end of its bubble, the
183
+ // bubble in the pill's own corner. It goes no further right.
184
+ let screens = NSScreen.screens
185
+ guard let first = screens.first else { return }
186
+ let mouse = pointerSource()
187
+ var wanted = place
188
+ if let carried {
189
+ wanted = CGPoint(x: carried.rest.x + mouse.x - carried.from.x, y: carried.rest.y + mouse.y - carried.from.y)
190
+ }
191
+ let spot = PillPlacement.character(saved: wanted, room: Self.head + PillPlacement.tail + Panel.size.height,
192
+ screens: screens.map(\.visibleFrame), fallback: first.visibleFrame)
193
+ let visible = spot.visible
194
+ rest = spot.place
195
+ let home = Double(rest.x)
196
+ if carried != nil { sim.carry(to: home) }
109
197
 
110
198
  let frame: CharacterSim.Frame
111
- if timer == nil {
199
+ if still {
112
200
  frame = CharacterSim.still(activity, home: home)
113
201
  } else {
114
202
  if activity == .listening, let read = levelSource {
@@ -116,16 +204,28 @@ final class CharacterHost {
116
204
  } else {
117
205
  level = 0
118
206
  }
119
- let mouse = pointerSource()
120
- let pointer = screen.frame.contains(mouse) ? (x: Double(mouse.x), y: Double(mouse.y - visible.minY)) : nil
207
+ let near = screens.first { $0.visibleFrame == visible }?.frame.contains(mouse) ?? false
208
+ let pointer = near ? (x: Double(mouse.x), y: Double(mouse.y - rest.y)) : nil
209
+ // The gap its tail fills counts as the bubble, so a pointer a
210
+ // little under a button does not send both away. With ⌘ held
211
+ // it waits too, to be taken hold of.
212
+ let held = carried != nil
213
+ || (bubbleFrame?.insetBy(dx: 0, dy: -PillPlacement.tail).contains(mouse) ?? false)
214
+ || (NSEvent.modifierFlags.contains(.command) && window.frame.contains(mouse))
121
215
  frame = sim.step(to: ProcessInfo.processInfo.systemUptime,
122
- CharacterSim.Input(activity: activity, level: level, pointer: pointer, home: home, range: range))
216
+ CharacterSim.Input(activity: activity, level: level, pointer: pointer, held: held,
217
+ home: home, range: Double(spot.ends.lowerBound)...Double(spot.ends.upperBound)))
123
218
  }
124
- let origin = NSPoint(x: (CGFloat(frame.x) - Self.size.width / 2).rounded(), y: visible.minY - Self.ground)
219
+ let origin = NSPoint(x: (CGFloat(frame.x) - Self.size.width / 2).rounded(), y: (rest.y - Self.ground).rounded())
125
220
  if window.frame.origin != origin { window.setFrameOrigin(origin) }
221
+ body = NSRect(x: CGFloat(frame.x) - Self.diameter / 2, y: rest.y, width: Self.diameter, height: Self.head)
222
+ // The bubble goes with the window, so the two move as one.
223
+ bubbleFrame = bubble?(NSPoint(x: origin.x + Self.size.width / 2, y: origin.y + Self.ground + Self.head), visible)
224
+ view.bubble = bubbleFrame?.offsetBy(dx: -origin.x, dy: -origin.y)
126
225
  view.origin = origin.x
127
226
  view.shown = frame
128
227
  view.needsDisplay = true
228
+ aim()
129
229
  }
130
230
  }
131
231
 
@@ -138,9 +238,18 @@ final class CharacterView: NSView {
138
238
  /// Under the character, in this view; and the view's left edge on screen.
139
239
  var edge = CGPoint.zero
140
240
  var origin: CGFloat = 0
241
+ /// Its speech bubble in this window, origin bottom-left; nil while hidden.
242
+ var bubble: NSRect?
243
+
244
+ /// A drag of its body. Its window takes the mouse for nothing else.
245
+ enum Drag { case began, moved, ended }
246
+ var onDrag: ((Drag) -> Void)?
141
247
 
142
248
  override var isFlipped: Bool { true }
143
- override func hitTest(_ point: NSPoint) -> NSView? { nil }
249
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
250
+ override func mouseDown(with event: NSEvent) { onDrag?(.began) }
251
+ override func mouseDragged(with event: NSEvent) { onDrag?(.moved) }
252
+ override func mouseUp(with event: NSEvent) { onDrag?(.ended) }
144
253
 
145
254
  override func draw(_ dirtyRect: NSRect) {
146
255
  guard let ctx = NSGraphicsContext.current?.cgContext else { return }
@@ -148,5 +257,30 @@ final class CharacterView: NSView {
148
257
  // to the nearest point; the rest of the way is drawn here.
149
258
  let at = CGPoint(x: CGFloat(shown.x) - origin, y: edge.y)
150
259
  CharacterDraw.draw(shown, in: ctx, edge: at, origin: origin, unit: unit, stops: stops)
260
+ if let bubble { drawTail(in: ctx, from: bubble) }
261
+ }
262
+
263
+ /// The bubble's tail: a small point from its bottom edge down to the
264
+ /// character, in the bubble's own colours.
265
+ private func drawTail(in ctx: CGContext, from bubble: NSRect) {
266
+ let half: CGFloat = 7, corner = Brand.Radius.lg + half
267
+ let x = min(max(bounds.midX, bubble.minX + corner), bubble.maxX - corner)
268
+ // One point up into the bubble, so no gap shows between the two.
269
+ let top = bounds.height - bubble.minY - 1
270
+ let tip = CGPoint(x: x, y: top + 1 + PillPlacement.tail)
271
+ effectiveAppearance.performAsCurrentDrawingAppearance {
272
+ ctx.move(to: CGPoint(x: x - half, y: top))
273
+ ctx.addLine(to: tip)
274
+ ctx.addLine(to: CGPoint(x: x + half, y: top))
275
+ ctx.closePath()
276
+ ctx.setFillColor(NSColor.windowBackgroundColor.withAlphaComponent(0.92).cgColor)
277
+ ctx.fillPath()
278
+ ctx.move(to: CGPoint(x: x - half, y: top + 1))
279
+ ctx.addLine(to: tip)
280
+ ctx.addLine(to: CGPoint(x: x + half, y: top + 1))
281
+ ctx.setStrokeColor(NSColor.separatorColor.cgColor)
282
+ ctx.setLineWidth(1)
283
+ ctx.strokePath()
284
+ }
151
285
  }
152
286
  }
@@ -17,6 +17,9 @@ struct CharacterSim {
17
17
  /// The pointer: along the edge, and its height above it. Nil when
18
18
  /// it is on another screen.
19
19
  var pointer: (x: Double, y: Double)?
20
+ /// The pointer is on its speech bubble: it stays where it is, so
21
+ /// the bubble is not pulled from under the pointer.
22
+ var held = false
20
23
  /// Where it rests, and how far it may go.
21
24
  var home = 0.0
22
25
  var range: ClosedRange<Double> = 0...0
@@ -98,14 +101,17 @@ struct CharacterSim {
98
101
 
99
102
  // The pointer comes close: out of its way. Left alone: back home,
100
103
  // unless the pointer is resting there.
101
- let near = input.pointer.map { abs($0.x - x) < reach && $0.y < tall } ?? false
102
- if near {
104
+ let near = input.pointer.map { abs($0.x - x) < reach && abs($0.y) < tall } ?? false
105
+ if input.held {
106
+ target = x
107
+ awayUntil = max(awayUntil, now + Self.awayFor)
108
+ } else if near {
103
109
  target = M.aside(x: x, pointer: input.pointer!.x, clear: Self.clear, range: input.range)
104
110
  awayUntil = now + Self.awayFor
105
111
  restSince = now
106
112
  steppedAside = true
107
113
  } else if now >= awayUntil {
108
- let taken = input.pointer.map { abs($0.x - input.home) < Self.clear && $0.y < tall } ?? false
114
+ let taken = input.pointer.map { abs($0.x - input.home) < Self.clear && abs($0.y) < tall } ?? false
109
115
  if !taken {
110
116
  if target != input.home { steppedAside = false }
111
117
  target = min(max(input.home, input.range.lowerBound), input.range.upperBound)
@@ -191,6 +197,11 @@ struct CharacterSim {
191
197
  dots: dots.compactMap { dot(at: now, $0) }, stars: bursts.flatMap { stars(at: now, $0) })
192
198
  }
193
199
 
200
+ /// Carried by the pointer (#502): it is where it is put, at once.
201
+ mutating func carry(to place: Double) {
202
+ x = place; target = place; speed = 0
203
+ }
204
+
194
205
  /// The state alone, where it rests: for Reduce Motion, which shows a
195
206
  /// still picture that changes between states and never a loop.
196
207
  static func still(_ activity: M.Activity, home: Double) -> Frame {
@@ -49,6 +49,21 @@ enum Config {
49
49
  }
50
50
  }
51
51
 
52
+ /// Where the character was last dragged to (the point under it, in
53
+ /// screen points). Its own key, so the pill's place is kept for the
54
+ /// orb look. Nil: under its bubble's default corner.
55
+ static var characterPlace: CGPoint? {
56
+ get {
57
+ guard let xy = UserDefaults.standard.array(forKey: "characterPlace") as? [Double], xy.count == 2
58
+ else { return nil }
59
+ return CGPoint(x: xy[0], y: xy[1])
60
+ }
61
+ set {
62
+ if let p = newValue { UserDefaults.standard.set([Double(p.x), Double(p.y)], forKey: "characterPlace") }
63
+ else { UserDefaults.standard.removeObject(forKey: "characterPlace") }
64
+ }
65
+ }
66
+
52
67
  /// The agent actually answering: `agentID`, else `chosenAgentID`, else
53
68
  /// the daemon's default (AgentClient.resolveAgent). Progress follows it.
54
69
  @MainActor static var effectiveAgentID: String = ""
@@ -107,11 +107,18 @@ final class Panel: NSPanel {
107
107
 
108
108
  /// Told what the orb is shown, so the character can show the same.
109
109
  var onLook: ((State, NSColor, [NSColor]?) -> Void)?
110
+ /// Told when it comes on screen or leaves it, so a still character
111
+ /// draws its bubble's tail again.
112
+ var onShown: (() -> Void)?
110
113
 
111
114
  /// Off while the character stands in for the orb (Character.swift):
112
115
  /// the words start where the orb was.
113
116
  private(set) var showsOrb = true
114
117
 
118
+ /// Set while the pill is the character's speech bubble: where the
119
+ /// character's head is, and the screen it is on.
120
+ private(set) var bubble: (head: NSPoint, visible: NSRect)?
121
+
115
122
  /// Told of every state rendered, so the menu-bar icon can follow.
116
123
  var onRender: ((State) -> Void)?
117
124
 
@@ -332,18 +339,50 @@ final class Panel: NSPanel {
332
339
  @MainActor
333
340
  func restorePosition() {
334
341
  collapse(animated: false)
342
+ if let bubble {
343
+ place(PillPlacement.bubble(size: frame.size, head: bubble.head, visible: bubble.visible))
344
+ return
345
+ }
335
346
  let screens = NSScreen.screens.map(\.visibleFrame)
336
347
  let fallback = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame ?? .zero
337
348
  place(PillPlacement.clamp(saved: Config.pillOrigin, size: frame.size, screens: screens, fallback: fallback))
338
349
  }
339
350
 
340
351
  /// Back to the bottom-right corner, forgetting where it was dragged.
352
+ /// Not while it is the character's bubble: nothing would move, and
353
+ /// the place kept for the orb look would be lost.
341
354
  @MainActor
342
355
  func resetPosition() {
356
+ guard bubble == nil else { return }
343
357
  Config.pillOrigin = nil
344
358
  restorePosition()
345
359
  }
346
360
 
361
+ /// The character's speech bubble (#491): the pill sits above `head`,
362
+ /// the top of the character, and goes where it goes, grown or not. It
363
+ /// is not dragged by itself, and the place it was dragged to as a
364
+ /// pill is kept for the orb look.
365
+ @MainActor
366
+ func attach(head: NSPoint, visible: NSRect) {
367
+ bubble = (head, visible)
368
+ isMovableByWindowBackground = false
369
+ // Growing or collapsing: it catches up on the next frame.
370
+ guard placing == 0 else { return }
371
+ let to = PillPlacement.bubble(size: Self.size, head: head, visible: visible)
372
+ let from = collapsedFrame().origin
373
+ let moved = PillPlacement.inside(frame.offsetBy(dx: to.x - from.x, dy: to.y - from.y), visible)
374
+ if moved.origin != frame.origin { place(moved.origin) }
375
+ }
376
+
377
+ /// The orb look again: a pill of its own, back where it was left.
378
+ @MainActor
379
+ func detach() {
380
+ guard bubble != nil else { return }
381
+ bubble = nil
382
+ isMovableByWindowBackground = true
383
+ restorePosition()
384
+ }
385
+
347
386
  @MainActor
348
387
  private func place(_ origin: NSPoint) {
349
388
  placing += 1
@@ -398,7 +437,7 @@ final class Panel: NSPanel {
398
437
  func show() {
399
438
  orb.setOnScreen(showsOrb)
400
439
  miniOrbs.setOnScreen(!miniOrbs.isHidden)
401
- if !isVisible { orderFrontRegardless() }
440
+ if !isVisible { orderFrontRegardless(); onShown?() }
402
441
  }
403
442
 
404
443
  @MainActor
@@ -406,7 +445,7 @@ final class Panel: NSPanel {
406
445
  closeButton.isHidden = true
407
446
  orb.setOnScreen(false)
408
447
  miniOrbs.setOnScreen(false)
409
- if isVisible { orderOut(nil) }
448
+ if isVisible { orderOut(nil); onShown?() }
410
449
  }
411
450
 
412
451
  @objc private func closeClicked() { onDismiss?() }
@@ -461,7 +500,6 @@ final class Panel: NSPanel {
461
500
  default: colors = agentPalette()
462
501
  }
463
502
  orb.show(state.orbPhase, tint: tint, colors: colors)
464
- onLook?(state, tint, colors)
465
503
 
466
504
  if state.isMeta {
467
505
  stopMarquee()
@@ -477,6 +515,9 @@ final class Panel: NSPanel {
477
515
  // Grown into an answer, it stays until it collapses, idle or not;
478
516
  // so does a pill with agents still busy in its mini orbs.
479
517
  if dismissed || (state.isMeta && !alwaysVisible && !expanded && busyCount == 0) { hide() } else { show() }
518
+ // After showing or hiding: a still character draws its bubble's
519
+ // tail only while the bubble is on screen.
520
+ onLook?(state, tint, colors)
480
521
  armCollapse()
481
522
  onRender?(state)
482
523
  }
@@ -68,6 +68,55 @@ enum PillPlacement {
68
68
  width: size.width, height: size.height)
69
69
  }
70
70
 
71
+ /// How far the bubble reaches to the right of the character's middle,
72
+ /// and the gap above its head that the bubble's tail fills.
73
+ static let bubbleReach: CGFloat = 64
74
+ static let tail: CGFloat = 10
75
+
76
+ /// The pill as the character's speech bubble (#491): above `head`,
77
+ /// the top of the character, most of it to the left, kept on screen.
78
+ static func bubble(size: CGSize, head: CGPoint, visible: CGRect) -> CGPoint {
79
+ inside(CGRect(x: head.x + bubbleReach - size.width, y: head.y + tail,
80
+ width: size.width, height: size.height), visible).origin
81
+ }
82
+
83
+ /// How close the middle of the character comes to the left edge of
84
+ /// its screen.
85
+ static let characterInset: CGFloat = 44
86
+
87
+ /// Where the character rests: the point under it, the screen it is
88
+ /// on, and the ends it may step aside to.
89
+ struct Spot: Equatable {
90
+ let place: CGPoint
91
+ let visible: CGRect
92
+ let ends: ClosedRange<CGFloat>
93
+ }
94
+
95
+ /// A place the character was dragged to (#502) made safe: on the
96
+ /// screen nearest to it, between the ends it may step aside to, with
97
+ /// `room` above it for its body and its bubble. Further than `room`
98
+ /// from every screen (its monitor was unplugged, or the saved value
99
+ /// is nonsense) or nil: under the right end of its bubble, the bubble
100
+ /// in the pill's default corner of `fallback`.
101
+ static func character(saved: CGPoint?, room: CGFloat, screens: [CGRect], fallback: CGRect) -> Spot {
102
+ var visible = fallback
103
+ var p = CGPoint(x: fallback.maxX, y: fallback.minY)
104
+ if let saved, saved.x.isFinite, saved.y.isFinite {
105
+ func far(_ r: CGRect) -> CGFloat {
106
+ hypot(max(r.minX - saved.x, 0, saved.x - r.maxX), max(r.minY - saved.y, 0, saved.y - r.maxY))
107
+ }
108
+ if let near = screens.min(by: { far($0) < far($1) }), far(near) <= room {
109
+ visible = near
110
+ p = saved
111
+ }
112
+ }
113
+ let left = visible.minX + characterInset
114
+ let right = max(visible.maxX - inset - bubbleReach, left)
115
+ return Spot(place: CGPoint(x: min(max(p.x, left), right),
116
+ y: min(max(p.y, visible.minY), max(visible.maxY - room, visible.minY))),
117
+ visible: visible, ends: left...right)
118
+ }
119
+
71
120
  /// `frame` moved the least distance that puts it inside `bounds`. A
72
121
  /// frame larger than `bounds` keeps its top-left corner in view.
73
122
  static func inside(_ frame: CGRect, _ bounds: CGRect) -> CGRect {
@@ -49,6 +49,11 @@ final class StatusMenu: NSObject, NSMenuDelegate {
49
49
  var onResetPosition: (() -> Void)?
50
50
  /// Whether the pill is on screen, for "Hide pill" and "Show floating pill".
51
51
  var pillVisible: () -> Bool = { false }
52
+ /// The character look: whether the character is on screen. Nil with
53
+ /// the orb look.
54
+ var characterVisible: () -> Bool? = { nil }
55
+ /// Bring a hidden character back, and let its bubble show again.
56
+ var onShowCharacter: (() -> Void)?
52
57
  var onSettings: (() -> Void)?
53
58
 
54
59
  override init() {
@@ -243,9 +248,15 @@ final class StatusMenu: NSObject, NSMenuDelegate {
243
248
  let orb = action("Animated orb", #selector(toggleOrb), key: "")
244
249
  orb.state = Config.animatedOrb ? .on : .off
245
250
  menu.addItem(orb)
246
- let hide = action("Hide pill", #selector(hidePill), key: "")
247
- hide.isEnabled = pillVisible()
248
- menu.addItem(hide)
251
+ // The character hides with its bubble, and has no bubble at rest.
252
+ switch characterVisible() {
253
+ case true?: menu.addItem(action("Hide character", #selector(hidePill), key: ""))
254
+ case false?: menu.addItem(action("Show character", #selector(showCharacter), key: ""))
255
+ case nil:
256
+ let hide = action("Hide pill", #selector(hidePill), key: "")
257
+ hide.isEnabled = pillVisible()
258
+ menu.addItem(hide)
259
+ }
249
260
  menu.addItem(action("Reset position", #selector(resetPosition), key: ""))
250
261
 
251
262
  menu.addItem(.separator())
@@ -303,6 +314,7 @@ final class StatusMenu: NSObject, NSMenuDelegate {
303
314
  }
304
315
 
305
316
  @objc private func hidePill() { onHidePill?() }
317
+ @objc private func showCharacter() { onShowCharacter?() }
306
318
  @objc private func resetPosition() { onResetPosition?() }
307
319
 
308
320
  @objc private func openSettings() { onSettings?() }
@@ -145,6 +145,12 @@ struct VoiceSettings: Codable, Equatable {
145
145
  return out
146
146
  }
147
147
 
148
+ /// Seconds to wait before asking the daemon again after a read at
149
+ /// start that failed: 2, 4, 8, 16, then every 30.
150
+ static func retryDelay(after failures: Int) -> Double {
151
+ min(30, pow(2, Double(max(1, failures))))
152
+ }
153
+
148
154
  /// The fields a preview speaks with: the draft's voice for one agent.
149
155
  func previewVoice(for id: String) -> [String: Any] {
150
156
  guard let v = agents.first(where: { $0.id == id })?.voice else { return [:] }