Skip to main content

gpui_ai/
loading.rs

1//! An animated pixel-grid loading state with optional elapsed time.
2
3use crate::motion::{MotionTokens, VisibleAnimationExt as _};
4use crate::theme::SemanticStyledExt as _;
5use gpui::{
6    App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
7    SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
8    prelude::FluentBuilder as _,
9};
10use gpui_component::{ActiveTheme as _, StyledExt as _, h_flex};
11use std::panic::Location;
12use std::time::Duration;
13
14/// Grid dimensions of the loader.
15const ROWS: usize = 3;
16const COLS: usize = 3;
17
18/// A pixel-grid loader for "the agent is working" moments.
19///
20/// A small grid of squares pulses in a diagonal sweep, next to a label and an
21/// optional elapsed-time readout. The component holds no timer: the caller
22/// owns the clock and re-renders to tick the elapsed display (the sweep
23/// itself animates independently via GPUI's animation system).
24///
25/// # Example
26///
27/// ```
28/// # use gpui_ai::prelude::*;
29/// # use std::time::Duration;
30/// LoadingState::new()
31///     .label("Reasoning about your request")
32///     .elapsed(Duration::from_secs(7));
33/// ```
34#[derive(IntoElement)]
35pub struct LoadingState {
36    id: ElementId,
37    style: StyleRefinement,
38    label: SharedString,
39    elapsed: Option<Duration>,
40}
41
42impl LoadingState {
43    /// Creates a loader with the default label.
44    #[track_caller]
45    pub fn new() -> Self {
46        Self {
47            id: ElementId::CodeLocation(*Location::caller()),
48            style: StyleRefinement::default(),
49            label: "Working…".into(),
50            elapsed: None,
51        }
52    }
53
54    /// Sets the label describing what is happening.
55    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
56        self.label = label.into();
57        self
58    }
59
60    /// Shows an elapsed-time readout after the label.
61    pub fn elapsed(mut self, elapsed: Duration) -> Self {
62        self.elapsed = Some(elapsed);
63        self
64    }
65}
66
67impl Default for LoadingState {
68    #[track_caller]
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl Styled for LoadingState {
75    fn style(&mut self) -> &mut StyleRefinement {
76        &mut self.style
77    }
78}
79
80impl RenderOnce for LoadingState {
81    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
82        let tokens = cx.theme().semantic_tokens();
83        let color = cx.theme().primary;
84        let sweep = MotionTokens::read(cx).grid_sweep();
85        let cell_gap = tokens.spacing.xxs;
86        let cell_size = tokens.spacing.xs;
87        let full = crate::motion::motion_is_full(cx);
88        let cell_radius = tokens.spacing.xxs;
89
90        // One clock for the whole grid: the root animation samples a single
91        // phase and every cell derives its pulse from it, so nine squares
92        // cost one scheduled animation, not nine. Phase-locked to the shared
93        // epoch, so loaders mounted at different moments sweep together.
94        let grid = div()
95            .flex()
96            .flex_col()
97            .gap(cell_gap)
98            .with_visible_animation(
99                "loading-grid",
100                // Frame demand: active while the caller keeps the loader mounted
101                // — the loader *is* the "work is running" state and owns no
102                // clock, so it settles by being unmounted. Reduced motion holds
103                // delta at 0, leaving a static diagonal gradient across the grid.
104                sweep.looping_synced(),
105                move |grid, delta| {
106                    let delta = if full { delta } else { 0.0 };
107                    grid.children((0..ROWS).map(|row| {
108                        h_flex().gap(cell_gap).children((0..COLS).map(move |col| {
109                            // Each cell's pulse is phase-shifted along the
110                            // diagonal, producing a sweep from the top-left
111                            // corner.
112                            let phase = (row + col) as f32 / ((ROWS + COLS) as f32);
113                            let wave = ((delta - phase).rem_euclid(1.0) * 2.0 - 1.0).abs();
114                            div()
115                                .size(cell_size)
116                                .rounded(cell_radius)
117                                .bg(color)
118                                .opacity(0.15 + 0.85 * wave)
119                        }))
120                    }))
121                },
122            );
123
124        h_flex()
125            .id(self.id)
126            .role(Role::ProgressIndicator)
127            .aria_label(self.label.clone())
128            .items_center()
129            .gap(tokens.spacing.md)
130            .child(grid)
131            .child(
132                div()
133                    .text_token(tokens.typography.sm)
134                    .text_color(cx.theme().muted_foreground)
135                    .child(self.label),
136            )
137            .when_some(self.elapsed, |this, elapsed| {
138                this.child(
139                    div()
140                        .text_token(tokens.typography.xs)
141                        .font_family(cx.theme().mono_font_family.clone())
142                        .text_color(cx.theme().muted_foreground)
143                        .child(format!("{:.0}s", elapsed.as_secs_f64())),
144                )
145            })
146            .refine_style(&self.style)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use gpui::{Context, Render, TestAppContext, WindowHandle, px, size};
154
155    struct LoaderProbe {
156        elapsed: Option<Duration>,
157    }
158
159    impl Render for LoaderProbe {
160        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
161            let mut loader = LoadingState::new().label("Working");
162            if let Some(elapsed) = self.elapsed {
163                loader = loader.elapsed(elapsed);
164            }
165            div().child(loader)
166        }
167    }
168
169    fn open(elapsed: Option<Duration>, cx: &mut TestAppContext) -> WindowHandle<LoaderProbe> {
170        cx.update(crate::init);
171        let window = cx.open_window(size(px(240.), px(80.)), |_, _| LoaderProbe { elapsed });
172        cx.run_until_parked();
173        // Resolve the initial visibility observation before measuring the
174        // steady region clock (the visibility transition is a one-off).
175        next_frame(&window, cx);
176        window
177    }
178
179    fn next_frame(window: &WindowHandle<LoaderProbe>, cx: &mut TestAppContext) -> usize {
180        let callbacks = window
181            .update(cx, |_, window, cx| window.simulate_next_frame(cx))
182            .expect("the loader window should remain open");
183        cx.run_until_parked();
184        callbacks
185    }
186
187    #[gpui::test]
188    fn the_whole_grid_ticks_on_one_clock(cx: &mut TestAppContext) {
189        // The count is the architecture: nine cells deriving their pulse from
190        // one sampled phase schedule one animation, not nine.
191        let window = open(None, cx);
192        assert_eq!(next_frame(&window, cx), 1, "one region, one clock");
193    }
194
195    #[gpui::test]
196    fn an_elapsed_update_does_not_disturb_the_clock(cx: &mut TestAppContext) {
197        let window = open(Some(Duration::from_secs(3)), cx);
198        assert_eq!(next_frame(&window, cx), 1);
199
200        // Mid-cycle, the caller ticks the readout — the reason this component
201        // re-renders in practice, once a second for as long as it is shown.
202        cx.executor().advance_clock(Duration::from_millis(700));
203        window
204            .update(cx, |probe, _, cx| {
205                probe.elapsed = Some(Duration::from_secs(4));
206                cx.notify();
207            })
208            .expect("the loader window should remain open");
209        cx.run_until_parked();
210
211        // The update re-renders while the previous frame's callback is still
212        // queued, so the frame right after it can transiently report both.
213        // Drain it; the steady state is the claim.
214        next_frame(&window, cx);
215
216        // Still exactly one scheduled animation: the update neither tore the
217        // clock down (0) nor left a second one running beside it (2).
218        assert_eq!(next_frame(&window, cx), 1);
219    }
220
221    #[gpui::test]
222    fn reduced_motion_holds_the_choreographed_still_frame(cx: &mut TestAppContext) {
223        cx.update(|cx| cx.set_reduce_motion(true));
224        let window = open(None, cx);
225        assert_eq!(
226            next_frame(&window, cx),
227            0,
228            "a held sweep schedules nothing; the static diagonal gradient is the frame"
229        );
230    }
231}