Skip to main content

gpui_kit_material/components/
progress.rs

1//! M3 progress indicators: linear and circular, flat or M3 Expressive
2//! wavy, 4dp or thick, measured or indeterminate, plus icon-sized spinners.
3//! [`LinearProgress`] and [`CircularProgress`] are the full components; the
4//! free functions are shorthands kept for existing callers.
5mod circular;
6mod indicator;
7mod linear;
8mod paint;
9pub use circular::{
10    CircularProgressFrame, CircularProgressPlayback, circular_indeterminate_frame,
11    circular_progress, indeterminate_circular_progress,
12    indeterminate_circular_progress_with_playback, sample_circular_progress,
13};
14pub use indicator::{CircularProgress, circular_progress_size};
15pub use linear::{
16    LINEAR_INDETERMINATE_MS, LinearProgress, PROGRESS_THICK_THICKNESS, PROGRESS_THICKNESS,
17    ProgressShape, linear_indeterminate_lines,
18};
19
20use crate::color::ColorRole;
21use crate::icons::{Icon, IconName};
22use crate::theme::color;
23use gpui::{prelude::*, *};
24use gpui_base::Progress;
25use std::time::Duration;
26
27const SPINNER_TURN_MS: u64 = 1000;
28
29/// A small accent spinner (16px), announced as indeterminate progress
30/// named by the caller's `label`.
31pub fn spinner(id: impl Into<ElementId>, label: impl Into<SharedString>, cx: &App) -> AnyElement {
32    spin(
33        IconName::LoaderCircle.icon().size(px(16.)),
34        id,
35        label,
36        None,
37        cx,
38    )
39}
40
41/// A spinner standing in an icon column, at that column's icon size.
42pub fn icon_spinner(
43    id: impl Into<ElementId>,
44    label: impl Into<SharedString>,
45    size: Rems,
46    cx: &App,
47) -> AnyElement {
48    spin(
49        IconName::LoaderCircle.icon().size(size),
50        id,
51        label,
52        None,
53        cx,
54    )
55}
56
57/// A spinner whose caller-owned clock survives virtual unmounting.
58///
59/// Reduced motion displays a static icon without starting an unused clock.
60/// Once started, time continues during unmounting and reduced motion; disabling
61/// reduced motion resumes the current phase. Use a new handle to restart.
62pub fn spinner_with_playback(
63    id: impl Into<ElementId>,
64    label: impl Into<SharedString>,
65    playback: RepeatAnimationPlayback,
66    cx: &App,
67) -> AnyElement {
68    spin(
69        IconName::LoaderCircle.icon().size(px(16.)),
70        id,
71        label,
72        Some(playback),
73        cx,
74    )
75}
76
77/// Icon-sized variant of [`spinner_with_playback`] with the same clock policy.
78pub fn icon_spinner_with_playback(
79    id: impl Into<ElementId>,
80    label: impl Into<SharedString>,
81    size: Rems,
82    playback: RepeatAnimationPlayback,
83    cx: &App,
84) -> AnyElement {
85    spin(
86        IconName::LoaderCircle.icon().size(size),
87        id,
88        label,
89        Some(playback),
90        cx,
91    )
92}
93
94fn spin(
95    icon: Icon,
96    id: impl Into<ElementId>,
97    label: impl Into<SharedString>,
98    playback: Option<RepeatAnimationPlayback>,
99    cx: &App,
100) -> AnyElement {
101    let id = id.into();
102    let icon = icon.color(color(ColorRole::PRIMARY));
103    // The spinner is decorative motion; the progress node carries its meaning.
104    let root = Progress::new(id.clone())
105        .accessibility_label(label)
106        .indeterminate(true)
107        .flex_shrink_0()
108        .flex()
109        .items_center()
110        .justify_center();
111    if cx.reduce_motion() {
112        return root.child(icon).into_any_element();
113    }
114    // `Icon` is not `Styled`, so the animation wraps a slot that rebuilds
115    // the rotated glyph each frame.
116    root.child(
117        div()
118            .flex_shrink_0()
119            .with_animation(
120                id,
121                Animation::new(Duration::from_millis(SPINNER_TURN_MS)).repeat(),
122                move |slot, t| {
123                    #[cfg(test)]
124                    tests::record_repeat_phase(t);
125                    slot.child(icon.clone().rotate(std::f32::consts::TAU * t))
126                },
127            )
128            .when_some(playback, |animation, playback| {
129                animation.with_repeat_playback(playback)
130            })
131            .repeat_when_visible(),
132    )
133    .into_any_element()
134}
135
136/// Unmeasured work uses motion, never a fabricated completion percentage.
137///
138/// Exposes a progress-indicator role without a name; prefer
139/// [`indeterminate_progress_with_label`], which names it.
140pub fn indeterminate_progress(id: impl Into<ElementId>, _cx: &App) -> AnyElement {
141    LinearProgress::unlabelled(id).into_any_element()
142}
143
144/// [`indeterminate_progress`] named by the caller's `label` for assistive
145/// technology.
146pub fn indeterminate_progress_with_label(
147    id: impl Into<ElementId>,
148    label: impl Into<SharedString>,
149    _cx: &App,
150) -> AnyElement {
151    LinearProgress::new(id, label).into_any_element()
152}
153
154/// Indeterminate progress with a caller-owned clock; see
155/// [`spinner_with_playback`] for unmounting and reduced-motion semantics.
156pub fn indeterminate_progress_with_playback(
157    id: impl Into<ElementId>,
158    playback: RepeatAnimationPlayback,
159    _cx: &App,
160) -> AnyElement {
161    LinearProgress::unlabelled(id)
162        .playback(playback)
163        .into_any_element()
164}
165
166/// Indeterminate progress with custom thickness/color and a persistent clock.
167/// The caller-supplied `label` names the indicator for assistive technology.
168pub fn indeterminate_linear_progress_with_playback(
169    id: impl Into<ElementId>,
170    label: impl Into<SharedString>,
171    thickness: Pixels,
172    muted: bool,
173    playback: RepeatAnimationPlayback,
174    _cx: &App,
175) -> AnyElement {
176    LinearProgress::new(id, label)
177        .thickness(thickness)
178        .muted(muted)
179        .playback(playback)
180        .into_any_element()
181}
182
183/// A measured fraction as Material 3 linear progress, 4px thick.
184///
185/// Exposes a progress-indicator role and value without a name; prefer
186/// [`progress_with_label`], which names it.
187pub fn progress(
188    id: impl Into<ElementId>,
189    progress: f32,
190    _window: &mut Window,
191    _cx: &mut App,
192) -> AnyElement {
193    LinearProgress::unlabelled(id)
194        .value(Some(progress))
195        .into_any_element()
196}
197
198/// [`progress`] named by the caller's `label` for assistive technology.
199pub fn progress_with_label(
200    id: impl Into<ElementId>,
201    label: impl Into<SharedString>,
202    progress: f32,
203    _window: &mut Window,
204    _cx: &mut App,
205) -> AnyElement {
206    LinearProgress::new(id, label)
207        .value(Some(progress))
208        .into_any_element()
209}
210
211/// Material 3 linear progress: the active indicator, a 4px gap, the track
212/// and a stop dot at the track's end. Only a measured fraction draws it;
213/// `None` shows the indeterminate indicator at the same thickness. A paused
214/// or stopping process (`muted`) keeps its fraction in the outline color.
215/// The caller-supplied `label` names the indicator for assistive technology.
216pub fn linear_progress(
217    id: impl Into<ElementId>,
218    label: impl Into<SharedString>,
219    fraction: Option<f32>,
220    thickness: Pixels,
221    muted: bool,
222    _window: &mut Window,
223    _cx: &mut App,
224) -> AnyElement {
225    LinearProgress::new(id, label)
226        .value(fraction)
227        .thickness(thickness)
228        .muted(muted)
229        .into_any_element()
230}
231
232#[cfg(test)]
233mod tests {
234    use gpui::{Context, IntoElement, Render, TestAppContext, Window, div, prelude::*, px};
235    use std::time::Duration;
236
237    thread_local! {
238        static REPEAT_PHASES: std::cell::RefCell<Vec<f32>> = const { std::cell::RefCell::new(Vec::new()) };
239    }
240
241    pub(super) fn record_repeat_phase(phase: f32) {
242        REPEAT_PHASES.with_borrow_mut(|phases| phases.push(phase));
243    }
244
245    struct RepeatHarness {
246        kind: usize,
247        mounted: bool,
248        playback: gpui::RepeatAnimationPlayback,
249    }
250
251    impl Render for RepeatHarness {
252        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
253            let mut root = div().w(px(200.)).h(px(40.));
254            if self.mounted {
255                let playback = self.playback.clone();
256                root = root.child(match self.kind {
257                    0 => super::spinner_with_playback("repeat", "Loading", playback, cx),
258                    1 => super::icon_spinner_with_playback(
259                        "repeat",
260                        "Loading",
261                        gpui::rems(1.),
262                        playback,
263                        cx,
264                    ),
265                    2 => super::indeterminate_progress_with_playback("repeat", playback, cx),
266                    _ => super::indeterminate_linear_progress_with_playback(
267                        "repeat",
268                        "Loading",
269                        px(6.),
270                        true,
271                        playback,
272                        cx,
273                    ),
274                });
275            }
276            root
277        }
278    }
279
280    /// One wavy measured indicator, linear or circular.
281    struct WavyHarness {
282        value: f32,
283        circular: bool,
284    }
285
286    impl Render for WavyHarness {
287        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
288            let value = Some(self.value);
289            div().w(px(200.)).child(if self.circular {
290                super::CircularProgress::new("wavy", "Upload")
291                    .wavy()
292                    .value(value)
293                    .into_any_element()
294            } else {
295                super::LinearProgress::new("wavy", "Upload")
296                    .wavy()
297                    .value(value)
298                    .into_any_element()
299            })
300        }
301    }
302
303    /// Frames requested after drawing a settled wavy indicator at `value`.
304    fn frames_after_settling(cx: &mut TestAppContext, value: f32, circular: bool) -> usize {
305        let (_, cx) = cx.add_window_view(|_, _| WavyHarness { value, circular });
306        cx.update(|window, cx| {
307            window.refresh();
308            window.draw(cx).clear(cx);
309            // Let any first-frame motion settle, then draw once more.
310            window.simulate_next_frame(cx);
311            window.refresh();
312            window.draw(cx).clear(cx);
313            window.simulate_next_frame(cx)
314        })
315    }
316
317    #[gpui::test]
318    fn a_flat_wave_requests_no_frames(cx: &mut TestAppContext) {
319        for circular in [false, true] {
320            for value in [0.05, 0.1, 0.95, 0.97, 1.] {
321                assert_eq!(
322                    frames_after_settling(cx, value, circular),
323                    0,
324                    "circular {circular}, {value}: a flat wave kept the window busy"
325                );
326            }
327            assert!(
328                frames_after_settling(cx, 0.5, circular) > 0,
329                "a visible wave moves"
330            );
331        }
332    }
333
334    #[gpui::test]
335    fn explicit_repeat_clocks_survive_unmount_and_reduced_motion(cx: &mut TestAppContext) {
336        for kind in 0..4 {
337            let (view, cx) = cx.add_window_view(|_, _| RepeatHarness {
338                kind,
339                mounted: false,
340                playback: gpui::RepeatAnimationPlayback::default(),
341            });
342            REPEAT_PHASES.with_borrow_mut(Vec::clear);
343            // Neither unmounted nor initially reduced indicators start playback.
344            cx.executor().advance_clock(Duration::from_secs(2));
345            cx.update(|window, cx| {
346                cx.set_reduce_motion(true);
347                view.update(cx, |view, _| view.mounted = true);
348                window.refresh();
349                window.draw(cx).clear(cx);
350            });
351            assert!(REPEAT_PHASES.with_borrow(|phases| phases.is_empty()));
352            cx.executor().advance_clock(Duration::from_secs(1));
353            cx.update(|window, cx| {
354                cx.set_reduce_motion(false);
355                window.refresh();
356                window.draw(cx).clear(cx);
357            });
358            assert_eq!(
359                REPEAT_PHASES.with_borrow(|phases| *phases.last().unwrap()),
360                0.
361            );
362            cx.update(|window, cx| {
363                view.update(cx, |view, _| view.mounted = false);
364                window.refresh();
365                window.draw(cx).clear(cx);
366                window.refresh();
367                window.draw(cx).clear(cx);
368            });
369            let count = REPEAT_PHASES.with_borrow(Vec::len);
370            cx.executor().advance_clock(Duration::from_millis(350));
371            cx.update(|window, cx| {
372                window.refresh();
373                window.draw(cx).clear(cx);
374            });
375            assert_eq!(REPEAT_PHASES.with_borrow(Vec::len), count);
376            cx.update(|window, cx| {
377                view.update(cx, |view, _| view.mounted = true);
378                window.refresh();
379                window.draw(cx).clear(cx);
380            });
381            let duration = if kind < 2 { 1000. } else { 1750. };
382            let phase = REPEAT_PHASES.with_borrow(|phases| *phases.last().unwrap());
383            assert!(
384                (phase - 350. / duration).abs() < 0.001,
385                "kind {kind}: {phase}"
386            );
387            cx.update(|window, cx| {
388                cx.set_reduce_motion(true);
389                window.refresh();
390                window.draw(cx).clear(cx);
391            });
392            let count = REPEAT_PHASES.with_borrow(Vec::len);
393            cx.executor().advance_clock(Duration::from_millis(350));
394            cx.update(|window, cx| {
395                window.refresh();
396                window.draw(cx).clear(cx);
397            });
398            assert_eq!(REPEAT_PHASES.with_borrow(Vec::len), count);
399            cx.update(|window, cx| {
400                cx.set_reduce_motion(false);
401                window.refresh();
402                window.draw(cx).clear(cx);
403            });
404            let phase = REPEAT_PHASES.with_borrow(|phases| *phases.last().unwrap());
405            assert!(
406                (phase - 700. / duration).abs() < 0.001,
407                "kind {kind}: {phase}"
408            );
409            // Leave no mounted animation from this case for the next case.
410            cx.update(|window, cx| {
411                view.update(cx, |view, _| view.mounted = false);
412                window.refresh();
413                window.draw(cx).clear(cx);
414            });
415        }
416    }
417}
418
419#[cfg(test)]
420mod layout_tests {
421    use gpui::{
422        Context, ElementId, IntoElement, Render, Role, TestAppContext, Window, div, prelude::*, px,
423    };
424    use gpui_base::test_support::{ElementSnapshot, find};
425
426    /// Every shorthand and component, as an application renders them.
427    struct Rendered;
428    impl Render for Rendered {
429        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
430            div()
431                .w(px(200.))
432                .flex()
433                .flex_col()
434                .child(super::linear_progress(
435                    "measured",
436                    "Upload",
437                    Some(0.25),
438                    px(4.),
439                    false,
440                    window,
441                    cx,
442                ))
443                .child(super::linear_progress(
444                    "unmeasured",
445                    "Sync",
446                    None,
447                    px(4.),
448                    false,
449                    window,
450                    cx,
451                ))
452                .child(super::progress_with_label("named", "Copy", 0.5, window, cx))
453                .child(super::indeterminate_progress_with_label(
454                    "named-busy",
455                    "Index",
456                    cx,
457                ))
458                .child(super::progress("legacy", 0.5, window, cx))
459                .child(super::CircularProgress::new("ring", "Render").value(Some(0.75)))
460        }
461    }
462
463    fn node(window: &Window, id: &'static str) -> ElementSnapshot {
464        find(window, &[], &ElementId::from(id)).unwrap_or_else(|| panic!("{id} was not rendered"))
465    }
466
467    #[gpui::test]
468    fn rendered_progress_nodes_carry_role_and_caller_label(cx: &mut TestAppContext) {
469        cx.update(|cx| cx.set_reduce_motion(true));
470        let (_, cx) = cx.add_window_view(|_, _| Rendered);
471        cx.update(|window, cx| window.draw(cx).clear(cx));
472        cx.update(|window, _| {
473            for (id, label) in [
474                ("measured", "Upload"),
475                ("unmeasured", "Sync"),
476                ("named", "Copy"),
477                ("named-busy", "Index"),
478                ("ring", "Render"),
479            ] {
480                let node = node(window, id);
481                assert_eq!(node.role(), Some(Role::ProgressIndicator), "{id}");
482                assert_eq!(node.label(), Some(label), "{id}");
483                assert!(node.visible(), "{id}");
484            }
485            // The 4dp linear indicator fills its row.
486            let measured = node(window, "measured").bounds();
487            assert_eq!(measured.size.width, px(200.));
488            assert_eq!(measured.size.height, px(4.));
489            // The legacy shorthand stays unnamed.
490            let legacy = node(window, "legacy");
491            assert_eq!(legacy.role(), Some(Role::ProgressIndicator));
492            assert_eq!(legacy.label(), None);
493        });
494    }
495}