agentix-cli 0.89.0 → 0.91.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.
Files changed (126) hide show
  1. package/apps/mac-voice/Sources/AgentXVoice/Character.swift +152 -0
  2. package/apps/mac-voice/Sources/AgentXVoice/CharacterDraw.swift +218 -0
  3. package/apps/mac-voice/Sources/AgentXVoice/CharacterMath.swift +145 -0
  4. package/apps/mac-voice/Sources/AgentXVoice/CharacterSim.swift +241 -0
  5. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +35 -3
  6. package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +19 -0
  7. package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +4 -0
  8. package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +7 -2
  9. package/apps/mac-voice/Sources/AgentXVoice/main.swift +11 -0
  10. package/dist/{App-BSGDYHRD.js → App-PBPYFVBE.js} +2 -2
  11. package/dist/{ChatApp-K57DBCDT.js → ChatApp-QC7QAQEW.js} +2 -2
  12. package/dist/{agent-6MTUM5DR.js → agent-BQEENICU.js} +2 -2
  13. package/dist/{board-dashboard-GL6GQ32D.js → board-dashboard-ZLDVYPZ4.js} +210 -210
  14. package/dist/{board-dashboard-GL6GQ32D.js.map → board-dashboard-ZLDVYPZ4.js.map} +1 -1
  15. package/dist/{chunk-54VYZE7A.js → chunk-24P6UR3L.js} +12 -12
  16. package/dist/chunk-24P6UR3L.js.map +1 -0
  17. package/dist/{chunk-DWZYVYZS.js → chunk-3SZ4CU6G.js} +57 -15
  18. package/dist/chunk-3SZ4CU6G.js.map +1 -0
  19. package/dist/{chunk-TNR7AI4H.js → chunk-4SHWIGTM.js} +67 -67
  20. package/dist/chunk-4SHWIGTM.js.map +1 -0
  21. package/dist/{chunk-N4HFUAPN.js → chunk-4TKGR3XF.js} +2 -2
  22. package/dist/{chunk-3CHAPGUX.js → chunk-7RDNVR5I.js} +195 -194
  23. package/dist/chunk-7RDNVR5I.js.map +1 -0
  24. package/dist/{chunk-7OEFPYQJ.js → chunk-AVA245WL.js} +2 -2
  25. package/dist/{chunk-BFY4D4RH.js → chunk-BTTQZWYB.js} +2 -2
  26. package/dist/{chunk-I6HCIQCQ.js → chunk-EBHHMJZX.js} +2 -2
  27. package/dist/{chunk-7QDAGGOD.js → chunk-EJPQPU5J.js} +2 -2
  28. package/dist/{chunk-EUXF2GJY.js → chunk-ES2NYGS2.js} +2 -2
  29. package/dist/{chunk-X2F3AVGR.js → chunk-HXM35HRM.js} +2 -2
  30. package/dist/{chunk-MZQ5PCLA.js → chunk-HY6ILQ4T.js} +2 -2
  31. package/dist/{chunk-BUELCBBV.js → chunk-L73YON3H.js} +8 -8
  32. package/dist/{chunk-BUELCBBV.js.map → chunk-L73YON3H.js.map} +1 -1
  33. package/dist/{chunk-XVDASTUX.js → chunk-NIQEKVWY.js} +2 -2
  34. package/dist/{chunk-7K5SJJUI.js → chunk-OKT5RCXP.js} +2 -2
  35. package/dist/{chunk-HOXHUFZB.js → chunk-OS34M3FV.js} +2 -2
  36. package/dist/{chunk-C3VCIEZS.js → chunk-PJZ4ZHQS.js} +2 -2
  37. package/dist/{chunk-PN6XHTG5.js → chunk-RJ7DASOU.js} +4 -4
  38. package/dist/{chunk-PN6XHTG5.js.map → chunk-RJ7DASOU.js.map} +1 -1
  39. package/dist/{chunk-BOENB3IK.js → chunk-RS6BNRVF.js} +2 -2
  40. package/dist/{chunk-HQWMWSSE.js → chunk-S4JD5FEE.js} +2 -2
  41. package/dist/{chunk-KIXDJEGA.js → chunk-SH5Y7N4W.js} +2 -2
  42. package/dist/{chunk-2FLVU2QW.js → chunk-SQOBKSP3.js} +2 -2
  43. package/dist/{chunk-OAA7TNB6.js → chunk-TGN7K7VG.js} +2 -2
  44. package/dist/{chunk-7ADEEHZQ.js → chunk-URVWZ5FM.js} +2 -2
  45. package/dist/{chunk-BYD5B3P3.js → chunk-UVMCAZL7.js} +2 -2
  46. package/dist/{chunk-ECCPLASB.js → chunk-ZVXKCXTP.js} +2 -2
  47. package/dist/cli.js +1 -1
  48. package/dist/{client-UKLOVDZB.js → client-WFEXHCNH.js} +2 -2
  49. package/dist/config-PPYPER75.js +2 -0
  50. package/dist/config-mutator-QQDBVKWM.js +2 -0
  51. package/dist/{doctor-COAIIKLV.js → doctor-NRKGBTVD.js} +2 -2
  52. package/dist/index.d.ts +31 -15
  53. package/dist/index.js +1 -1
  54. package/dist/processes-MMAVAZAC.js +2 -0
  55. package/dist/{program-QKXRASTV.js → program-7YKH5WTC.js} +181 -181
  56. package/dist/program-7YKH5WTC.js.map +1 -0
  57. package/dist/{promote-B7FGHUFA.js → promote-PY73YR6C.js} +2 -2
  58. package/dist/{query-ZSVAGGSM.js → query-F3R5KXA7.js} +2 -2
  59. package/dist/{registry-4DWWYWDR.js → registry-YBMYCRQH.js} +2 -2
  60. package/dist/{request-planner-DSXXT75B.js → request-planner-CVNEV6NJ.js} +2 -2
  61. package/dist/{review-candidates-34S6R3DJ.js → review-candidates-5QTF5JRG.js} +2 -2
  62. package/dist/{routing-HWGNYIF3.js → routing-BJLETKHZ.js} +2 -2
  63. package/dist/schedule-tool-E2PRGDLW.js +2 -0
  64. package/dist/scheduler-53POJWDI.js +2 -0
  65. package/dist/{seat-OQZY3CW7.js → seat-OORCKWER.js} +2 -2
  66. package/dist/{select-BBTWL7FN.js → select-OGMDS37Y.js} +2 -2
  67. package/dist/{set-enabled-OC3YT4D4.js → set-enabled-IKSHTUUF.js} +2 -2
  68. package/dist/{surface-usage-O5L4KLGS.js → surface-usage-CH3SHO77.js} +2 -2
  69. package/dist/{tool-P4AHNZ5O.js → tool-6FVBXUTV.js} +2 -2
  70. package/dist/{tool-R36FDI6E.js → tool-CKIO3NBX.js} +2 -2
  71. package/dist/wiki-4CPXUAPW.js +2 -0
  72. package/package.json +1 -1
  73. package/dist/chunk-3CHAPGUX.js.map +0 -1
  74. package/dist/chunk-54VYZE7A.js.map +0 -1
  75. package/dist/chunk-DWZYVYZS.js.map +0 -1
  76. package/dist/chunk-TNR7AI4H.js.map +0 -1
  77. package/dist/config-WUDWHRT6.js +0 -2
  78. package/dist/config-mutator-UPLM7KGO.js +0 -2
  79. package/dist/processes-TXEWMXXJ.js +0 -2
  80. package/dist/program-QKXRASTV.js.map +0 -1
  81. package/dist/schedule-tool-JLIS2GQU.js +0 -2
  82. package/dist/scheduler-F6NRVZ7W.js +0 -2
  83. package/dist/wiki-5E4AGLKY.js +0 -2
  84. /package/dist/{App-BSGDYHRD.js.map → App-PBPYFVBE.js.map} +0 -0
  85. /package/dist/{ChatApp-K57DBCDT.js.map → ChatApp-QC7QAQEW.js.map} +0 -0
  86. /package/dist/{agent-6MTUM5DR.js.map → agent-BQEENICU.js.map} +0 -0
  87. /package/dist/{chunk-N4HFUAPN.js.map → chunk-4TKGR3XF.js.map} +0 -0
  88. /package/dist/{chunk-7OEFPYQJ.js.map → chunk-AVA245WL.js.map} +0 -0
  89. /package/dist/{chunk-BFY4D4RH.js.map → chunk-BTTQZWYB.js.map} +0 -0
  90. /package/dist/{chunk-I6HCIQCQ.js.map → chunk-EBHHMJZX.js.map} +0 -0
  91. /package/dist/{chunk-7QDAGGOD.js.map → chunk-EJPQPU5J.js.map} +0 -0
  92. /package/dist/{chunk-EUXF2GJY.js.map → chunk-ES2NYGS2.js.map} +0 -0
  93. /package/dist/{chunk-X2F3AVGR.js.map → chunk-HXM35HRM.js.map} +0 -0
  94. /package/dist/{chunk-MZQ5PCLA.js.map → chunk-HY6ILQ4T.js.map} +0 -0
  95. /package/dist/{chunk-XVDASTUX.js.map → chunk-NIQEKVWY.js.map} +0 -0
  96. /package/dist/{chunk-7K5SJJUI.js.map → chunk-OKT5RCXP.js.map} +0 -0
  97. /package/dist/{chunk-HOXHUFZB.js.map → chunk-OS34M3FV.js.map} +0 -0
  98. /package/dist/{chunk-C3VCIEZS.js.map → chunk-PJZ4ZHQS.js.map} +0 -0
  99. /package/dist/{chunk-BOENB3IK.js.map → chunk-RS6BNRVF.js.map} +0 -0
  100. /package/dist/{chunk-HQWMWSSE.js.map → chunk-S4JD5FEE.js.map} +0 -0
  101. /package/dist/{chunk-KIXDJEGA.js.map → chunk-SH5Y7N4W.js.map} +0 -0
  102. /package/dist/{chunk-2FLVU2QW.js.map → chunk-SQOBKSP3.js.map} +0 -0
  103. /package/dist/{chunk-OAA7TNB6.js.map → chunk-TGN7K7VG.js.map} +0 -0
  104. /package/dist/{chunk-7ADEEHZQ.js.map → chunk-URVWZ5FM.js.map} +0 -0
  105. /package/dist/{chunk-BYD5B3P3.js.map → chunk-UVMCAZL7.js.map} +0 -0
  106. /package/dist/{chunk-ECCPLASB.js.map → chunk-ZVXKCXTP.js.map} +0 -0
  107. /package/dist/{client-UKLOVDZB.js.map → client-WFEXHCNH.js.map} +0 -0
  108. /package/dist/{config-WUDWHRT6.js.map → config-PPYPER75.js.map} +0 -0
  109. /package/dist/{config-mutator-UPLM7KGO.js.map → config-mutator-QQDBVKWM.js.map} +0 -0
  110. /package/dist/{doctor-COAIIKLV.js.map → doctor-NRKGBTVD.js.map} +0 -0
  111. /package/dist/{processes-TXEWMXXJ.js.map → processes-MMAVAZAC.js.map} +0 -0
  112. /package/dist/{promote-B7FGHUFA.js.map → promote-PY73YR6C.js.map} +0 -0
  113. /package/dist/{query-ZSVAGGSM.js.map → query-F3R5KXA7.js.map} +0 -0
  114. /package/dist/{registry-4DWWYWDR.js.map → registry-YBMYCRQH.js.map} +0 -0
  115. /package/dist/{request-planner-DSXXT75B.js.map → request-planner-CVNEV6NJ.js.map} +0 -0
  116. /package/dist/{review-candidates-34S6R3DJ.js.map → review-candidates-5QTF5JRG.js.map} +0 -0
  117. /package/dist/{routing-HWGNYIF3.js.map → routing-BJLETKHZ.js.map} +0 -0
  118. /package/dist/{schedule-tool-JLIS2GQU.js.map → schedule-tool-E2PRGDLW.js.map} +0 -0
  119. /package/dist/{scheduler-F6NRVZ7W.js.map → scheduler-53POJWDI.js.map} +0 -0
  120. /package/dist/{seat-OQZY3CW7.js.map → seat-OORCKWER.js.map} +0 -0
  121. /package/dist/{select-BBTWL7FN.js.map → select-OGMDS37Y.js.map} +0 -0
  122. /package/dist/{set-enabled-OC3YT4D4.js.map → set-enabled-IKSHTUUF.js.map} +0 -0
  123. /package/dist/{surface-usage-O5L4KLGS.js.map → surface-usage-CH3SHO77.js.map} +0 -0
  124. /package/dist/{tool-P4AHNZ5O.js.map → tool-6FVBXUTV.js.map} +0 -0
  125. /package/dist/{tool-R36FDI6E.js.map → tool-CKIO3NBX.js.map} +0 -0
  126. /package/dist/{wiki-5E4AGLKY.js.map → wiki-4CPXUAPW.js.map} +0 -0
@@ -0,0 +1,152 @@
1
+ import AppKit
2
+
3
+ /// The character (#458): the orb grown into a small creature, in the
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.
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.
13
+ @MainActor
14
+ final class CharacterHost {
15
+ /// The body's width in points, and the room around it for its marks
16
+ /// and for the dots it leaves behind.
17
+ private static let diameter: CGFloat = 56
18
+ private static let size = NSSize(width: 440, height: 150)
19
+ /// The edge it hovers above, from the bottom of its window.
20
+ private static let ground: CGFloat = 8
21
+
22
+ private let window: NSPanel
23
+ private let view = CharacterView(frame: NSRect(origin: .zero, size: CharacterHost.size))
24
+ private var sim = CharacterSim(unit: Double(CharacterHost.diameter) / 100)
25
+ private var timer: Timer?
26
+ private var motionObserver: NSObjectProtocol?
27
+
28
+ private var shown = false
29
+ private var animated = true
30
+ private var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
31
+ private var activity = CharacterMath.Activity.idle
32
+ private var level = 0.0
33
+ /// The microphone's RMS level, read while listening. Set by the app.
34
+ var levelSource: (() -> Float)?
35
+ /// Where the pointer is, in screen coordinates.
36
+ var pointerSource: () -> NSPoint = { NSEvent.mouseLocation }
37
+
38
+ init() {
39
+ window = NSPanel(contentRect: NSRect(origin: .zero, size: Self.size),
40
+ styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
41
+ window.isFloatingPanel = true
42
+ window.level = .floating
43
+ window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
44
+ window.isOpaque = false
45
+ window.backgroundColor = .clear
46
+ window.hasShadow = false
47
+ window.hidesOnDeactivate = false
48
+ window.ignoresMouseEvents = true
49
+ window.contentView = view
50
+ view.unit = Self.diameter / 100
51
+ view.edge = CGPoint(x: Self.size.width / 2, y: Self.size.height - Self.ground)
52
+ motionObserver = NSWorkspace.shared.notificationCenter.addObserver(
53
+ forName: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification,
54
+ object: nil, queue: .main) { [weak self] _ in
55
+ MainActor.assumeIsolated {
56
+ guard let self else { return }
57
+ self.reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
58
+ self.run()
59
+ }
60
+ }
61
+ }
62
+
63
+ /// `voice.look` is "character", or not.
64
+ func setShown(_ on: Bool) {
65
+ guard shown != on else { return }
66
+ shown = on
67
+ if on { sim = CharacterSim(unit: Double(Self.diameter) / 100) }
68
+ run()
69
+ }
70
+
71
+ /// "Animated orb" in the menu.
72
+ func setAnimated(_ on: Bool) {
73
+ guard animated != on else { return }
74
+ animated = on
75
+ run()
76
+ }
77
+
78
+ /// Show what the pill shows, in `colors` or in shades of `tint`.
79
+ func show(_ activity: CharacterMath.Activity, tint: NSColor, colors: [NSColor]?) {
80
+ self.activity = activity
81
+ view.stops = CharacterDraw.stops(tint: tint, colors: colors)
82
+ if shown && timer == nil { tick() }
83
+ }
84
+
85
+ /// The frames run only while it is on screen and may move.
86
+ private func run() {
87
+ timer?.invalidate()
88
+ timer = nil
89
+ guard shown else { window.orderOut(nil); return }
90
+ tick()
91
+ 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() }
95
+ }
96
+ // Keeps moving while a menu is open or the pill is dragged.
97
+ if let timer { RunLoop.main.add(timer, forMode: .common) }
98
+ }
99
+
100
+ 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
109
+
110
+ let frame: CharacterSim.Frame
111
+ if timer == nil {
112
+ frame = CharacterSim.still(activity, home: home)
113
+ } else {
114
+ if activity == .listening, let read = levelSource {
115
+ level = OrbMath.smooth(level, toward: OrbMath.level(fromRMS: read()))
116
+ } else {
117
+ level = 0
118
+ }
119
+ let mouse = pointerSource()
120
+ let pointer = screen.frame.contains(mouse) ? (x: Double(mouse.x), y: Double(mouse.y - visible.minY)) : nil
121
+ frame = sim.step(to: ProcessInfo.processInfo.systemUptime,
122
+ CharacterSim.Input(activity: activity, level: level, pointer: pointer, home: home, range: range))
123
+ }
124
+ let origin = NSPoint(x: (CGFloat(frame.x) - Self.size.width / 2).rounded(), y: visible.minY - Self.ground)
125
+ if window.frame.origin != origin { window.setFrameOrigin(origin) }
126
+ view.origin = origin.x
127
+ view.shown = frame
128
+ view.needsDisplay = true
129
+ }
130
+ }
131
+
132
+ /// The character's drawing surface. Flipped, so the drawing's y grows
133
+ /// downwards as in the pose sheet it was ported from.
134
+ final class CharacterView: NSView {
135
+ var shown = CharacterSim.Frame()
136
+ var stops = CharacterDraw.stops(tint: Brand.accent, colors: nil)
137
+ var unit: CGFloat = 0.56
138
+ /// Under the character, in this view; and the view's left edge on screen.
139
+ var edge = CGPoint.zero
140
+ var origin: CGFloat = 0
141
+
142
+ override var isFlipped: Bool { true }
143
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
144
+
145
+ override func draw(_ dirtyRect: NSRect) {
146
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
147
+ // The body is drawn where the window is, which follows the frame
148
+ // to the nearest point; the rest of the way is drawn here.
149
+ let at = CGPoint(x: CGFloat(shown.x) - origin, y: edge.y)
150
+ CharacterDraw.draw(shown, in: ctx, edge: at, origin: origin, unit: unit, stops: stops)
151
+ }
152
+ }
@@ -0,0 +1,218 @@
1
+ import AppKit
2
+
3
+ /// One frame of the character, drawn in code like the orb: one drawing,
4
+ /// every palette. The context is flipped (y grows downwards) and scaled so
5
+ /// the body is 100 across, with the origin on the edge under the character.
6
+ enum CharacterDraw {
7
+ typealias Frame = CharacterSim.Frame
8
+
9
+ private static let ink = NSColor(srgbRed: 0.059, green: 0.133, blue: 0.2, alpha: 1)
10
+ private static let radius: CGFloat = 50
11
+
12
+ /// Five stops, deep to light: the agent's palette, or shades of `tint`
13
+ /// without one (an error, held notifications, an older daemon).
14
+ static func stops(tint: NSColor, colors: [NSColor]?) -> [NSColor] {
15
+ if let colors, colors.count == 5 { return colors }
16
+ let base = tint.usingColorSpace(.sRGB) ?? tint
17
+ return [base.blended(withFraction: 0.45, of: .black), base.blended(withFraction: 0.2, of: .black), base,
18
+ base.blended(withFraction: 0.35, of: .white), base.blended(withFraction: 0.7, of: .white)].map { $0 ?? base }
19
+ }
20
+
21
+ /// Draw `frame`. `edge` is the point under the character and `unit`
22
+ /// the points per drawing unit; the marks are placed from `origin`,
23
+ /// the screen position of the context's left edge.
24
+ static func draw(_ frame: Frame, in ctx: CGContext, edge: CGPoint, origin: CGFloat, unit: CGFloat, stops c: [NSColor]) {
25
+ let pose = frame.pose
26
+ let sx = CGFloat(pose.sx), sy = CGFloat(pose.sy), lift = CGFloat(pose.lift)
27
+ let turned = CGFloat(abs(frame.face)), way: CGFloat = frame.face >= 0 ? 1 : -1
28
+
29
+ // Trail dots and stars stay where they were left, on the screen.
30
+ for m in frame.dots + frame.stars {
31
+ ctx.saveGState()
32
+ ctx.translateBy(x: CGFloat(m.x) - origin, y: edge.y - CGFloat(m.y))
33
+ ctx.scaleBy(x: unit, y: unit)
34
+ ctx.setFillColor(c[m.shade].withAlphaComponent(CGFloat(m.alpha)).cgColor)
35
+ let r = CGFloat(m.r)
36
+ if m.turn == 0 {
37
+ ctx.fillEllipse(in: CGRect(x: -r, y: -r, width: 2 * r, height: 2 * r))
38
+ } else {
39
+ ctx.rotate(by: CGFloat(m.turn) * .pi / 180)
40
+ let star = CGMutablePath()
41
+ star.move(to: CGPoint(x: 0, y: -r))
42
+ for p in [CGPoint(x: r, y: 0), CGPoint(x: 0, y: r), CGPoint(x: -r, y: 0), CGPoint(x: 0, y: -r)] {
43
+ star.addQuadCurve(to: p, control: .zero)
44
+ }
45
+ ctx.addPath(star)
46
+ ctx.fillPath()
47
+ }
48
+ ctx.restoreGState()
49
+ }
50
+
51
+ ctx.saveGState()
52
+ ctx.translateBy(x: edge.x, y: edge.y)
53
+ ctx.scaleBy(x: unit, y: unit)
54
+
55
+ // Its shadow on the edge, smaller the higher it hovers.
56
+ let shadow = 34 * (1 - min(lift, 30) / 60)
57
+ ctx.setFillColor(ink.withAlphaComponent(0.13).cgColor)
58
+ ctx.fillEllipse(in: CGRect(x: -shadow, y: 0, width: 2 * shadow, height: 10))
59
+
60
+ let middle = -lift - radius * sy
61
+ marks(frame, in: ctx, middle: middle, stops: c)
62
+
63
+ ctx.translateBy(x: 0, y: -lift)
64
+ ctx.rotate(by: CGFloat(pose.tilt) * .pi / 180)
65
+ let cy = -radius * sy
66
+
67
+ // The body: a soft glow, the palette from light (top left) to
68
+ // deep, a rim so it reads on a pale wallpaper, and a highlight.
69
+ let space = CGColorSpace(name: CGColorSpace.sRGB)!
70
+ ctx.saveGState()
71
+ ctx.translateBy(x: 0, y: cy)
72
+ ctx.scaleBy(x: sx, y: sy)
73
+ let glow = CGGradient(colorsSpace: space, colors: [c[2].withAlphaComponent(0.34).cgColor, c[2].withAlphaComponent(0).cgColor] as CFArray,
74
+ locations: [0.6, 1])!
75
+ ctx.drawRadialGradient(glow, startCenter: .zero, startRadius: 0, endCenter: .zero, endRadius: radius + 20, options: [])
76
+ let body = CGRect(x: -radius, y: -radius, width: 2 * radius, height: 2 * radius)
77
+ ctx.saveGState()
78
+ ctx.addEllipse(in: body)
79
+ ctx.clip()
80
+ let fill = CGGradient(colorsSpace: space, colors: [c[4], c[3], c[2], c[1], c[0]].map(\.cgColor) as CFArray,
81
+ locations: [0, 0.28, 0.58, 0.84, 1])!
82
+ let light = CGPoint(x: -14, y: -20)
83
+ ctx.drawRadialGradient(fill, startCenter: light, startRadius: 0, endCenter: light, endRadius: 78,
84
+ options: [.drawsAfterEndLocation])
85
+ ctx.restoreGState()
86
+ ctx.setStrokeColor(c[0].withAlphaComponent(0.4).cgColor)
87
+ ctx.setLineWidth(1.5)
88
+ ctx.strokeEllipse(in: body.insetBy(dx: 0.75, dy: 0.75))
89
+ ctx.translateBy(x: -17, y: -24)
90
+ ctx.rotate(by: -24 * .pi / 180)
91
+ ctx.setFillColor(NSColor.white.withAlphaComponent(0.35).cgColor)
92
+ ctx.fillEllipse(in: CGRect(x: -13, y: -8, width: 26, height: 16))
93
+ ctx.restoreGState()
94
+
95
+ // Turned, both eyes move to that side and the far one narrows.
96
+ let ey = cy + 2 * sy
97
+ let gaze = CGPoint(x: CGFloat(pose.gx), y: CGFloat(pose.gy))
98
+ eye(in: ctx, at: CGPoint(x: (15 + 18 * turned) * sx * way, y: ey), pose, gaze: gaze, width: 1 - 0.38 * turned)
99
+ eye(in: ctx, at: CGPoint(x: (-15 + 26 * turned) * sx * way, y: ey), pose, gaze: gaze, width: 1)
100
+ ctx.restoreGState()
101
+ }
102
+
103
+ /// One eye. As it closes, the eyeball fades into the curve of the shut
104
+ /// eye, so one look turns into another without a jump.
105
+ private static func eye(in ctx: CGContext, at p: CGPoint, _ pose: CharacterMath.Pose, gaze: CGPoint, width w: CGFloat) {
106
+ let open = CGFloat(pose.open), s = CGFloat(pose.size), lid = CGFloat(pose.lid), curve = CGFloat(pose.curve)
107
+ let k = min(max(open, 0) / 0.3, 1)
108
+ if k > 0 {
109
+ let c = CGPoint(x: p.x + gaze.x, y: p.y + gaze.y)
110
+ let rx = 6.5 * s * w, ry = 9.5 * s * max(open, 0.04)
111
+ let top = c.y - ry + 2 * ry * lid // what shows below the eyelid
112
+ ctx.saveGState()
113
+ ctx.clip(to: CGRect(x: c.x - rx - 1, y: top, width: 2 * rx + 2, height: 2 * ry + 1))
114
+ ctx.setFillColor(ink.withAlphaComponent(k).cgColor)
115
+ ctx.fillEllipse(in: CGRect(x: c.x - rx, y: c.y - ry, width: 2 * rx, height: 2 * ry))
116
+ ctx.restoreGState()
117
+ if lid > 0.02 {
118
+ let half = rx * max(1 - pow((top - c.y) / ry, 2), 0).squareRoot() + 1.5 * w
119
+ line(in: ctx, [CGPoint(x: c.x - half, y: top), CGPoint(x: c.x + half, y: top)],
120
+ ink.withAlphaComponent(k * min(lid * 4, 1)), width: 2.6)
121
+ }
122
+ let shine = k * max(1 - lid * 2.5, 0) * min(max(open * 2 - 0.6, 0), 1)
123
+ ctx.setFillColor(NSColor.white.withAlphaComponent(shine).cgColor)
124
+ let r = 2.2 * s * w
125
+ ctx.fillEllipse(in: CGRect(x: c.x, y: c.y - 3.4 * s * open - r, width: 2 * r, height: 2 * r))
126
+ }
127
+ if k < 1 {
128
+ let shut = CGMutablePath()
129
+ shut.move(to: CGPoint(x: p.x - 7 * w, y: p.y + 1.5 + 1.5 * curve))
130
+ shut.addQuadCurve(to: CGPoint(x: p.x + 7 * w, y: p.y + 1.5 + 1.5 * curve),
131
+ control: CGPoint(x: p.x, y: p.y - 0.5 - 7.5 * curve))
132
+ ctx.addPath(shut)
133
+ ctx.setStrokeColor(ink.withAlphaComponent(1 - k).cgColor)
134
+ ctx.setLineWidth(3.2)
135
+ ctx.setLineCap(.round)
136
+ ctx.strokePath()
137
+ }
138
+ }
139
+
140
+ /// The marks beside it, each with a loop of its own: voice rings, the
141
+ /// arcs of its own voice, thinking dots, sleep letters, call rings and
142
+ /// the question. `middle` is the height of the middle of its body.
143
+ private static func marks(_ frame: Frame, in ctx: CGContext, middle: CGFloat, stops c: [NSColor]) {
144
+ let pose = frame.pose, t = frame.t
145
+ func beat(_ rate: Double, _ i: Int) -> CGFloat { CGFloat(0.5 + 0.5 * sin(2 * .pi * (rate * t - Double(i) * 0.22))) }
146
+ /// Arcs opening away from the body on `side`, swelling one after the other.
147
+ func arcs(_ n: Int, side: CGFloat, x: CGFloat, y: CGFloat, strength: CGFloat, rate: Double, _ color: NSColor) {
148
+ guard strength > 0.01 else { return }
149
+ for i in 0..<n {
150
+ let b = beat(rate, i), r = 9 + CGFloat(i) * 9 + 2 * b
151
+ let arc = CGMutablePath()
152
+ arc.addArc(center: CGPoint(x: x, y: y), radius: r, startAngle: -.pi / 2, endAngle: .pi / 2, clockwise: side < 0)
153
+ ctx.addPath(arc)
154
+ ctx.setStrokeColor(color.withAlphaComponent(strength * (0.85 - CGFloat(i) * 0.25) * (0.35 + 0.65 * b)).cgColor)
155
+ ctx.setLineWidth(3)
156
+ ctx.setLineCap(.round)
157
+ ctx.strokePath()
158
+ }
159
+ }
160
+ // Listening: the rings follow your voice.
161
+ arcs(3, side: -1, x: -78, y: middle, strength: CGFloat(pose.hear * (0.45 + 0.55 * frame.level)), rate: 1.7, c[2])
162
+ // Speaking: its own voice goes out the other way.
163
+ arcs(3, side: 1, x: 74, y: middle - 8, strength: CGFloat(frame.voice), rate: 2.1, c[2])
164
+ // An agent is calling: it rings on both sides.
165
+ arcs(2, side: 1, x: 76, y: middle - 20, strength: CGFloat(pose.ring), rate: 2.4, c[1])
166
+ arcs(2, side: -1, x: -76, y: middle - 20, strength: CGFloat(pose.ring), rate: 2.4, c[1])
167
+
168
+ // Working: three dots that hop in turn.
169
+ let think = CGFloat(pose.think)
170
+ if think > 0.01 {
171
+ for i in 0..<3 {
172
+ let hop = CGFloat(max(sin(2 * .pi * (1.1 * t - Double(i) * 0.2)), 0)), r = (4 + CGFloat(i) * 1.6) * think
173
+ ctx.setFillColor(c[1].withAlphaComponent(think * (0.45 + CGFloat(i) * 0.25)).cgColor)
174
+ ctx.fillEllipse(in: CGRect(x: 62 + CGFloat(i) * 17 - r, y: middle - 8 - CGFloat(i) * 9 - 6 * hop - r, width: 2 * r, height: 2 * r))
175
+ }
176
+ }
177
+
178
+ // Dozing: each z rises, grows and fades.
179
+ if pose.sleep > 0.01 {
180
+ let life = 2.4, every = 1.2
181
+ for j in max(Int(((t - life) / every).rounded(.down)) + 1, 0)...Int((t / every).rounded(.down)) {
182
+ let a = (t - Double(j) * every) / life
183
+ guard a >= 0, a < 1 else { continue }
184
+ text("z", in: ctx, at: CGPoint(x: 50 + 24 * a + 5 * sin(5 * a), y: Double(middle) - 16 - 40 * a),
185
+ size: 13 + 9 * a, c[1].withAlphaComponent(CGFloat(pose.sleep * 0.9 * sin(.pi * a))))
186
+ }
187
+ }
188
+
189
+ // Needs your answer: a question that grows out of it and waits.
190
+ let ask = CGFloat(pose.ask)
191
+ if ask > 0.01 {
192
+ let r = 15 * ask, bob = CGFloat(2 * sin(2 * .pi * t / 1.8))
193
+ let at = CGPoint(x: 64, y: middle - 44 - bob)
194
+ let bubble = CGRect(x: at.x - r, y: at.y - r, width: 2 * r, height: 2 * r)
195
+ ctx.setFillColor(NSColor.white.withAlphaComponent(ask).cgColor)
196
+ ctx.fillEllipse(in: bubble)
197
+ ctx.setStrokeColor(c[0].withAlphaComponent(ask).cgColor)
198
+ ctx.setLineWidth(2.5)
199
+ ctx.strokeEllipse(in: bubble)
200
+ text("?", in: ctx, at: CGPoint(x: at.x - 5.5 * ask, y: at.y - 12 * ask), size: Double(20 * ask), c[0].withAlphaComponent(ask))
201
+ }
202
+ }
203
+
204
+ private static func line(in ctx: CGContext, _ points: [CGPoint], _ color: NSColor, width: CGFloat) {
205
+ ctx.setStrokeColor(color.cgColor)
206
+ ctx.setLineWidth(width)
207
+ ctx.setLineCap(.round)
208
+ ctx.strokeLineSegments(between: points)
209
+ }
210
+
211
+ /// Bold text with its top-left corner at `at`.
212
+ private static func text(_ s: String, in ctx: CGContext, at: CGPoint, size: Double, _ color: NSColor) {
213
+ NSGraphicsContext.saveGraphicsState()
214
+ NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true)
215
+ (s as NSString).draw(at: at, withAttributes: [.font: NSFont.systemFont(ofSize: size, weight: .bold), .foregroundColor: color])
216
+ NSGraphicsContext.restoreGraphicsState()
217
+ }
218
+ }
@@ -0,0 +1,145 @@
1
+ import Foundation
2
+
3
+ /// The numbers behind the character (#458): the orb grown into a small
4
+ /// creature. Kept free of AppKit so the tests can check them without a
5
+ /// window.
6
+ ///
7
+ /// Nothing snaps. A state is a small set of numbers (stretch, lean,
8
+ /// height, gaze, eyes, and how strong each mark is), so going from any
9
+ /// state to any other is a blend of those numbers. Lengths are in the
10
+ /// drawing's own units: the body is 100 across.
11
+ enum CharacterMath {
12
+ /// What the character shows: the nine states of the pose sheet.
13
+ enum Mood: String, CaseIterable {
14
+ case idle, notices, listening, working, speaking, understood, dozing, calling, asking
15
+ }
16
+
17
+ /// What the assistant is doing: the pill's states without their words.
18
+ enum Activity { case idle, listening, thinking, speaking, ringing, waiting }
19
+
20
+ /// The state shown for what the assistant is doing. Noticing, the nod
21
+ /// and dozing are not activities: the character passes through them
22
+ /// (`passing`) or settles into them with time.
23
+ static func mood(for activity: Activity) -> Mood {
24
+ switch activity {
25
+ case .idle: return .idle
26
+ case .listening: return .listening
27
+ case .thinking: return .working
28
+ case .speaking: return .speaking
29
+ case .ringing: return .calling
30
+ case .waiting: return .asking
31
+ }
32
+ }
33
+
34
+ /// The state it goes through on the way from `from` to `to`, if any:
35
+ /// it notices you before it listens and when it wakes, and nods once
36
+ /// it has heard you.
37
+ static func passing(from: Mood, to: Mood) -> Mood? {
38
+ if from == .dozing { return .notices }
39
+ if from == .idle && to == .listening { return .notices }
40
+ if from == .listening && to == .working { return .understood }
41
+ return nil
42
+ }
43
+
44
+ /// Seconds a passing state is held before moving on.
45
+ static func hold(_ mood: Mood) -> Double { mood == .understood ? 0.9 : 0.45 }
46
+
47
+ /// Seconds to get into a state. It settles into sleep slowly.
48
+ static func duration(into mood: Mood) -> Double {
49
+ switch mood {
50
+ case .dozing: return 1.8
51
+ case .notices, .understood: return 0.3
52
+ default: return 0.5
53
+ }
54
+ }
55
+
56
+ struct Pose: Equatable {
57
+ // Body: stretch, lean in degrees, height above the edge.
58
+ var sx = 1.0, sy = 1.0, tilt = 0.0, lift = 14.0
59
+ // Eyes: gaze, how open (1 open, 0 shut), size, eyelid, and the
60
+ // curve of the shut eye (1 a smile, -1 asleep).
61
+ var gx = 0.0, gy = 0.0, open = 1.0, size = 1.0, lid = 0.0, curve = 0.0
62
+ // Marks, 0…1: voice rings, thinking dots, speech arcs, sleep
63
+ // letters, call rings, the question.
64
+ var hear = 0.0, think = 0.0, speak = 0.0, sleep = 0.0, ring = 0.0, ask = 0.0
65
+ }
66
+
67
+ static func pose(_ mood: Mood) -> Pose {
68
+ switch mood {
69
+ case .idle: return Pose()
70
+ case .notices: return Pose(sx: 0.94, sy: 1.08, lift: 18, gy: -3, size: 1.22)
71
+ case .listening: return Pose(tilt: -9, gx: -2, gy: -1, hear: 1)
72
+ case .working: return Pose(sx: 1.03, sy: 0.96, gx: -4, gy: 5, lid: 0.5, think: 1)
73
+ case .speaking: return Pose(sx: 0.97, sy: 1.04, lift: 16, gy: -1, speak: 1)
74
+ case .understood: return Pose(sx: 1.07, sy: 0.9, lift: 8, open: 0, curve: 1)
75
+ case .dozing: return Pose(sx: 1.14, sy: 0.8, lift: 3, open: 0, curve: -1, sleep: 1)
76
+ case .calling: return Pose(sx: 0.95, sy: 1.06, lift: 20, size: 1.22, ring: 1)
77
+ case .asking: return Pose(tilt: 11, gx: 3, gy: -3, ask: 1)
78
+ }
79
+ }
80
+
81
+ /// `a` towards `b`: the eyes, the body and the marks each by their own
82
+ /// amount, so the eyes can lead and the body follow.
83
+ static func blend(_ a: Pose, _ b: Pose, eyes: Double, body: Double, marks: Double) -> Pose {
84
+ func mix(_ x: Double, _ y: Double, _ p: Double) -> Double { x + (y - x) * p }
85
+ return Pose(sx: mix(a.sx, b.sx, body), sy: mix(a.sy, b.sy, body),
86
+ tilt: mix(a.tilt, b.tilt, body), lift: mix(a.lift, b.lift, body),
87
+ gx: mix(a.gx, b.gx, eyes), gy: mix(a.gy, b.gy, eyes),
88
+ open: mix(a.open, b.open, eyes), size: mix(a.size, b.size, eyes),
89
+ lid: mix(a.lid, b.lid, eyes), curve: mix(a.curve, b.curve, eyes),
90
+ hear: mix(a.hear, b.hear, marks), think: mix(a.think, b.think, marks),
91
+ speak: mix(a.speak, b.speak, marks), sleep: mix(a.sleep, b.sleep, marks),
92
+ ring: mix(a.ring, b.ring, marks), ask: mix(a.ask, b.ask, marks))
93
+ }
94
+
95
+ /// One change of state. The eyes go first and are there by half time;
96
+ /// the body follows a moment later, with weight.
97
+ struct Transition {
98
+ var from: Pose
99
+ var to: Pose
100
+ var start: Double
101
+ var duration: Double
102
+
103
+ func pose(at t: Double) -> Pose {
104
+ blend(from, to,
105
+ eyes: ease(span(t, start, start + duration * 0.5)),
106
+ body: swing(span(t, start + duration * 0.15, start + duration * 1.25)),
107
+ marks: ease(span(t, start, start + duration)))
108
+ }
109
+ }
110
+
111
+ /// Slow start, slow stop.
112
+ static func ease(_ p: Double) -> Double {
113
+ let p = min(max(p, 0), 1)
114
+ return p * p * (3 - 2 * p)
115
+ }
116
+
117
+ /// How far `t` is between `a` and `b`, 0…1.
118
+ static func span(_ t: Double, _ a: Double, _ b: Double) -> Double {
119
+ min(max((t - a) / (b - a), 0), 1)
120
+ }
121
+
122
+ /// Like `ease`, with weight: a small wind-up before it goes and a
123
+ /// small overshoot before it settles.
124
+ static func swing(_ p: Double, back: Double = 1) -> Double {
125
+ let p = min(max(p, 0), 1), c = back * 1.525
126
+ if p < 0.5 { return pow(2 * p, 2) * ((c + 1) * 2 * p - c) / 2 }
127
+ return (pow(2 * p - 2, 2) * ((c + 1) * (2 * p - 2) + c) + 2) / 2
128
+ }
129
+
130
+ /// The same "random" number 0…1 for the same `n`, so the marks are
131
+ /// the same on every run and the tests can count on them.
132
+ static func rnd(_ n: Int, _ salt: Int = 0) -> Double {
133
+ let v = sin(Double(n) * 12.9898 + Double(salt) * 78.233) * 43758.5453
134
+ return v - v.rounded(.down)
135
+ }
136
+
137
+ /// Where it goes to get out of the pointer's way: `clear` to the side
138
+ /// it is already on, or to the other side when the screen ends there.
139
+ static func aside(x: Double, pointer: Double, clear: Double, range: ClosedRange<Double>) -> Double {
140
+ let side: Double = x >= pointer ? 1 : -1
141
+ let there = pointer + side * clear
142
+ if range.contains(there) { return there }
143
+ return min(max(pointer - side * clear, range.lowerBound), range.upperBound)
144
+ }
145
+ }