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