Skip to main content

gpui_ai/
orbs.rs

1//! An ambient, always-moving indicator for "the model is alive" moments.
2//!
3//! The glyph is a 3×3 dot lattice whose cells pulse with choreographed
4//! phase offsets. Variants give the same geometry distinct motion
5//! personalities — a radial wavefront, a diagonal band sweep, a comet
6//! running the lattice perimeter, a traveling column, and a scrambled
7//! pulse — so an application can pick the one that matches its voice.
8//! Under reduced motion every variant resolves to a useful static frame.
9
10use crate::motion::{AmbientLoopSpec, MotionTokens, VisibleAnimationExt as _};
11use gpui::InteractiveElement as _;
12use gpui::{
13    App, ElementId, IntoElement, ParentElement as _, Pixels, RenderOnce, StyleRefinement, Styled,
14    Window, div, px,
15};
16use gpui_component::{ActiveTheme as _, StyledExt as _};
17
18/// Lattice is N×N dots. Dot positions derive from the cluster diameter and
19/// dot size; no separate pitch constant is needed.
20const N: usize = 3;
21
22/// Stage edge length the geometry is tuned on.
23const STAGE: f32 = 28.0;
24
25/// Center-to-center lattice spacing at stage size, in stage pixels.
26const PITCH_STAGE: f32 = 6.5;
27
28/// One cycle of any variant, in milliseconds. Phase offsets are fractions of
29/// this duration. Derived from the ambient-loop role, not owned here: the
30/// choreography below phases in integer beats and needs the cycle as a
31/// number, not a `Duration`.
32const CYCLE_MS: u64 = AmbientLoopSpec::ORB_LATTICE.period_millis();
33
34/// A choreography for the lattice's per-dot animation delays and motion.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum OrbVariant {
37    /// Radiates from the center on a round wavefront.
38    #[default]
39    Radial,
40    /// A broad band crosses the grid on the diagonal.
41    Diagonal,
42    /// One head with a decaying tail runs the perimeter clockwise.
43    Comet,
44    /// A soft column travels left to right.
45    Column,
46    /// Like `Comet`, but the pulse jumps pseudo-randomly around the ring.
47    Scattered,
48}
49
50impl OrbVariant {
51    /// All variants, in stable display order.
52    pub const ALL: [Self; 5] = [
53        Self::Radial,
54        Self::Diagonal,
55        Self::Comet,
56        Self::Column,
57        Self::Scattered,
58    ];
59
60    /// Stable identifier used in element IDs and tests.
61    pub fn slug(self) -> &'static str {
62        match self {
63            Self::Radial => "radial",
64            Self::Diagonal => "diagonal",
65            Self::Comet => "comet",
66            Self::Column => "column",
67            Self::Scattered => "scattered",
68        }
69    }
70
71    /// Per-cell animation delay in milliseconds. Negative values seed a cell
72    /// partway into the shared cycle, which turns nine identical dots on one
73    /// clock into one traveling pattern.
74    fn cell_delay(self, x: usize, y: usize) -> i64 {
75        let mid = (N - 1) as f32 / 2.0;
76        let dx = x as f32 - mid;
77        let dy = y as f32 - mid;
78        match self {
79            // Center leads a beat early so the next swell doesn't sit behind
80            // the outer fade.
81            Self::Radial => {
82                (dx.hypot(dy) * 700.0) as i64 - if dx == 0.0 && dy == 0.0 { 180 } else { 0 }
83            }
84            // Spread close to the cycle keeps the sweep continuous: the far
85            // corner restarts as the near one does.
86            Self::Diagonal => ((x + y) as f32 / (2.0 * (N - 1) as f32) * 1500.0) as i64,
87            Self::Comet | Self::Scattered => {
88                let Some(ring_index) = perimeter_index(x, y) else {
89                    return 0;
90                };
91                let count = perimeter_len() as f32;
92                match self {
93                    Self::Comet => {
94                        -(((perimeter_len() - ring_index) % perimeter_len()) as f32 / count
95                            * CYCLE_MS as f32) as i64
96                    }
97                    // (i * 3) % len walks the ring in a scattered order.
98                    Self::Scattered => {
99                        -(((ring_index * 3) % perimeter_len()) as f32 / count * CYCLE_MS as f32)
100                            as i64
101                    }
102                    _ => unreachable!("outer match covers both arms"),
103                }
104            }
105            Self::Column => (x as f32 / (N - 1) as f32 * 1100.0) as i64,
106        }
107    }
108}
109
110/// Clockwise walk of the lattice perimeter — the track `Comet`/`Scattered`
111/// pulses run on. Returns the ring position of a cell, or `None` for the
112/// interior center.
113fn perimeter_index(x: usize, y: usize) -> Option<usize> {
114    let last = N - 1;
115    let on_ring = x == 0 || x == last || y == 0 || y == last;
116    if !on_ring {
117        return None;
118    }
119    // Walk: top row left→right, right column top→bottom, bottom row
120    // right→left, left column bottom→top.
121    let index = if y == 0 {
122        x
123    } else if x == last {
124        last + y
125    } else if y == last {
126        last + last + (last - x)
127    } else {
128        last + last + last + (last - y)
129    };
130    Some(index)
131}
132
133/// Number of cells on the lattice perimeter.
134fn perimeter_len() -> usize {
135    4 * (N - 1)
136}
137
138/// A cluster of softly pulsing dots — an ambient thinking indicator for
139/// moments with no progress to report, where a spinner would feel too
140/// mechanical (voice idle states, model warm-up, long silent reasoning).
141///
142/// Dots take their colors from the active theme (`primary`, `info`, `cyan`),
143/// so the indicator follows light/dark modes and custom themes like every
144/// other component.
145///
146/// # Example
147///
148/// ```
149/// # use gpui_ai::prelude::*;
150/// # use gpui::px;
151/// Orbs::new();                                  // default variant, 40px
152/// Orbs::new().variant(OrbVariant::Comet);       // comet choreography
153/// Orbs::new().diameter(px(64.));                // larger
154/// ```
155#[derive(IntoElement)]
156pub struct Orbs {
157    id: ElementId,
158    style: StyleRefinement,
159    diameter: Pixels,
160    variant: OrbVariant,
161}
162
163impl Orbs {
164    /// Creates a 40px orb cluster using the [`OrbVariant::Radial`] variant.
165    #[track_caller]
166    pub fn new() -> Self {
167        Self {
168            id: ElementId::CodeLocation(*std::panic::Location::caller()),
169            style: StyleRefinement::default(),
170            diameter: px(40.),
171            variant: OrbVariant::default(),
172        }
173    }
174
175    /// Sets stable instance identity. Supply a domain ID when constructing
176    /// multiple clusters from the same call site (for example, in a loop).
177    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
178        self.id = id.into();
179        self
180    }
181
182    /// Sets the overall cluster diameter. Dot sizes and spacing scale with it.
183    pub fn diameter(mut self, diameter: impl Into<Pixels>) -> Self {
184        self.diameter = diameter.into();
185        self
186    }
187
188    /// Sets the lattice choreography.
189    pub fn variant(mut self, variant: OrbVariant) -> Self {
190        self.variant = variant;
191        self
192    }
193}
194
195impl Default for Orbs {
196    #[track_caller]
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202impl Styled for Orbs {
203    fn style(&mut self) -> &mut StyleRefinement {
204        &mut self.style
205    }
206}
207
208/// Everything one dot needs per animation frame, computed once per render.
209///
210/// The whole lattice samples a single clock, so per-frame work is this table
211/// walked nine times — no per-dot animation state, and nothing allocated
212/// while the clock runs.
213struct LatticeDot {
214    home_x: Pixels,
215    home_y: Pixels,
216    gather: (f32, f32),
217    release: (f32, f32),
218    seeded_phase: f32,
219    color: gpui::Hsla,
220}
221
222impl RenderOnce for Orbs {
223    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
224        let tokens = cx.theme().semantic_tokens();
225        let full = crate::motion::motion_is_full(cx);
226        let lattice = MotionTokens::read(cx).orb_lattice();
227        let scale = self.diameter.as_f32() / STAGE;
228        let dot = px(4.2 * scale);
229        // Center-to-center spacing between lattice dots.
230        let pitch = px(6.5 * scale);
231        let colors = [cx.theme().primary, cx.theme().info, cx.theme().cyan];
232        let variant = self.variant;
233        let variant_index = OrbVariant::ALL
234            .iter()
235            .position(|candidate| *candidate == variant)
236            .unwrap_or_default() as u64;
237        // Lattice geometry: center the N×N grid of dots inside the cluster.
238        // Cell pitch keeps a dot-gap-dot rhythm; the whole block is offset so
239        // the lattice is centered regardless of diameter.
240        let span = px((N - 1) as f32 * pitch.as_f32());
241        let origin = (self.diameter - span - dot) / 2.0;
242        let radius = tokens.radius.full;
243
244        let dots: Vec<LatticeDot> = (0..N)
245            .flat_map(|y| (0..N).map(move |x| (x, y)))
246            .map(|(x, y)| {
247                let delay = variant.cell_delay(x, y);
248                // Negative delays seed partway into the cycle; the shared
249                // clock starts at zero, so shift into the positive domain by
250                // pre-advancing the phase.
251                let seeded_phase = if delay < 0 {
252                    (CYCLE_MS as i64 + delay % CYCLE_MS as i64) as f32 / CYCLE_MS as f32
253                } else {
254                    delay as f32 / CYCLE_MS as f32
255                };
256                // Swirl-settle: each dot gathers from a position rotated one
257                // way around the lattice center and releases to the mirror
258                // rotation, so the cycle keeps swirling in one direction
259                // instead of rewinding.
260                LatticeDot {
261                    home_x: origin + pitch * x,
262                    home_y: origin + pitch * y,
263                    gather: swirl_offset(x, y, -SWIRL_RADIANS, scale),
264                    release: swirl_offset(x, y, SWIRL_RADIANS, scale),
265                    seeded_phase,
266                    color: colors[(x + y) % colors.len()].opacity(0.55),
267                }
268            })
269            .collect();
270
271        div()
272            .id(self.id)
273            .relative()
274            .size(self.diameter)
275            .refine_style(&self.style)
276            .child(div().relative().size_full().with_visible_animation(
277                ElementId::NamedInteger("orb-lattice".into(), variant_index),
278                // Frame demand: intentionally ambient. Orbs are the "the
279                // model is alive" glyph, so the lattice animates for as long
280                // as the cluster is on screen; there is no settled state to
281                // reach and treating one as a bug would delete the
282                // component's reason to exist. The caller decides when the
283                // cluster is mounted. One clock for all nine dots,
284                // phase-locked to the shared epoch so side-by-side clusters
285                // tick together. Reduced motion holds delta at 0, leaving
286                // each dot at its seeded phase — a still choreographed
287                // frame, not nine identical dots.
288                lattice.looping_synced(),
289                move |stage, delta| {
290                    let delta = if full { delta } else { 0.0 };
291                    stage.children(dots.iter().map(|spec| {
292                        let phase = (delta + spec.seeded_phase) % 1.0;
293                        // Eased triangle: 0→1→0 across the cycle so each dot
294                        // leaves home, swings through the far rotation, and
295                        // returns.
296                        let swing = if phase < 0.5 {
297                            phase * 2.0
298                        } else {
299                            2.0 - phase * 2.0
300                        };
301                        let eased = swing * swing * (3.0 - 2.0 * swing);
302                        let (ax, ay) = spec.gather;
303                        let (bx, by) = spec.release;
304                        let dx = ax + (bx - ax) * eased;
305                        let dy = ay + (by - ay) * eased;
306                        // One-beat swell rides on top of the travel:
307                        // brightest mid-swing, dimmest at rest.
308                        let swell = (phase * std::f32::consts::TAU).sin();
309                        div()
310                            .absolute()
311                            .left(spec.home_x + px(dx))
312                            .top(spec.home_y + px(dy))
313                            .size(dot)
314                            .rounded(radius)
315                            .bg(spec.color)
316                            .opacity(0.35 + 0.5 * (0.5 + 0.5 * swell))
317                    }))
318                },
319            ))
320    }
321}
322
323/// Radians of rotation at each end of the settle cycle (~60°).
324const SWIRL_RADIANS: f32 = 1.05;
325
326/// Outward push on top of the rotation, as a fraction of pitch.
327const SWIRL_SPREAD: f32 = 1.6;
328
329/// Offset from a cell's own grid slot to its swirled position, scaled to the
330/// rendered cluster size.
331fn swirl_offset(x: usize, y: usize, angle: f32, scale: f32) -> (f32, f32) {
332    let mid = (N - 1) as f32 / 2.0;
333    let dx = x as f32 - mid;
334    let dy = y as f32 - mid;
335    let pitch = PITCH_STAGE * scale;
336    let cos = angle.cos();
337    let sin = angle.sin();
338    (
339        ((dx * cos - dy * sin) * SWIRL_SPREAD - dx) * pitch,
340        ((dx * sin + dy * cos) * SWIRL_SPREAD - dy) * pitch,
341    )
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use gpui::{Context, Render, TestAppContext, WindowHandle, size};
348
349    struct LatticeProbe {
350        variant: OrbVariant,
351    }
352
353    impl Render for LatticeProbe {
354        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
355            div().child(Orbs::new().variant(self.variant))
356        }
357    }
358
359    fn open(variant: OrbVariant, cx: &mut TestAppContext) -> WindowHandle<LatticeProbe> {
360        cx.update(crate::init);
361        let window = cx.open_window(size(px(80.), px(80.)), |_, _| LatticeProbe { variant });
362        cx.run_until_parked();
363        next_frame(&window, cx);
364        window
365    }
366
367    fn next_frame(window: &WindowHandle<LatticeProbe>, cx: &mut TestAppContext) -> usize {
368        let callbacks = window
369            .update(cx, |_, window, cx| window.simulate_next_frame(cx))
370            .expect("the lattice window should remain open");
371        cx.run_until_parked();
372        callbacks
373    }
374
375    #[gpui::test]
376    fn every_variant_runs_its_lattice_on_one_clock(cx: &mut TestAppContext) {
377        let window = open(OrbVariant::Radial, cx);
378        for variant in OrbVariant::ALL {
379            window
380                .update(cx, |probe, _, cx| {
381                    probe.variant = variant;
382                    cx.notify();
383                })
384                .expect("the lattice window should remain open");
385            cx.run_until_parked();
386            // The switch re-renders while the previous frame's callback is
387            // still queued; drain it, then hold the steady state.
388            next_frame(&window, cx);
389            assert_eq!(
390                next_frame(&window, cx),
391                1,
392                "{variant:?} must derive nine dots from one clock"
393            );
394        }
395    }
396
397    #[gpui::test]
398    fn reduced_motion_leaves_a_choreographed_still_lattice(cx: &mut TestAppContext) {
399        cx.update(|cx| cx.set_reduce_motion(true));
400        let window = open(OrbVariant::Radial, cx);
401        assert_eq!(
402            next_frame(&window, cx),
403            0,
404            "a held lattice schedules nothing; seeded phases are the still frame"
405        );
406    }
407
408    #[test]
409    fn radial_wavefront_radiates_from_center() {
410        let center = OrbVariant::Radial.cell_delay(1, 1);
411        let corner = OrbVariant::Radial.cell_delay(2, 2);
412        assert!(center < corner, "corner should lag the center");
413        // Center leads by its beat bonus.
414        assert_eq!(center, -180);
415    }
416
417    #[test]
418    fn diagonal_band_orders_by_x_plus_y() {
419        let first = OrbVariant::Diagonal.cell_delay(0, 0);
420        let middle = OrbVariant::Diagonal.cell_delay(1, 1);
421        let last = OrbVariant::Diagonal.cell_delay(2, 2);
422        assert!(first < middle && middle < last);
423    }
424
425    #[test]
426    fn column_travels_left_to_right() {
427        let left = OrbVariant::Column.cell_delay(0, 1);
428        let right = OrbVariant::Column.cell_delay(2, 1);
429        assert!(left < right);
430    }
431
432    #[test]
433    fn comet_walks_the_perimeter_and_skips_the_center() {
434        assert_eq!(OrbVariant::Comet.cell_delay(1, 1), 0);
435        // Clockwise walk: (0,0)→(1,0)→(2,0)→(2,1)→(2,2). The head cell (0,0)
436        // leads at zero; every follower seeds progressively *later* in the
437        // previous cycle (delays rise toward zero), so the visible pulse
438        // travels clockwise along the ring.
439        let delays: Vec<i64> = [(0, 0), (1, 0), (2, 0), (2, 1), (2, 2)]
440            .map(|(x, y)| OrbVariant::Comet.cell_delay(x, y))
441            .to_vec();
442        assert_eq!(*delays.first().expect("non-empty"), 0);
443        assert!(
444            delays[1..].windows(2).all(|pair| pair[0] < pair[1]),
445            "follower delays must increase clockwise: {delays:?}"
446        );
447    }
448
449    #[test]
450    fn scattered_visits_every_ring_cell_with_shuffled_phases() {
451        // Both variants produce the same set of phase magnitudes over the
452        // ring (all eight cells), but assign them in a different order.
453        let mut comet: Vec<u64> = perimeter_cells()
454            .map(|(x, y)| OrbVariant::Comet.cell_delay(x, y).unsigned_abs())
455            .collect();
456        let mut scattered: Vec<u64> = perimeter_cells()
457            .map(|(x, y)| OrbVariant::Scattered.cell_delay(x, y).unsigned_abs())
458            .collect();
459        comet.sort_unstable();
460        scattered.sort_unstable();
461        assert_eq!(comet, scattered);
462
463        // The assignment order differs: at least one ring cell has a
464        // different phase under Scattered than under Comet.
465        let different = perimeter_cells()
466            .zip(perimeter_cells())
467            .any(|((cx_, cy_), (sx, sy))| {
468                OrbVariant::Comet.cell_delay(cx_, cy_) != OrbVariant::Scattered.cell_delay(sx, sy)
469            });
470        assert!(different, "scattered must not preserve the clockwise order");
471    }
472
473    /// Ring cells in clockwise order.
474    fn perimeter_cells() -> impl Iterator<Item = (usize, usize)> {
475        (0..N)
476            .map(|x| (x, 0))
477            .chain((1..N).map(move |y| (N - 1, y)))
478            .chain((0..N - 1).rev().map(move |x| (x, N - 1)))
479            .chain((1..N - 1).rev().map(move |y| (0, y)))
480    }
481
482    #[test]
483    fn perimeter_covers_all_eight_ring_cells_exactly_once() {
484        let mut seen = Vec::new();
485        for y in 0..N {
486            for x in 0..N {
487                if let Some(index) = perimeter_index(x, y) {
488                    seen.push(index);
489                }
490            }
491        }
492        seen.sort_unstable();
493        seen.dedup();
494        assert_eq!(seen.len(), perimeter_len());
495        assert_eq!(seen, (0..perimeter_len()).collect::<Vec<_>>());
496    }
497}