agentix-cli 0.90.0 → 0.91.1
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/Character.swift +152 -0
- package/apps/mac-voice/Sources/AgentXVoice/CharacterDraw.swift +218 -0
- package/apps/mac-voice/Sources/AgentXVoice/CharacterMath.swift +145 -0
- package/apps/mac-voice/Sources/AgentXVoice/CharacterSim.swift +241 -0
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +35 -3
- package/apps/mac-voice/Sources/AgentXVoice/SettingsLook.swift +19 -0
- package/apps/mac-voice/Sources/AgentXVoice/SettingsWindow.swift +4 -0
- package/apps/mac-voice/Sources/AgentXVoice/VoiceSettings.swift +7 -2
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +11 -0
- package/dist/{App-LC34QQPN.js → App-PBPYFVBE.js} +2 -2
- package/dist/{ChatApp-FB72AK7M.js → ChatApp-QC7QAQEW.js} +2 -2
- package/dist/{agent-OZ4XFAD4.js → agent-BQEENICU.js} +2 -2
- package/dist/{board-dashboard-YBWSJ7J2.js → board-dashboard-ZLDVYPZ4.js} +4 -4
- package/dist/{chunk-XSVGD64O.js → chunk-24P6UR3L.js} +12 -12
- package/dist/chunk-24P6UR3L.js.map +1 -0
- package/dist/{chunk-RLXEF2X6.js → chunk-4SHWIGTM.js} +5 -5
- package/dist/{chunk-A5DFX47N.js → chunk-4TKGR3XF.js} +2 -2
- package/dist/{chunk-UWH7NTGV.js → chunk-AVA245WL.js} +2 -2
- package/dist/{chunk-ISIJAZGO.js → chunk-EBHHMJZX.js} +2 -2
- package/dist/{chunk-3SEFVZA7.js → chunk-EJPQPU5J.js} +2 -2
- package/dist/{chunk-QYFF2WJ2.js → chunk-ES2NYGS2.js} +2 -2
- package/dist/{chunk-N2OMSKIQ.js → chunk-HXM35HRM.js} +2 -2
- package/dist/{chunk-LCWE2GHC.js → chunk-HY6ILQ4T.js} +2 -2
- package/dist/{chunk-MU4S6LQA.js → chunk-L73YON3H.js} +2 -2
- package/dist/{chunk-64UK5VG4.js → chunk-NIQEKVWY.js} +2 -2
- package/dist/{chunk-LS6KXRXY.js → chunk-OKT5RCXP.js} +2 -2
- package/dist/{chunk-BEJCSZOQ.js → chunk-OS34M3FV.js} +2 -2
- package/dist/{chunk-JF5UGNMG.js → chunk-PJZ4ZHQS.js} +2 -2
- package/dist/{chunk-TA2BYYNZ.js → chunk-RS6BNRVF.js} +2 -2
- package/dist/{chunk-3GN255MT.js → chunk-S4JD5FEE.js} +2 -2
- package/dist/{chunk-MBVRADNG.js → chunk-SH5Y7N4W.js} +2 -2
- package/dist/{chunk-JLCYQZRT.js → chunk-SQOBKSP3.js} +2 -2
- package/dist/{chunk-TS7JDPOY.js → chunk-URVWZ5FM.js} +2 -2
- package/dist/{chunk-JVQXD35E.js → chunk-VCO46KQZ.js} +172 -172
- package/dist/{chunk-JVQXD35E.js.map → chunk-VCO46KQZ.js.map} +1 -1
- package/dist/{chunk-26UFDYRZ.js → chunk-ZVXKCXTP.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/{client-IPZRCNE7.js → client-WFEXHCNH.js} +2 -2
- package/dist/config-PPYPER75.js +2 -0
- package/dist/config-mutator-QQDBVKWM.js +2 -0
- package/dist/{doctor-BTHIQN7F.js → doctor-NRKGBTVD.js} +2 -2
- package/dist/index.d.ts +17 -2
- package/dist/index.js +1 -1
- package/dist/{program-PURSFZBM.js → program-TIKUIL2G.js} +165 -165
- package/dist/program-TIKUIL2G.js.map +1 -0
- package/dist/{promote-RDXNGZKX.js → promote-PY73YR6C.js} +2 -2
- package/dist/{query-OGMNU45Y.js → query-F3R5KXA7.js} +2 -2
- package/dist/{registry-LKIOKDIG.js → registry-YBMYCRQH.js} +2 -2
- package/dist/{request-planner-EP6QJAK5.js → request-planner-CVNEV6NJ.js} +2 -2
- package/dist/{review-candidates-BJPY54JI.js → review-candidates-5QTF5JRG.js} +2 -2
- package/dist/{routing-PZNFU3OV.js → routing-BJLETKHZ.js} +2 -2
- package/dist/schedule-tool-E2PRGDLW.js +2 -0
- package/dist/scheduler-53POJWDI.js +2 -0
- package/dist/{seat-4OONQEDH.js → seat-OORCKWER.js} +2 -2
- package/dist/{select-BR3NBXW7.js → select-OGMDS37Y.js} +2 -2
- package/dist/{set-enabled-Z3NKON2M.js → set-enabled-IKSHTUUF.js} +2 -2
- package/dist/{tool-SV55IZS3.js → tool-6FVBXUTV.js} +2 -2
- package/dist/{tool-DWMZ5OWI.js → tool-CKIO3NBX.js} +2 -2
- package/dist/wiki-4CPXUAPW.js +2 -0
- package/package.json +2 -2
- package/dist/chunk-XSVGD64O.js.map +0 -1
- package/dist/config-HZTKLB6A.js +0 -2
- package/dist/config-mutator-6S4X6WMU.js +0 -2
- package/dist/program-PURSFZBM.js.map +0 -1
- package/dist/schedule-tool-C3CE6GJG.js +0 -2
- package/dist/scheduler-PJMGEJMR.js +0 -2
- package/dist/wiki-EWEJOKP7.js +0 -2
- /package/dist/{App-LC34QQPN.js.map → App-PBPYFVBE.js.map} +0 -0
- /package/dist/{ChatApp-FB72AK7M.js.map → ChatApp-QC7QAQEW.js.map} +0 -0
- /package/dist/{agent-OZ4XFAD4.js.map → agent-BQEENICU.js.map} +0 -0
- /package/dist/{board-dashboard-YBWSJ7J2.js.map → board-dashboard-ZLDVYPZ4.js.map} +0 -0
- /package/dist/{chunk-RLXEF2X6.js.map → chunk-4SHWIGTM.js.map} +0 -0
- /package/dist/{chunk-A5DFX47N.js.map → chunk-4TKGR3XF.js.map} +0 -0
- /package/dist/{chunk-UWH7NTGV.js.map → chunk-AVA245WL.js.map} +0 -0
- /package/dist/{chunk-ISIJAZGO.js.map → chunk-EBHHMJZX.js.map} +0 -0
- /package/dist/{chunk-3SEFVZA7.js.map → chunk-EJPQPU5J.js.map} +0 -0
- /package/dist/{chunk-QYFF2WJ2.js.map → chunk-ES2NYGS2.js.map} +0 -0
- /package/dist/{chunk-N2OMSKIQ.js.map → chunk-HXM35HRM.js.map} +0 -0
- /package/dist/{chunk-LCWE2GHC.js.map → chunk-HY6ILQ4T.js.map} +0 -0
- /package/dist/{chunk-MU4S6LQA.js.map → chunk-L73YON3H.js.map} +0 -0
- /package/dist/{chunk-64UK5VG4.js.map → chunk-NIQEKVWY.js.map} +0 -0
- /package/dist/{chunk-LS6KXRXY.js.map → chunk-OKT5RCXP.js.map} +0 -0
- /package/dist/{chunk-BEJCSZOQ.js.map → chunk-OS34M3FV.js.map} +0 -0
- /package/dist/{chunk-JF5UGNMG.js.map → chunk-PJZ4ZHQS.js.map} +0 -0
- /package/dist/{chunk-TA2BYYNZ.js.map → chunk-RS6BNRVF.js.map} +0 -0
- /package/dist/{chunk-3GN255MT.js.map → chunk-S4JD5FEE.js.map} +0 -0
- /package/dist/{chunk-MBVRADNG.js.map → chunk-SH5Y7N4W.js.map} +0 -0
- /package/dist/{chunk-JLCYQZRT.js.map → chunk-SQOBKSP3.js.map} +0 -0
- /package/dist/{chunk-TS7JDPOY.js.map → chunk-URVWZ5FM.js.map} +0 -0
- /package/dist/{chunk-26UFDYRZ.js.map → chunk-ZVXKCXTP.js.map} +0 -0
- /package/dist/{client-IPZRCNE7.js.map → client-WFEXHCNH.js.map} +0 -0
- /package/dist/{config-HZTKLB6A.js.map → config-PPYPER75.js.map} +0 -0
- /package/dist/{config-mutator-6S4X6WMU.js.map → config-mutator-QQDBVKWM.js.map} +0 -0
- /package/dist/{doctor-BTHIQN7F.js.map → doctor-NRKGBTVD.js.map} +0 -0
- /package/dist/{promote-RDXNGZKX.js.map → promote-PY73YR6C.js.map} +0 -0
- /package/dist/{query-OGMNU45Y.js.map → query-F3R5KXA7.js.map} +0 -0
- /package/dist/{registry-LKIOKDIG.js.map → registry-YBMYCRQH.js.map} +0 -0
- /package/dist/{request-planner-EP6QJAK5.js.map → request-planner-CVNEV6NJ.js.map} +0 -0
- /package/dist/{review-candidates-BJPY54JI.js.map → review-candidates-5QTF5JRG.js.map} +0 -0
- /package/dist/{routing-PZNFU3OV.js.map → routing-BJLETKHZ.js.map} +0 -0
- /package/dist/{schedule-tool-C3CE6GJG.js.map → schedule-tool-E2PRGDLW.js.map} +0 -0
- /package/dist/{scheduler-PJMGEJMR.js.map → scheduler-53POJWDI.js.map} +0 -0
- /package/dist/{seat-4OONQEDH.js.map → seat-OORCKWER.js.map} +0 -0
- /package/dist/{select-BR3NBXW7.js.map → select-OGMDS37Y.js.map} +0 -0
- /package/dist/{set-enabled-Z3NKON2M.js.map → set-enabled-IKSHTUUF.js.map} +0 -0
- /package/dist/{tool-SV55IZS3.js.map → tool-6FVBXUTV.js.map} +0 -0
- /package/dist/{tool-DWMZ5OWI.js.map → tool-CKIO3NBX.js.map} +0 -0
- /package/dist/{wiki-EWEJOKP7.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
|
+
}
|