Skip to main content

agent/avatar/
mod.rs

1//! The blob avatar: one silhouette, two eyes and a little life.
2//!
3//! The shape is a radial profile rather than a pick from a roster, so a preset,
4//! a name and [`Shape::random`] are the same kind of value and there is no
5//! vocabulary to outgrow. Colour comes from the theme.
6//!
7//! [`mascot`] draws the same face as cells — one identity at two resolutions,
8//! since a rail and a chat header showing one name should show one being.
9//!
10//! ```ignore
11//! div().w(px(48.)).h(px(48.)).child(agent::avatar(Face::from("Sara").pose(t)))
12//! ```
13
14mod eyes;
15mod motion;
16mod pixels;
17mod shape;
18
19pub use eyes::{Eye, Eyes};
20pub use motion::{Beat, Motion};
21pub use pixels::{GRID, mascot};
22pub use shape::{Lobe, SAMPLES, Shape, seed};
23
24use gpui::{
25    AnyElement, Bounds, Hsla, IntoElement, PathBuilder, Pixels, Point, Styled, Window, canvas,
26    hsla, point, px,
27};
28use theme::{Theme, flatten, legible_on};
29
30/// The share of the box the body fills at rest.
31const FILL: f32 = 0.4;
32
33/// A face: what to draw, and what to draw it in.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct Face {
36    pub shape: Shape,
37    pub eyes: Eyes,
38    pub motion: Motion,
39    /// `None` follows `theme.accent`.
40    pub color: Option<Hsla>,
41}
42
43impl Face {
44    pub fn new(shape: Shape) -> Self {
45        Self {
46            shape,
47            eyes: Eyes::default(),
48            motion: Motion::default(),
49            color: None,
50        }
51    }
52
53    pub fn eyes(mut self, eyes: Eyes) -> Self {
54        self.eyes = eyes;
55        self
56    }
57
58    pub fn motion(mut self, motion: Motion) -> Self {
59        self.motion = motion;
60        self
61    }
62
63    pub fn color(mut self, color: Hsla) -> Self {
64        self.color = Some(color);
65        self
66    }
67}
68
69impl From<u64> for Face {
70    fn from(seed: u64) -> Self {
71        Self::new(Shape::from(seed)).eyes(Eyes::from(seed))
72    }
73}
74
75/// The silhouette and eyes, both from [`seed`]`(name)`.
76impl From<&str> for Face {
77    fn from(name: &str) -> Self {
78        Self::from(seed(name))
79    }
80}
81
82impl Face {
83    /// The face at `t`, motion already spent — everything the painter needs and
84    /// the only form two faces can meet in.
85    ///
86    /// `t` is the caller's clock, in seconds. `pose(0.)` is a face that never
87    /// moves.
88    pub fn pose(&self, t: f32) -> Pose {
89        let beat = self.motion.beat(t);
90        let shape = self.motion.shape(self.shape, t);
91        let breath = |(x, y): (f32, f32)| (x * beat.scale, y * beat.scale);
92        Pose {
93            outline: shape.outline().map(breath),
94            eyes: self.eyes.place(&shape, self.motion.reach()).map(|e| {
95                let (cx, cy) = breath((e.cx + beat.gaze.0, e.cy + beat.gaze.1));
96                Eye {
97                    cx,
98                    cy,
99                    rx: e.rx * beat.scale,
100                    ry: e.ry * beat.scale * (1.0 - beat.lid * 0.92),
101                    ..e
102                }
103            }),
104            color: self.color,
105        }
106    }
107}
108
109/// One face at one instant. A [`Shape`]'s harmonics are not interpolable — a
110/// count of lobes has no half — so blending happens here, where the outline is
111/// already sampled.
112#[derive(Clone, Copy, Debug, PartialEq)]
113pub struct Pose {
114    pub outline: [(f32, f32); SAMPLES],
115    pub eyes: [Eye; 2],
116    pub color: Option<Hsla>,
117}
118
119impl Pose {
120    /// Point for point, which is only sound because every outline is sampled at
121    /// the same angles — the property the whole representation is chosen for.
122    pub fn lerp(a: &Self, b: &Self, k: f32) -> Self {
123        let k = k.clamp(0.0, 1.0);
124        let f = |x: f32, y: f32| x + (y - x) * k;
125        Self {
126            outline: std::array::from_fn(|i| {
127                (
128                    f(a.outline[i].0, b.outline[i].0),
129                    f(a.outline[i].1, b.outline[i].1),
130                )
131            }),
132            eyes: std::array::from_fn(|i| Eye {
133                cx: f(a.eyes[i].cx, b.eyes[i].cx),
134                cy: f(a.eyes[i].cy, b.eyes[i].cy),
135                rx: f(a.eyes[i].rx, b.eyes[i].rx),
136                ry: f(a.eyes[i].ry, b.eyes[i].ry),
137                rot: f(a.eyes[i].rot, b.eyes[i].rot),
138                n: f(a.eyes[i].n, b.eyes[i].n),
139            }),
140            // Through sRGB rather than around the hue wheel, which would sweep
141            // a whole rainbow between two palette entries.
142            color: match (a.color, b.color) {
143                (Some(x), Some(y)) => Some(flatten(hsla(y.h, y.s, y.l, k), x)),
144                (x, y) => {
145                    if k < 0.5 {
146                        x
147                    } else {
148                        y
149                    }
150                }
151            },
152        }
153    }
154}
155
156/// The element, filling its layout bounds. The eyes are `theme.bg` or
157/// `theme.text`, whichever reads on the body.
158pub fn avatar(pose: Pose) -> AnyElement {
159    canvas(
160        move |_: Bounds<Pixels>, _, _| (),
161        move |bounds, (), window, cx| {
162            let theme = Theme::of(cx);
163            let head = pose.color.unwrap_or(theme.accent);
164            // Whichever end of the theme reads as a hole in this body.
165            let text = flatten(theme.text, head);
166            let ink = if legible_on(head, theme.bg, text) == theme.bg {
167                theme.bg
168            } else {
169                theme.text
170            };
171            paint(window, bounds, &pose, head, ink);
172        },
173    )
174    .size_full()
175    .into_any_element()
176}
177
178fn paint(window: &mut Window, bounds: Bounds<Pixels>, pose: &Pose, head: Hsla, ink: Hsla) {
179    let span = bounds.size.width.min(bounds.size.height).to_f64() as f32;
180    let unit = span * FILL;
181    let mid = bounds.center();
182    let map = |x: f32, y: f32| point(mid.x + px(x * unit), mid.y + px(y * unit));
183
184    window.paint_layer(bounds, |window| {
185        fill(
186            window,
187            map(pose.outline[0].0, pose.outline[0].1),
188            spline(&pose.outline, &map),
189            head,
190        );
191        for eye in &pose.eyes {
192            let (start, segs) = superellipse(eye, &map);
193            fill(window, start, segs, ink);
194        }
195    });
196}
197
198type Cubic = (Point<Pixels>, Point<Pixels>, Point<Pixels>);
199
200fn fill(window: &mut Window, start: Point<Pixels>, segs: Vec<Cubic>, color: Hsla) {
201    let mut b = PathBuilder::fill();
202    b.move_to(start);
203    for (end, c1, c2) in segs {
204        b.cubic_bezier_to(end, c1, c2);
205    }
206    b.close();
207    if let Ok(path) = b.build() {
208        window.paint_path(path, color);
209    }
210}
211
212/// A closed Catmull-Rom through the sampled outline, as cubic Béziers — which
213/// is what rounds the corners a polygon profile lands between samples.
214fn spline(pts: &[(f32, f32); SAMPLES], map: &impl Fn(f32, f32) -> Point<Pixels>) -> Vec<Cubic> {
215    let at = |i: isize| pts[i.rem_euclid(SAMPLES as isize) as usize];
216    (0..SAMPLES as isize)
217        .map(|i| {
218            let ((x0, y0), (x1, y1), (x2, y2), (x3, y3)) = (at(i - 1), at(i), at(i + 1), at(i + 2));
219            (
220                map(x2, y2),
221                map(x1 + (x2 - x0) / 6.0, y1 + (y2 - y0) / 6.0),
222                map(x2 - (x3 - x1) / 6.0, y2 - (y3 - y1) / 6.0),
223            )
224        })
225        .collect()
226}
227
228/// `|x/rx|^n + |y/ry|^n = 1`, each quadrant one cubic whose control offset puts
229/// the curve through the 45° point.
230fn superellipse(e: &Eye, map: &impl Fn(f32, f32) -> Point<Pixels>) -> (Point<Pixels>, Vec<Cubic>) {
231    // Past n ≈ 5.55 the offset exceeds the radius and the curve bulges outside
232    // its own bounding box.
233    let k = ((8.0 * 2f32.powf(-1.0 / e.n) - 4.0) / 3.0).min(1.0);
234    let (ak, bk) = (e.rx * k, e.ry * k);
235    let (rx, ry) = (e.rx, e.ry);
236    let pts = [
237        (rx, 0.0),
238        (rx, bk),
239        (ak, ry),
240        (0.0, ry),
241        (-ak, ry),
242        (-rx, bk),
243        (-rx, 0.0),
244        (-rx, -bk),
245        (-ak, -ry),
246        (0.0, -ry),
247        (ak, -ry),
248        (rx, -bk),
249        (rx, 0.0),
250    ];
251    let (sin, cos) = e.rot.to_radians().sin_cos();
252    let at = |(x, y): (f32, f32)| map(e.cx + x * cos - y * sin, e.cy + x * sin + y * cos);
253    (
254        at(pts[0]),
255        (1..13)
256            .step_by(3)
257            .map(|i| (at(pts[i + 2]), at(pts[i]), at(pts[i + 1])))
258            .collect(),
259    )
260}