agentix-cli 0.98.0 → 0.99.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.
@@ -4,7 +4,8 @@ import AppKit
4
4
  /// agent's palette, with two eyes, no mouth and no legs. It hovers above
5
5
  /// the bottom edge of the screen, or where it was dragged to (#502),
6
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.
7
+ /// The answering agent can send it to something on screen, which it
8
+ /// marks (#482). Chosen with `voice.look`; the orb stays the default.
8
9
  ///
9
10
  /// It reacts and never interrupts: its window takes no keys, no clicks
10
11
  /// but a drag of its body with ⌘ held, and never comes forward by itself.
@@ -44,6 +45,12 @@ final class CharacterHost {
44
45
  /// bubble is, or nil while it is hidden. Set by the app.
45
46
  var bubble: ((NSPoint, NSRect) -> NSRect?)?
46
47
  private var bubbleFrame: NSRect?
48
+ /// What stands on its line (#539): the sides of the windows there,
49
+ /// read once a second while it may stroll and play mode is ticked.
50
+ private var edges: [Double] = []
51
+ private var edgesRead = 0.0
52
+ /// Its bubble holds something to use: no play with the pointer then.
53
+ var bubbleShows: (() -> Bool)?
47
54
 
48
55
  /// Where it was dragged to: the point under it. Nil: its corner.
49
56
  private var place = Config.characterPlace
@@ -58,10 +65,14 @@ final class CharacterHost {
58
65
  /// Carried by the pointer: where the pointer took hold, and where it
59
66
  /// stood then, which is not where it rests once it has stepped aside.
60
67
  private var carried: (from: NSPoint, rest: CGPoint)?
68
+ /// What the answering agent sent it to show (#482), and its mark.
69
+ private let guide = CharacterGuide()
61
70
  /// A still picture: Reduce Motion, or "Animated orb" off.
62
71
  private var still: Bool { !animated || reduceMotion }
63
72
  /// The look is the character: whether it is on screen. Nil with the orb.
64
73
  var onScreen: Bool? { shown ? !hidden : nil }
74
+ /// Told when it comes on screen or leaves it: shown or hidden, or the look changed.
75
+ var onScreenChanged: (() -> Void)?
65
76
 
66
77
  init() {
67
78
  window = NSPanel(contentRect: NSRect(origin: .zero, size: Self.size),
@@ -95,6 +106,7 @@ final class CharacterHost {
95
106
  shown = on
96
107
  if on { sim = CharacterSim(unit: Double(Self.diameter) / 100) }
97
108
  run()
109
+ onScreenChanged?()
98
110
  }
99
111
 
100
112
  /// Hide it, as its bubble is hidden: gone until it is asked back.
@@ -102,6 +114,7 @@ final class CharacterHost {
102
114
  guard hidden != on else { return }
103
115
  hidden = on
104
116
  run()
117
+ onScreenChanged?()
105
118
  }
106
119
 
107
120
  /// "Reset position" in the menu: back to its corner, forgetting where
@@ -130,6 +143,16 @@ final class CharacterHost {
130
143
  redraw()
131
144
  }
132
145
 
146
+ /// Go beside `rect` and mark it, or with nil go back to where it rests.
147
+ func guide(to rect: NSRect?, mark kind: GuideMath.Mark = .none) {
148
+ if shown, !hidden, carried == nil, let rect {
149
+ guide.show(rect, kind, color: view.stops[2], animated: !still)
150
+ } else {
151
+ guide.end()
152
+ }
153
+ redraw()
154
+ }
155
+
133
156
  /// Standing still, it is drawn again when something changes: its
134
157
  /// state, or its bubble showing or hiding.
135
158
  func redraw() {
@@ -140,7 +163,7 @@ final class CharacterHost {
140
163
  private func run() {
141
164
  timer?.invalidate()
142
165
  timer = nil
143
- guard shown, !hidden else { carried = nil; window.orderOut(nil); return }
166
+ guard shown, !hidden else { carried = nil; guide(to: nil); window.orderOut(nil); return }
144
167
  tick()
145
168
  window.orderFrontRegardless()
146
169
  // Still, it only watches for the hand that moves it.
@@ -166,7 +189,7 @@ final class CharacterHost {
166
189
  switch phase {
167
190
  case .began:
168
191
  let mouse = pointerSource()
169
- if body.contains(mouse) { carried = (mouse, CGPoint(x: body.midX, y: rest.y)) }
192
+ if body.contains(mouse) { guide.end(glide: false); carried = (mouse, CGPoint(x: body.midX, y: rest.y)) }
170
193
  case .moved:
171
194
  if carried != nil { tick() }
172
195
  case .ended:
@@ -194,11 +217,14 @@ final class CharacterHost {
194
217
  var wanted = place
195
218
  if let carried {
196
219
  wanted = CGPoint(x: carried.rest.x + mouse.x - carried.from.x, y: carried.rest.y + mouse.y - carried.from.y)
220
+ } else if let beside = guide.stand(screens: screens, body: Self.diameter, tall: Self.head) {
221
+ wanted = beside
197
222
  }
198
223
  let spot = PillPlacement.character(saved: wanted, room: Self.head + PillPlacement.tail + Panel.size.height,
199
224
  screens: screens.map(\.visibleFrame), fallback: first.visibleFrame)
200
225
  let visible = spot.visible
201
- rest = spot.place
226
+ let now = ProcessInfo.processInfo.systemUptime
227
+ rest = CGPoint(x: spot.place.x, y: guide.height(from: rest.y, toward: spot.place.y, now: now, still: still))
202
228
  let home = Double(rest.x)
203
229
  if carried != nil { sim.carry(to: home) }
204
230
 
@@ -212,15 +238,23 @@ final class CharacterHost {
212
238
  level = 0
213
239
  }
214
240
  let near = screens.first { $0.visibleFrame == visible }?.frame.contains(mouse) ?? false
215
- let pointer = near ? (x: Double(mouse.x), y: Double(mouse.y - rest.y)) : nil
241
+ // Showing something, it does not step aside for the pointer.
242
+ let pointer = near && guide.showing == nil ? (x: Double(mouse.x), y: Double(mouse.y - rest.y)) : nil
216
243
  // The gap its tail fills counts as the bubble, so a pointer a
217
244
  // little under a button does not send both away. With ⌘ held
218
245
  // it waits too, to be taken hold of.
219
246
  let held = carried != nil
220
247
  || (bubbleFrame?.insetBy(dx: 0, dy: -PillPlacement.tail).contains(mouse) ?? false)
221
248
  || (NSEvent.modifierFlags.contains(.command) && window.frame.contains(mouse))
222
- frame = sim.step(to: ProcessInfo.processInfo.systemUptime,
223
- CharacterSim.Input(activity: activity, level: level, pointer: pointer, held: held, strolls: strolls,
249
+ if now - edgesRead >= 1 {
250
+ edgesRead = now
251
+ // The window list counts down from the top of the main screen.
252
+ let top = first.frame.maxY - rest.y
253
+ edges = strolls && Config.playMode ? Meets.edges(of: Meets.windows(), band: (top - Self.head)...top) : []
254
+ }
255
+ frame = sim.step(to: now,
256
+ CharacterSim.Input(activity: activity, level: level, pointer: pointer, held: held, sent: guide.showing != nil, strolls: strolls,
257
+ edges: edges, shows: bubbleShows?() ?? false, plays: Config.playMode, down: NSEvent.pressedMouseButtons & 1 != 0,
224
258
  home: home, range: Double(spot.ends.lowerBound)...Double(spot.ends.upperBound)))
225
259
  }
226
260
  let origin = NSPoint(x: (CGFloat(frame.x) - Self.size.width / 2).rounded(), y: (rest.y - Self.ground).rounded())
@@ -235,59 +269,3 @@ final class CharacterHost {
235
269
  aim()
236
270
  }
237
271
  }
238
-
239
- /// The character's drawing surface. Flipped, so the drawing's y grows
240
- /// downwards as in the pose sheet it was ported from.
241
- final class CharacterView: NSView {
242
- var shown = CharacterSim.Frame()
243
- var stops = CharacterDraw.stops(tint: Brand.accent, colors: nil)
244
- var unit: CGFloat = 0.56
245
- /// Under the character, in this view; and the view's left edge on screen.
246
- var edge = CGPoint.zero
247
- var origin: CGFloat = 0
248
- /// Its speech bubble in this window, origin bottom-left; nil while hidden.
249
- var bubble: NSRect?
250
-
251
- /// A drag of its body. Its window takes the mouse for nothing else.
252
- enum Drag { case began, moved, ended }
253
- var onDrag: ((Drag) -> Void)?
254
-
255
- override var isFlipped: Bool { true }
256
- override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
257
- override func mouseDown(with event: NSEvent) { onDrag?(.began) }
258
- override func mouseDragged(with event: NSEvent) { onDrag?(.moved) }
259
- override func mouseUp(with event: NSEvent) { onDrag?(.ended) }
260
-
261
- override func draw(_ dirtyRect: NSRect) {
262
- guard let ctx = NSGraphicsContext.current?.cgContext else { return }
263
- // The body is drawn where the window is, which follows the frame
264
- // to the nearest point; the rest of the way is drawn here.
265
- let at = CGPoint(x: CGFloat(shown.x) - origin, y: edge.y)
266
- CharacterDraw.draw(shown, in: ctx, edge: at, origin: origin, unit: unit, stops: stops)
267
- if let bubble { drawTail(in: ctx, from: bubble) }
268
- }
269
-
270
- /// The bubble's tail: a small point from its bottom edge down to the
271
- /// character, in the bubble's own colours.
272
- private func drawTail(in ctx: CGContext, from bubble: NSRect) {
273
- let half: CGFloat = 7, corner = Brand.Radius.lg + half
274
- let x = min(max(bounds.midX, bubble.minX + corner), bubble.maxX - corner)
275
- // One point up into the bubble, so no gap shows between the two.
276
- let top = bounds.height - bubble.minY - 1
277
- let tip = CGPoint(x: x, y: top + 1 + PillPlacement.tail)
278
- effectiveAppearance.performAsCurrentDrawingAppearance {
279
- ctx.move(to: CGPoint(x: x - half, y: top))
280
- ctx.addLine(to: tip)
281
- ctx.addLine(to: CGPoint(x: x + half, y: top))
282
- ctx.closePath()
283
- ctx.setFillColor(NSColor.windowBackgroundColor.withAlphaComponent(0.92).cgColor)
284
- ctx.fillPath()
285
- ctx.move(to: CGPoint(x: x - half, y: top + 1))
286
- ctx.addLine(to: tip)
287
- ctx.addLine(to: CGPoint(x: x + half, y: top + 1))
288
- ctx.setStrokeColor(NSColor.separatorColor.cgColor)
289
- ctx.setLineWidth(1)
290
- ctx.strokePath()
291
- }
292
- }
293
- }
@@ -142,4 +142,13 @@ enum CharacterMath {
142
142
  if range.contains(there) { return there }
143
143
  return min(max(pointer - side * clear, range.lowerBound), range.upperBound)
144
144
  }
145
+
146
+ /// Walking from `from` to `to`: the nearest of `edges` in its way, and
147
+ /// where it stops, `gap` short of it and never back from where it is.
148
+ static func stop(from: Double, to: Double, edges: [Double], gap: Double) -> (edge: Double, at: Double)? {
149
+ let way: Double = to >= from ? 1 : -1
150
+ let ahead = edges.filter { way * ($0 - from) > 0 && way * ($0 - to) < gap }
151
+ guard let edge = ahead.min(by: { abs($0 - from) < abs($1 - from) }) else { return nil }
152
+ return (edge, from + way * max(way * (edge - from) - gap, 0))
153
+ }
145
154
  }
@@ -10,48 +10,6 @@ import Foundation
10
10
  struct CharacterSim {
11
11
  typealias M = CharacterMath
12
12
 
13
- struct Input {
14
- var activity = M.Activity.idle
15
- /// 0…1: the microphone while listening.
16
- var level = 0.0
17
- /// The pointer: along the edge, and its height above it. Nil when
18
- /// it is on another screen.
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
23
- /// `voice.stroll`: with nothing to do, it takes a slow stroll
24
- /// beside where it rests now and then (#482).
25
- var strolls = false
26
- /// Where it rests, and how far it may go.
27
- var home = 0.0
28
- var range: ClosedRange<Double> = 0...0
29
- }
30
-
31
- /// A trail dot or a star, where and how it is drawn this frame.
32
- struct Mark: Equatable {
33
- /// Along the edge and above it, in points.
34
- var x, y: Double
35
- /// Radius in drawing units, opacity, turn in degrees, and which
36
- /// palette stop colours it.
37
- var r, alpha, turn: Double
38
- var shade: Int
39
- }
40
-
41
- struct Frame {
42
- var pose = M.Pose()
43
- var x = 0.0
44
- /// -1 turned left, 0 facing you, 1 turned right.
45
- var face = 0.0
46
- var level = 0.0
47
- /// 0…1: the rise and fall of its own voice while speaking.
48
- var voice = 0.0
49
- /// Seconds since it appeared, for the marks' own loops.
50
- var t = 0.0
51
- var dots: [Mark] = []
52
- var stars: [Mark] = []
53
- }
54
-
55
13
  /// Points per drawing unit: a body 56 points across.
56
14
  let unit: Double
57
15
  /// Idle this long, it dozes.
@@ -82,6 +40,11 @@ struct CharacterSim {
82
40
  private var strollTo = 0.0, strolled = 0.0
83
41
  private var strollNext: Double?
84
42
  private var strolls = 0
43
+ /// What stopped the stroll it is on.
44
+ private var met: Double?
45
+ /// Its games with the pointer, and the crouch and hop they give it.
46
+ private(set) var play = PointerPlay()
47
+ private var crouch = 0.0, hop = 0.0
85
48
  private var face = 0.0, lean = 0.0
86
49
  private var glance = (x: 0.0, y: 0.0)
87
50
 
@@ -113,9 +76,28 @@ struct CharacterSim {
113
76
  // The pointer comes close: out of its way. Left alone: back home,
114
77
  // unless the pointer is resting there.
115
78
  let near = input.pointer.map { abs($0.x - x) < reach && abs($0.y) < tall } ?? false
116
- if input.held {
79
+ // Idle with play mode on, a game with the pointer comes first.
80
+ // Sent to show something, it does not play.
81
+ var game = PointerPlay.Out()
82
+ if input.plays && !input.sent && !input.held && !input.shows && mood == .idle && input.activity == .idle {
83
+ game = play.step(now, dt, x: x, pointer: input.pointer, down: input.down, range: input.range)
84
+ } else {
85
+ play.stop(now, down: input.down)
86
+ }
87
+ if input.sent {
88
+ target = min(max(input.home, input.range.lowerBound), input.range.upperBound)
89
+ restSince = now
90
+ // The stroll is over: sent home again, it rests there.
91
+ strolled = 0; strollTo = 0; strollNext = nil
92
+ } else if input.held {
117
93
  target = x
118
94
  awayUntil = max(awayUntil, now + Self.awayFor)
95
+ } else if let to = game.target {
96
+ target = to
97
+ awayUntil = now + 1
98
+ restSince = now
99
+ // A game ends a stroll, as stepping aside does.
100
+ strolled = 0; strollTo = 0; strollNext = nil
119
101
  } else if near {
120
102
  target = M.aside(x: x, pointer: input.pointer!.x, clear: Self.clear, range: input.range)
121
103
  awayUntil = now + Self.awayFor
@@ -158,8 +140,19 @@ struct CharacterSim {
158
140
  let moving = abs(speed) > 40
159
141
  let way: Double = speed >= 0 ? 1 : -1
160
142
  func toward(_ value: inout Double, _ to: Double, _ rate: Double) { value += (to - value) * (1 - exp(-dt * rate)) }
161
- toward(&face, min(max(speed / 200, -1), 1), 10)
143
+ // Stopped at something in its way: it turns to look at it.
144
+ let stopped = strollTo != 0 && abs(strollTo - strolled) < 0.5 ? met : nil
145
+ let turned = stopped.map { $0 > x ? 0.7 : -0.7 } ?? 0
146
+ toward(&face, min(max(speed / 200 + turned, -1), 1), 10)
162
147
  toward(&lean, min(max(speed / 480, -1), 1) * 13, 8)
148
+ // A game's crouch and hop are followed, not taken at once, so a
149
+ // game cut short by work does not snap.
150
+ toward(&crouch, game.crouch, 20)
151
+ toward(&hop, game.hop, 20)
152
+ pose.sx *= 1 + 0.14 * crouch
153
+ pose.sy *= 1 - 0.22 * crouch
154
+ pose.lift += hop - 8 * crouch
155
+ if game.caught { burst(now, pose) }
163
156
 
164
157
  // It leaves dots behind while it moves, and stars when it has
165
158
  // stepped aside or understood.
@@ -186,6 +179,7 @@ struct CharacterSim {
186
179
  let pull = min(far / 60, 1) * 4.5
187
180
  look = (dx / far * pull, dy / far * pull)
188
181
  }
182
+ if stopped != nil { look = (turned * 5, -2) }
189
183
  toward(&glance.x, look.x + face * 2, 9)
190
184
  toward(&glance.y, look.y, 9)
191
185
 
@@ -229,12 +223,20 @@ struct CharacterSim {
229
223
  var way: Double = M.rnd(strolls, 2) < 0.5 ? -1 : 1
230
224
  if (way > 0 ? room.right : room.left) < far { way = -way }
231
225
  strollTo = way * min(far, way > 0 ? room.right : room.left)
226
+ met = nil
232
227
  strollNext = now + abs(strollTo) / Self.strollSpeed + 5 + 6 * M.rnd(strolls, 4)
233
228
  } else {
234
229
  strollTo = 0
235
230
  strollNext = nil
236
231
  }
237
232
  }
233
+ // Something in its way: it goes no further than just short of it.
234
+ // Only on the way out: nothing keeps it from going home.
235
+ if strollTo != 0, strollTo != strolled, let stop = M.stop(from: input.home + strolled, to: input.home + strollTo,
236
+ edges: input.edges, gap: 50 * unit + 10) {
237
+ strollTo = stop.at - input.home
238
+ met = stop.edge
239
+ }
238
240
  let pace = Self.strollSpeed * dt
239
241
  strolled += min(max(strollTo - strolled, -pace), pace)
240
242
  }
@@ -0,0 +1,59 @@
1
+ import Foundation
2
+
3
+ /// What the character sim is given and returns each frame (CharacterSim.swift).
4
+ extension CharacterSim {
5
+ struct Input {
6
+ var activity = M.Activity.idle
7
+ /// 0…1: the microphone while listening.
8
+ var level = 0.0
9
+ /// The pointer: along the edge, and its height above it. Nil when
10
+ /// it is on another screen.
11
+ var pointer: (x: Double, y: Double)?
12
+ /// The pointer is on its speech bubble: it stays where it is, so
13
+ /// the bubble is not pulled from under the pointer.
14
+ var held = false
15
+ /// The answering agent sent it to show something (#482): it goes
16
+ /// to `home` at once, awake, whatever the pointer did before.
17
+ var sent = false
18
+ /// `voice.stroll`: with nothing to do, it takes a slow stroll
19
+ /// beside where it rests now and then (#482).
20
+ var strolls = false
21
+ /// What stands on its line, along the edge: the sides of the
22
+ /// windows there (#539). On a stroll it stops short of the first.
23
+ var edges: [Double] = []
24
+ /// Its bubble holds something to use (an answer, an error, the
25
+ /// call buttons): no game, so the bubble waits for the hand.
26
+ var shows = false
27
+ /// "Play mode" is ticked: idle, it plays with the pointer (#505).
28
+ var plays = false
29
+ /// The mouse button is down.
30
+ var down = false
31
+ /// Where it rests, and how far it may go.
32
+ var home = 0.0
33
+ var range: ClosedRange<Double> = 0...0
34
+ }
35
+
36
+ /// A trail dot or a star, where and how it is drawn this frame.
37
+ struct Mark: Equatable {
38
+ /// Along the edge and above it, in points.
39
+ var x, y: Double
40
+ /// Radius in drawing units, opacity, turn in degrees, and which
41
+ /// palette stop colours it.
42
+ var r, alpha, turn: Double
43
+ var shade: Int
44
+ }
45
+
46
+ struct Frame {
47
+ var pose = M.Pose()
48
+ var x = 0.0
49
+ /// -1 turned left, 0 facing you, 1 turned right.
50
+ var face = 0.0
51
+ var level = 0.0
52
+ /// 0…1: the rise and fall of its own voice while speaking.
53
+ var voice = 0.0
54
+ /// Seconds since it appeared, for the marks' own loops.
55
+ var t = 0.0
56
+ var dots: [Mark] = []
57
+ var stars: [Mark] = []
58
+ }
59
+ }
@@ -0,0 +1,57 @@
1
+ import AppKit
2
+
3
+ /// The character's drawing surface. Flipped, so the drawing's y grows
4
+ /// downwards as in the pose sheet it was ported from.
5
+ final class CharacterView: NSView {
6
+ var shown = CharacterSim.Frame()
7
+ var stops = CharacterDraw.stops(tint: Brand.accent, colors: nil)
8
+ var unit: CGFloat = 0.56
9
+ /// Under the character, in this view; and the view's left edge on screen.
10
+ var edge = CGPoint.zero
11
+ var origin: CGFloat = 0
12
+ /// Its speech bubble in this window, origin bottom-left; nil while hidden.
13
+ var bubble: NSRect?
14
+
15
+ /// A drag of its body. Its window takes the mouse for nothing else.
16
+ enum Drag { case began, moved, ended }
17
+ var onDrag: ((Drag) -> Void)?
18
+
19
+ override var isFlipped: Bool { true }
20
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
21
+ override func mouseDown(with event: NSEvent) { onDrag?(.began) }
22
+ override func mouseDragged(with event: NSEvent) { onDrag?(.moved) }
23
+ override func mouseUp(with event: NSEvent) { onDrag?(.ended) }
24
+
25
+ override func draw(_ dirtyRect: NSRect) {
26
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
27
+ // The body is drawn where the window is, which follows the frame
28
+ // to the nearest point; the rest of the way is drawn here.
29
+ let at = CGPoint(x: CGFloat(shown.x) - origin, y: edge.y)
30
+ CharacterDraw.draw(shown, in: ctx, edge: at, origin: origin, unit: unit, stops: stops)
31
+ if let bubble { drawTail(in: ctx, from: bubble) }
32
+ }
33
+
34
+ /// The bubble's tail: a small point from its bottom edge down to the
35
+ /// character, in the bubble's own colours.
36
+ private func drawTail(in ctx: CGContext, from bubble: NSRect) {
37
+ let half: CGFloat = 7, corner = Brand.Radius.lg + half
38
+ let x = min(max(bounds.midX, bubble.minX + corner), bubble.maxX - corner)
39
+ // One point up into the bubble, so no gap shows between the two.
40
+ let top = bounds.height - bubble.minY - 1
41
+ let tip = CGPoint(x: x, y: top + 1 + PillPlacement.tail)
42
+ effectiveAppearance.performAsCurrentDrawingAppearance {
43
+ ctx.move(to: CGPoint(x: x - half, y: top))
44
+ ctx.addLine(to: tip)
45
+ ctx.addLine(to: CGPoint(x: x + half, y: top))
46
+ ctx.closePath()
47
+ ctx.setFillColor(NSColor.windowBackgroundColor.withAlphaComponent(0.92).cgColor)
48
+ ctx.fillPath()
49
+ ctx.move(to: CGPoint(x: x - half, y: top + 1))
50
+ ctx.addLine(to: tip)
51
+ ctx.addLine(to: CGPoint(x: x + half, y: top + 1))
52
+ ctx.setStrokeColor(NSColor.separatorColor.cgColor)
53
+ ctx.setLineWidth(1)
54
+ ctx.strokePath()
55
+ }
56
+ }
57
+ }
@@ -36,7 +36,8 @@ enum Config {
36
36
  }
37
37
 
38
38
  /// "Play mode" in the menu (#505): the character may play on a frozen
39
- /// picture of the screen when asked. Off by default.
39
+ /// picture of the screen when asked, and plays with the pointer while
40
+ /// idle. Off by default.
40
41
  static var playMode: Bool {
41
42
  get { UserDefaults.standard.bool(forKey: "playMode") }
42
43
  set { UserDefaults.standard.set(newValue, forKey: "playMode") }
@@ -0,0 +1,185 @@
1
+ import AppKit
2
+
3
+ /// Guiding (#482): the daemon's /voice/guide route, the wait on it, and
4
+ /// the mark the character leaves on what it shows. Where it stands is in
5
+ /// GuideMath.swift; Character.swift moves it there.
6
+ enum GuideClient {
7
+ struct Command: Decodable {
8
+ struct Rect: Decodable { let x, y, width, height: Double }
9
+ let seq: Int
10
+ /// Nil: back to where it rests.
11
+ let rect: Rect?
12
+ let mark: String
13
+ }
14
+
15
+ /// GET /voice/guide: the command after `seq`, as soon as there is
16
+ /// one; the current one after 25 s; nil when the daemon cannot be
17
+ /// reached or is too old to know the route.
18
+ static func next(after seq: Int) async -> Command? {
19
+ guard let url = URL(string: "\(Config.daemonURL)/voice/guide?after=\(seq)") else { return nil }
20
+ var req = URLRequest(url: url)
21
+ req.timeoutInterval = 35
22
+ guard let (data, response) = try? await URLSession.shared.data(for: req),
23
+ (response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
24
+ return try? JSONDecoder().decode(Command.self, from: data)
25
+ }
26
+ }
27
+
28
+ /// Waits on the daemon while the character is on screen. The wait is
29
+ /// also how the daemon knows there is a character to send.
30
+ @MainActor
31
+ final class GuideWatcher {
32
+ private var seq = 0
33
+ private var away = false
34
+ private var task: Task<Void, Never>?
35
+ /// The character is on screen.
36
+ var wanted: (() -> Bool)?
37
+ /// Show this, in AppKit coordinates, or go home.
38
+ var onCommand: ((NSRect?, GuideMath.Mark) -> Void)?
39
+
40
+ /// (Re)start the wait. Called again when the character comes on screen
41
+ /// or leaves it, so a wait already open is ended: cancelling it closes
42
+ /// its socket, and the daemon stops counting a character as there.
43
+ func start() {
44
+ task?.cancel()
45
+ task = Task { [weak self] in
46
+ while !Task.isCancelled {
47
+ guard let self else { return }
48
+ await self.turn()
49
+ }
50
+ }
51
+ }
52
+
53
+ private func turn() async {
54
+ guard wanted?() == true, let command = await GuideClient.next(after: seq) else {
55
+ // Hidden, or the daemon is gone: nothing is left on screen.
56
+ if away { away = false; onCommand?(nil, .none) }
57
+ try? await Task.sleep(nanoseconds: 2_000_000_000)
58
+ return
59
+ }
60
+ guard command.seq != seq else { return }
61
+ seq = command.seq
62
+ let rect = command.rect.map {
63
+ GuideMath.toAppKit(CGRect(x: $0.x, y: $0.y, width: $0.width, height: $0.height),
64
+ primaryHeight: NSScreen.screens.first?.frame.maxY ?? 0)
65
+ }
66
+ away = rect != nil
67
+ Log.info("guide: \(rect.map { "to \(Int($0.minX)),\(Int($0.minY)) \(Int($0.width))x\(Int($0.height)) \(command.mark)" } ?? "home")")
68
+ onCommand?(rect, GuideMath.Mark(rawValue: command.mark) ?? .box)
69
+ }
70
+ }
71
+
72
+ /// What the character was sent to show (#482): it stands beside it until
73
+ /// it is sent home, taken hold of or hidden; its mark; its glide there and back.
74
+ @MainActor
75
+ final class CharacterGuide {
76
+ private(set) var showing: NSRect?
77
+ private let mark = GuideMark()
78
+ /// On its way up or down, to what it shows or back; and the last frame.
79
+ private var gliding = false
80
+ private var ticked = 0.0
81
+
82
+ func show(_ rect: NSRect, _ kind: GuideMath.Mark, color: NSColor, animated: Bool) {
83
+ showing = rect
84
+ gliding = true
85
+ mark.show(kind, around: rect, color: color, animated: animated)
86
+ }
87
+
88
+ /// Back to where it rests, gliding there unless it was taken hold of.
89
+ func end(glide: Bool = true) {
90
+ gliding = glide && showing != nil
91
+ showing = nil
92
+ mark.hide()
93
+ }
94
+
95
+ /// Where it stands beside what it shows, on that thing's screen.
96
+ func stand(screens: [NSScreen], body: CGFloat, tall: CGFloat) -> CGPoint? {
97
+ guard let showing, let first = screens.first else { return nil }
98
+ let middle = CGPoint(x: showing.midX, y: showing.midY)
99
+ return GuideMath.stand(beside: showing, in: (screens.first { $0.frame.contains(middle) } ?? first).visibleFrame,
100
+ body: body, tall: tall, inset: PillPlacement.characterInset)
101
+ }
102
+
103
+ /// Its height this frame: sent somewhere or back, it glides up or down
104
+ /// to `y`; otherwise it is there at once.
105
+ func height(from: CGFloat, toward y: CGFloat, now: Double, still: Bool) -> CGFloat {
106
+ let h = gliding && !still ? GuideMath.glide(from, toward: y, dt: now - ticked) : y
107
+ gliding = h != y
108
+ ticked = now
109
+ return h
110
+ }
111
+ }
112
+
113
+ /// The mark on what the character shows: drawn on its own click-through
114
+ /// window over the page, which is never touched.
115
+ @MainActor
116
+ final class GuideMark {
117
+ /// Room around the outline for the stroke.
118
+ private static let margin: CGFloat = 6
119
+ private let window: NSPanel
120
+ private let shape = CAShapeLayer()
121
+
122
+ init() {
123
+ window = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
124
+ window.level = .floating
125
+ window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
126
+ window.isOpaque = false
127
+ window.backgroundColor = .clear
128
+ window.hasShadow = false
129
+ window.hidesOnDeactivate = false
130
+ window.ignoresMouseEvents = true
131
+ let view = NSView()
132
+ view.wantsLayer = true
133
+ view.layer?.addSublayer(shape)
134
+ window.contentView = view
135
+ shape.lineWidth = 3
136
+ shape.lineCap = .round
137
+ shape.lineJoin = .round
138
+ }
139
+
140
+ /// Draw `mark` around `rect`. Animated, it is drawn on once the
141
+ /// character has had time to get there.
142
+ func show(_ mark: GuideMath.Mark, around rect: NSRect, color: NSColor, animated: Bool) {
143
+ // Kept to the screen it is on, whatever size it was sent.
144
+ let screen = NSScreen.screens.first { $0.frame.intersects(rect) }?.frame ?? .null
145
+ let outline = GuideMath.outline(mark, around: rect).intersection(screen)
146
+ guard !outline.isNull, !outline.isEmpty else { return hide() }
147
+ let frame = outline.insetBy(dx: -Self.margin, dy: -Self.margin)
148
+ window.setFrame(frame, display: false)
149
+ let inner = CGRect(x: Self.margin, y: Self.margin, width: outline.width, height: outline.height)
150
+ let path = CGMutablePath()
151
+ switch mark {
152
+ case .circle: path.addEllipse(in: inner)
153
+ case .underline: path.move(to: inner.origin); path.addLine(to: CGPoint(x: inner.maxX, y: inner.minY))
154
+ default: path.addRoundedRect(in: inner, cornerWidth: min(6, inner.width / 2), cornerHeight: min(6, inner.height / 2))
155
+ }
156
+ shape.frame = CGRect(origin: .zero, size: frame.size)
157
+ shape.path = path
158
+ shape.strokeColor = color.cgColor
159
+ shape.fillColor = mark == .box ? color.withAlphaComponent(0.12).cgColor : nil
160
+ shape.removeAllAnimations()
161
+ if animated {
162
+ let draw = CABasicAnimation(keyPath: "strokeEnd")
163
+ draw.fromValue = 0
164
+ draw.toValue = 1
165
+ draw.beginTime = CACurrentMediaTime() + 0.35
166
+ draw.duration = 0.45
167
+ draw.fillMode = .backwards
168
+ draw.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
169
+ shape.add(draw, forKey: "draw")
170
+ let appear = CABasicAnimation(keyPath: "opacity")
171
+ appear.fromValue = 0
172
+ appear.toValue = 1
173
+ appear.beginTime = draw.beginTime
174
+ appear.duration = 0.15
175
+ appear.fillMode = .backwards
176
+ shape.add(appear, forKey: "appear")
177
+ }
178
+ window.orderFrontRegardless()
179
+ }
180
+
181
+ func hide() {
182
+ shape.removeAllAnimations()
183
+ window.orderOut(nil)
184
+ }
185
+ }