Skip to main content

agent/orbs/
orb.rs

1//! The [`Orb`] component: a Rust builder over the animation engine.
2//!
3//! The engine takes continuous, unbounded time — its modes mix incommensurate
4//! frequencies, so there is no seamless wrap point and gpui's folded
5//! `with_animation` clock would jump at every loop. An entity owns the real
6//! clock; [`orb_element`] is the same paint one layer down for hosts that
7//! already tick.
8
9use std::{cell::RefCell, rc::Rc, time::Duration};
10
11use gpui::{
12    Bounds, Context, IntoElement, ParentElement, Pixels, Render, Styled, Task, Window, canvas, div,
13    px,
14};
15use web_time::Instant;
16
17use crate::orbs::{
18    engine::{Frame, draw_mode_into, draw_mode_into_resolved},
19    paint::paint_frame,
20    presets::{Resolved, resolve_preset},
21    types::{OrbSize, OrbState, OrbTheme},
22};
23
24/// Frames per second the orb redraws at when no explicit rate is set.
25///
26/// The orb is a status indicator, not a game: its motion is slow and organic,
27/// and at 30 fps it is indistinguishable from 60 while costing half as much.
28/// Every redraw walks the whole element tree, so the tick rate — not the
29/// geometry — is what dominates CPU.
30pub const DEFAULT_TARGET_FPS: f32 = 30.0;
31
32/// Animated thinking-orb status indicator for AI / agent UIs.
33///
34/// ```ignore
35/// cx.new(|_| Orb::new().state(OrbState::Searching).size(OrbSize::Avatar))
36/// ```
37pub struct Orb {
38    state: OrbState,
39    size: OrbSize,
40    theme: OrbTheme,
41    /// Multiplier on top of the preset's baked speed.
42    speed: f32,
43    paused: bool,
44    /// When true, freeze on a static representative frame (`t = 0.6`). The
45    /// system `reduce_motion` setting forces the same, so hosts only set this
46    /// for their own per-surface motion preferences.
47    reduced_motion: bool,
48    /// Redraw rate ceiling.
49    target_fps: f32,
50    /// Stop animating while the host window is not the active one.
51    pause_when_inactive: bool,
52    /// Host-controlled visibility. When false the timer is cancelled and the
53    /// orb freezes — use this when the entity is still mounted but scrolled
54    /// off-screen (gpui has no intersection observer).
55    visible: bool,
56
57    // ---- clock ----
58    started: Instant,
59    /// Wall time accumulated while paused, subtracted from the animation clock
60    /// so pausing genuinely freezes motion instead of merely stopping redraws.
61    paused_total: Duration,
62    /// Set while a pause is in effect.
63    paused_at: Option<Instant>,
64
65    // ---- caches ----
66    /// `(state, size)` the cached `resolved` was computed for.
67    cache_key: (OrbState, OrbSize),
68    resolved: Resolved,
69    /// Geometry buffer reused across frames. Behind `Rc<RefCell<_>>` because
70    /// the canvas paint callback must be `'static` and so cannot borrow `self`.
71    frame: Rc<RefCell<Frame>>,
72    /// Explicit invalidation for animation ticks and semantic changes. Parent
73    /// renders between ticks reuse the retained geometry.
74    geometry_dirty: bool,
75
76    /// Pending redraw timer. At most one stays in flight; dropping it cancels
77    /// animation immediately when the orb becomes paused or invisible.
78    tick: Option<Task<()>>,
79    /// Window-activation subscription, registered lazily on first render since
80    /// it needs a `Window`. Dropping it unsubscribes.
81    activation: Option<gpui::Subscription>,
82}
83
84impl Default for Orb {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl Orb {
91    pub fn new() -> Self {
92        let state = OrbState::Working;
93        let size = OrbSize::Avatar;
94        Self {
95            state,
96            size,
97            theme: OrbTheme::Auto,
98            speed: 1.0,
99            paused: false,
100            reduced_motion: false,
101            target_fps: DEFAULT_TARGET_FPS,
102            pause_when_inactive: true,
103            visible: true,
104            started: Instant::now(),
105            paused_total: Duration::ZERO,
106            paused_at: None,
107            cache_key: (state, size),
108            resolved: resolve_preset(state, size),
109            frame: Rc::new(RefCell::new(Frame::new())),
110            geometry_dirty: true,
111            tick: None,
112            activation: None,
113        }
114    }
115
116    pub fn state(mut self, state: OrbState) -> Self {
117        self.state = state;
118        self
119    }
120
121    pub fn size(mut self, size: OrbSize) -> Self {
122        self.size = size;
123        self
124    }
125
126    pub fn theme(mut self, theme: OrbTheme) -> Self {
127        self.theme = theme;
128        self
129    }
130
131    pub fn speed(mut self, speed: f32) -> Self {
132        self.speed = sanitize_speed(speed);
133        self
134    }
135
136    /// Same clock rules as [`Self::set_paused`]: entering pause records
137    /// `paused_at`; leaving folds the elapsed pause into `paused_total`.
138    pub fn paused(mut self, paused: bool) -> Self {
139        apply_pause_clock(
140            &mut self.paused,
141            &mut self.paused_at,
142            &mut self.paused_total,
143            paused,
144        );
145        self
146    }
147
148    pub fn reduced_motion(mut self, reduced: bool) -> Self {
149        self.reduced_motion = reduced;
150        self
151    }
152
153    /// Cap the redraw rate. Values are clamped to `1.0..=240.0`.
154    ///
155    /// Lower is cheaper: cost scales linearly with this number.
156    pub fn target_fps(mut self, fps: f32) -> Self {
157        self.target_fps = sanitize_fps(fps);
158        self
159    }
160
161    /// Whether to freeze while the host window is inactive. Defaults to `true`.
162    ///
163    /// A background window's animation is not visible to anyone, so this is
164    /// usually free. Set it to `false` if the orb must keep moving in a window
165    /// that is visible but unfocused — a side panel, or a floating HUD.
166    pub fn pause_when_inactive(mut self, pause: bool) -> Self {
167        self.pause_when_inactive = pause;
168        self
169    }
170
171    /// Whether the host considers this orb on-screen. Defaults to `true`.
172    ///
173    /// gpui has no intersection observer. When you keep the entity mounted in
174    /// a scrollable list but it has scrolled away, call
175    /// [`Self::set_visible`]`(false)` (or build with `.visible(false)`) so the
176    /// timer stops. Prefer unmounting when you can.
177    pub fn visible(mut self, visible: bool) -> Self {
178        self.visible = visible;
179        self
180    }
181
182    /// Mutable setters for interactive playgrounds.
183    pub fn set_state(&mut self, state: OrbState, cx: &mut Context<Self>) {
184        if self.state != state {
185            self.state = state;
186            self.geometry_dirty = true;
187            cx.notify();
188        }
189    }
190
191    pub fn set_size(&mut self, size: OrbSize, cx: &mut Context<Self>) {
192        if self.size != size {
193            self.size = size;
194            self.geometry_dirty = true;
195            cx.notify();
196        }
197    }
198
199    pub fn set_theme(&mut self, theme: OrbTheme, cx: &mut Context<Self>) {
200        if self.theme != theme {
201            self.theme = theme;
202            cx.notify();
203        }
204    }
205
206    pub fn set_speed(&mut self, speed: f32, cx: &mut Context<Self>) {
207        let speed = sanitize_speed(speed);
208        if self.speed != speed {
209            self.speed = speed;
210            self.geometry_dirty = true;
211            cx.notify();
212        }
213    }
214
215    pub fn set_paused(&mut self, paused: bool, cx: &mut Context<Self>) {
216        if self.paused == paused {
217            return;
218        }
219        apply_pause_clock(
220            &mut self.paused,
221            &mut self.paused_at,
222            &mut self.paused_total,
223            paused,
224        );
225        self.geometry_dirty = true;
226        cx.notify();
227    }
228
229    pub fn set_reduced_motion(&mut self, reduced: bool, cx: &mut Context<Self>) {
230        if self.reduced_motion != reduced {
231            self.reduced_motion = reduced;
232            self.geometry_dirty = true;
233            cx.notify();
234        }
235    }
236
237    pub fn set_target_fps(&mut self, fps: f32, cx: &mut Context<Self>) {
238        self.target_fps = sanitize_fps(fps);
239        cx.notify();
240    }
241
242    pub fn set_pause_when_inactive(&mut self, pause: bool, cx: &mut Context<Self>) {
243        if self.pause_when_inactive != pause {
244            self.pause_when_inactive = pause;
245            cx.notify();
246        }
247    }
248
249    /// Host visibility gate — see [`Self::visible`].
250    pub fn set_visible(&mut self, visible: bool, cx: &mut Context<Self>) {
251        if self.visible != visible {
252            self.visible = visible;
253            self.geometry_dirty = true;
254            cx.notify();
255        }
256    }
257
258    pub fn state_value(&self) -> OrbState {
259        self.state
260    }
261
262    pub fn size_value(&self) -> OrbSize {
263        self.size
264    }
265
266    pub fn theme_value(&self) -> OrbTheme {
267        self.theme
268    }
269
270    pub fn speed_value(&self) -> f32 {
271        self.speed
272    }
273
274    pub fn is_paused(&self) -> bool {
275        self.paused
276    }
277
278    pub fn reduced_motion_value(&self) -> bool {
279        self.reduced_motion
280    }
281
282    pub fn target_fps_value(&self) -> f32 {
283        self.target_fps
284    }
285
286    pub fn pause_when_inactive_value(&self) -> bool {
287        self.pause_when_inactive
288    }
289
290    pub fn is_visible(&self) -> bool {
291        self.visible
292    }
293
294    /// Ink direction, resolved against the installed bezel appearance —
295    /// `Auto` follows it, no window subscription needed (the appearance is
296    /// process-wide and its switch repaints the window anyway).
297    fn dark(&self) -> bool {
298        match self.theme {
299            OrbTheme::Dark => true,
300            OrbTheme::Light => false,
301            OrbTheme::Auto => theme::current_appearance().is_dark(),
302        }
303    }
304
305    /// Animation clock in seconds, excluding any time spent paused.
306    ///
307    /// Accumulated in `f64` and only narrowed at the end: `f32` has a 24-bit
308    /// mantissa, so a clock driven straight off wall time quantises visibly
309    /// after several hours of uptime. Excluding paused time also means an orb
310    /// that is idle most of the session barely advances its clock at all.
311    ///
312    /// Known limit: the engine takes `t: f32`, so very long *continuous*
313    /// animation still loses step resolution. There is no seamless wrap point —
314    /// the modes mix incommensurate frequencies, so folding the clock would
315    /// trade slow degradation for a visible jump.
316    fn time_seconds(&self, reduced: bool) -> f32 {
317        if reduced {
318            return 0.6;
319        }
320        let paused = match self.paused_at {
321            Some(at) => self.paused_total + at.elapsed(),
322            None => self.paused_total,
323        };
324        let live = self.started.elapsed().saturating_sub(paused);
325        (live.as_secs_f64() * self.resolved.speed as f64 * self.speed as f64) as f32
326    }
327
328    /// Queue the next redraw, honouring [`Self::target_fps`].
329    fn schedule_tick(&mut self, cx: &mut Context<Self>) {
330        // Parent renders may happen between animation frames. Keep the timer
331        // already in flight rather than cancel/reallocate it and push the next
332        // frame farther into the future.
333        if self.tick.is_some() {
334            return;
335        }
336        let period = Duration::from_secs_f32(1.0 / self.target_fps);
337        self.tick = Some(cx.spawn(async move |this, cx| {
338            cx.background_executor().timer(period).await;
339            let _ = this.update(cx, |orb, cx| {
340                orb.tick = None;
341                orb.geometry_dirty = true;
342                cx.notify();
343            });
344        }));
345    }
346}
347
348impl Render for Orb {
349    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
350        // An orb in a background window stops ticking entirely, so it needs a
351        // nudge to start again when the window comes back. Registered once,
352        // here, because it needs a `Window`.
353        if self.activation.is_none() {
354            self.activation = Some(cx.observe_window_activation(window, |orb, _, cx| {
355                orb.geometry_dirty = true;
356                cx.notify();
357            }));
358        }
359
360        // Presets are pure functions of (state, size); recompute only when one
361        // of those actually changes rather than on every frame.
362        let key = (self.state, self.size);
363        if self.cache_key != key {
364            self.cache_key = key;
365            self.resolved = resolve_preset(self.state, self.size);
366        }
367
368        let size_px = self.size.pixels();
369        let dark = self.dark();
370        let reduced = self.reduced_motion || cx.reduce_motion();
371        let t = self.time_seconds(reduced);
372        let r_min = self.resolved.opts.r_min.unwrap_or(0.3);
373
374        // Only ticks and semantic changes invalidate geometry. A parent can
375        // re-render much faster than this orb's target FPS; those extra renders
376        // reuse the retained frame rather than running animation math again.
377        if self.geometry_dirty {
378            draw_mode_into_resolved(
379                self.resolved.mode,
380                size_px,
381                t,
382                &self.resolved.opts,
383                &mut self.frame.borrow_mut(),
384            );
385            self.geometry_dirty = false;
386        }
387
388        let animating = self.visible
389            && !self.paused
390            && !reduced
391            && (!self.pause_when_inactive || window.is_window_active());
392        if animating {
393            self.schedule_tick(cx);
394        } else {
395            // Drop any in-flight tick so a paused, hidden, or backgrounded orb
396            // costs nothing at all.
397            self.tick = None;
398        }
399
400        let frame = self.frame.clone();
401        div()
402            .size(px(size_px))
403            .flex_shrink_0()
404            .overflow_hidden()
405            .child(
406                canvas(
407                    move |_bounds: Bounds<Pixels>, _window, _cx| (),
408                    move |bounds, (), window, _cx| {
409                        paint_frame(window, bounds, &frame.borrow(), dark, r_min);
410                    },
411                )
412                .size_full(),
413            )
414    }
415}
416
417/// Paint one frame of an orb at animation time `t` (seconds, unbounded) — the
418/// pure, host-ticked form of [`Orb`]. Build it inside any render that runs on
419/// a clock of its own; the reduced-motion convention is `t = 0.6`.
420///
421/// `frame` is the caller's geometry buffer, overwritten here and handed to the
422/// paint closure. Geometry is a function of `t`, so there is nothing to cache
423/// between frames — but a host that keeps one buffer per orb reuses its two
424/// `Vec`s forever instead of growing a pair from empty on every tick.
425pub fn orb_element(
426    state: OrbState,
427    size: OrbSize,
428    t: f32,
429    frame: &Rc<RefCell<Frame>>,
430) -> impl IntoElement {
431    let resolved = resolve_preset(state, size);
432    let size_px = size.pixels();
433    let frame = frame.clone();
434    draw_mode_into(
435        resolved.mode,
436        size_px,
437        t,
438        &resolved.opts,
439        &mut frame.borrow_mut(),
440    );
441    let dark = theme::current_appearance().is_dark();
442    let r_min = resolved.opts.r_min.unwrap_or(0.3);
443    div()
444        .size(px(size_px))
445        .flex_shrink_0()
446        .overflow_hidden()
447        .child(
448            canvas(
449                move |_bounds: Bounds<Pixels>, _window, _cx| (),
450                move |bounds, (), window, _cx| {
451                    paint_frame(window, bounds, &frame.borrow(), dark, r_min);
452                },
453            )
454            .size_full(),
455        )
456}
457
458/// Shared pause-clock rules for the builder and the mutable setter.
459fn apply_pause_clock(
460    paused: &mut bool,
461    paused_at: &mut Option<Instant>,
462    paused_total: &mut Duration,
463    want: bool,
464) {
465    if *paused == want {
466        // Still clear a stuck paused_at if someone left it set while unpaused.
467        if !want && let Some(at) = paused_at.take() {
468            *paused_total += at.elapsed();
469        }
470        return;
471    }
472    *paused = want;
473    if want {
474        if paused_at.is_none() {
475            *paused_at = Some(Instant::now());
476        }
477    } else if let Some(at) = paused_at.take() {
478        *paused_total += at.elapsed();
479    }
480}
481
482fn sanitize_speed(speed: f32) -> f32 {
483    if speed.is_finite() {
484        speed.clamp(0.0, 100.0)
485    } else {
486        1.0
487    }
488}
489
490fn sanitize_fps(fps: f32) -> f32 {
491    if fps.is_finite() {
492        fps.clamp(1.0, 240.0)
493    } else {
494        DEFAULT_TARGET_FPS
495    }
496}