Skip to main content

gpui_kit_material/components/
loading_indicator.rs

1//! M3 Expressive loading indicator: a shape that morphs through Compose's
2//! seven Material shapes while it rotates, optionally inside a round
3//! container. Reference: <https://m3e.kyralabs.dev/components/loading-indicator>
4//! and the Figma kit's "Loading indicator" set (48dp container, 38dp
5//! active indicator, primary or on-primary-container on primary-container).
6//!
7//! The shapes and their morphs come from [`crate::shapes`], with Compose's
8//! `LoadingIndicator.kt` scale factor and centering; each frame is painted as
9//! one GPUI path.
10use crate::{
11    color::ColorRole,
12    motion::Spring,
13    shapes::{Morph, PathCache, RoundedPolygon, ShapeKind, morph_path, polygon_path},
14    theme::color,
15    tokens::typography::rem,
16};
17use gpui::{prelude::*, *};
18use gpui_base::Progress;
19use std::{sync::OnceLock, time::Duration};
20
21/// One of the Material shapes the loading indicator morphs between.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum LoadingShape {
24    /// The determinate indicator's empty state.
25    Circle,
26    SoftBurst,
27    Cookie9Sided,
28    Pentagon,
29    Pill,
30    Sunny,
31    Cookie4Sided,
32    Oval,
33}
34
35impl LoadingShape {
36    /// The indeterminate sequence, in the order the reference plays it.
37    pub const SEQUENCE: [Self; 7] = [
38        Self::SoftBurst,
39        Self::Cookie9Sided,
40        Self::Pentagon,
41        Self::Pill,
42        Self::Sunny,
43        Self::Cookie4Sided,
44        Self::Oval,
45    ];
46
47    /// This shape in the Material shape library.
48    pub const fn kind(self) -> ShapeKind {
49        match self {
50            Self::Circle => ShapeKind::Circle,
51            Self::SoftBurst => ShapeKind::SoftBurst,
52            Self::Cookie9Sided => ShapeKind::Cookie9Sided,
53            Self::Pentagon => ShapeKind::Pentagon,
54            Self::Pill => ShapeKind::Pill,
55            Self::Sunny => ShapeKind::Sunny,
56            Self::Cookie4Sided => ShapeKind::Cookie4Sided,
57            Self::Oval => ShapeKind::Oval,
58        }
59    }
60}
61
62/// The container (48dp), from the Figma kit and Compose's
63/// `LoadingIndicatorTokens`.
64pub const LOADING_INDICATOR_SIZE: f32 = 48.;
65/// The 38dp active-indicator box inside the container (Figma kit; Compose's
66/// `ActiveIndicatorScale`).
67const ACTIVE_INDICATOR_SCALE: f32 = 38. / LOADING_INDICATOR_SIZE;
68
69/// One morph lasts this long on the reference's spring, then the next starts.
70const MORPH_INTERVAL_MS: f32 = 650.;
71/// The morph spring (Compose: damping ratio 0.6, stiffness 200).
72const MORPH_SPRING: Spring = Spring {
73    damping: 0.6,
74    stiffness: 200.,
75};
76/// Each morph also turns the shape a quarter turn.
77const MORPH_ROTATION: f32 = 90.;
78/// Four passes through the sequence: 28 quarter turns (seven full turns)
79/// make the loop seamless. The constant rotation turns four times per loop,
80/// one turn per 4550ms (the reference's 4666ms, rounded to the loop).
81const LOOP_MORPHS: usize = 28;
82const LOOP_MS: f32 = MORPH_INTERVAL_MS * LOOP_MORPHS as f32;
83const LOOP_TURNS: f32 = 4.;
84
85/// A sampled indicator: the shapes being morphed, how far, and the turn.
86#[derive(Clone, Copy, Debug, PartialEq)]
87pub struct LoadingFrame {
88    pub from: LoadingShape,
89    pub to: LoadingShape,
90    /// 0 at `from`, 1 at `to`; the spring briefly overshoots past 1.
91    pub morph: f32,
92    pub rotation_degrees: f32,
93}
94
95impl LoadingFrame {
96    /// A shape at rest, unrotated.
97    pub fn still(shape: LoadingShape) -> Self {
98        Self {
99            from: shape,
100            to: shape,
101            morph: 0.,
102            rotation_degrees: 0.,
103        }
104    }
105
106    /// The determinate indicator: a circle at 0 that becomes the soft
107    /// burst at 1, turning half a turn on the way.
108    pub fn determinate(progress: f32) -> Self {
109        let progress = if progress.is_finite() {
110            progress.clamp(0., 1.)
111        } else {
112            0.
113        };
114        Self {
115            from: LoadingShape::Circle,
116            to: LoadingShape::SoftBurst,
117            morph: progress,
118            rotation_degrees: 180. * progress,
119        }
120    }
121}
122
123/// The indeterminate indicator `elapsed_ms` into its loop.
124pub fn loading_indicator_frame(elapsed_ms: f32) -> LoadingFrame {
125    let t = if elapsed_ms.is_finite() {
126        elapsed_ms.rem_euclid(LOOP_MS)
127    } else {
128        0.
129    };
130    let index = ((t / MORPH_INTERVAL_MS).floor() as usize).min(LOOP_MORPHS - 1);
131    let local = t - index as f32 * MORPH_INTERVAL_MS;
132    // The spring is within 0.5% of its target when the interval ends; take
133    // up that remainder linearly so the next morph starts exactly there.
134    let seconds = local / 1000.;
135    let rest = 1. - MORPH_SPRING.position(MORPH_INTERVAL_MS / 1000.);
136    let morph = MORPH_SPRING.position(seconds) + rest * local / MORPH_INTERVAL_MS;
137    let sequence = LoadingShape::SEQUENCE;
138    LoadingFrame {
139        from: sequence[index % sequence.len()],
140        to: sequence[(index + 1) % sequence.len()],
141        morph,
142        rotation_degrees: (360. * LOOP_TURNS * t / LOOP_MS
143            + MORPH_ROTATION * (index as f32 + morph))
144            .rem_euclid(360.),
145    }
146}
147
148/// The polygons' scale in the active-indicator box (Compose's
149/// `calculateScaleFactor`): a shape fills its box less than the circle it
150/// sweeps while rotating, so all of them shrink to keep every one inside.
151fn scale_factor<'a>(polygons: impl IntoIterator<Item = &'a RoundedPolygon>) -> f32 {
152    let mut factor = 1_f32;
153    for polygon in polygons {
154        let bounds = polygon.bounds(true);
155        let max = polygon.max_bounds();
156        let scale_x = (bounds[2] - bounds[0]) / (max[2] - max[0]);
157        let scale_y = (bounds[3] - bounds[1]) / (max[3] - max[1]);
158        // The larger, so a pill does not throw off the whole set.
159        factor = factor.min(scale_x.max(scale_y));
160    }
161    factor
162}
163
164/// The indeterminate set's scale.
165fn indeterminate_factor() -> f32 {
166    static FACTOR: OnceLock<f32> = OnceLock::new();
167    *FACTOR.get_or_init(|| scale_factor(LoadingShape::SEQUENCE.map(|s| s.kind().polygon())))
168}
169
170/// The determinate morph and its scale. Compose turns the circle by 360°/20
171/// for a smoother morph into the soft burst.
172fn determinate() -> &'static (Morph, f32) {
173    static DETERMINATE: OnceLock<(Morph, f32)> = OnceLock::new();
174    DETERMINATE.get_or_init(|| {
175        let circle = ShapeKind::Circle.polygon().rotated(360. / 20.).normalized();
176        let burst = ShapeKind::SoftBurst.polygon();
177        let factor = scale_factor([&circle, burst]);
178        (Morph::new(&circle, burst), factor)
179    })
180}
181
182/// The frame's outline, in a container of `container` px around `center`.
183fn frame_path(frame: LoadingFrame, center: Point<Pixels>, container: f32) -> Option<Path<Pixels>> {
184    let side = container * ACTIVE_INDICATOR_SCALE;
185    let turn = frame.rotation_degrees;
186    let (from, to) = (frame.from, frame.to);
187    if from == to {
188        let factor = if from == LoadingShape::Circle {
189            determinate().1
190        } else {
191            indeterminate_factor()
192        };
193        polygon_path(from.kind().polygon(), center, side * factor, turn)
194    } else if (from, to) == (LoadingShape::Circle, LoadingShape::SoftBurst) {
195        let (morph, factor) = determinate();
196        morph_path(morph, frame.morph, center, side * factor, turn)
197    } else {
198        let morph = from.kind().morph_to(to.kind());
199        morph_path(
200            morph,
201            frame.morph,
202            center,
203            side * indeterminate_factor(),
204            turn,
205        )
206    }
207}
208
209/// M3 Expressive loading indicator.
210///
211/// Indeterminate by default: it morphs through [`LoadingShape::SEQUENCE`]
212/// on the reference's spring while rotating. [`Self::progress`] makes it
213/// determinate (a circle growing into a soft burst). Reduced motion holds
214/// the first frame. The caller supplies the accessible label.
215#[derive(gpui::IntoElement)]
216pub struct LoadingIndicator {
217    id: ElementId,
218    label: SharedString,
219    progress: Option<f32>,
220    contained: bool,
221    size: f32,
222    shape: Option<LoadingShape>,
223    turn: f32,
224    playback: Option<RepeatAnimationPlayback>,
225}
226
227impl LoadingIndicator {
228    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
229        Self {
230            id: id.into(),
231            label: label.into(),
232            progress: None,
233            contained: false,
234            size: LOADING_INDICATOR_SIZE,
235            shape: None,
236            turn: 0.,
237            playback: None,
238        }
239    }
240
241    /// Show the round primary-container container behind the shape.
242    pub fn contained(mut self, contained: bool) -> Self {
243        self.contained = contained;
244        self
245    }
246
247    /// The container's size in design px (48 by default); the shape scales
248    /// with it.
249    pub fn size(mut self, size: f32) -> Self {
250        if size.is_finite() && size > 0. {
251            self.size = size;
252        }
253        self
254    }
255
256    /// A measured fraction (0-1), or `None` for indeterminate.
257    pub fn progress(mut self, progress: Option<f32>) -> Self {
258        self.progress = progress;
259        self
260    }
261
262    /// An extra turn of a measured indicator, in degrees: pull to refresh turns
263    /// it back past the threshold. A non-finite turn is ignored.
264    pub fn turn(mut self, degrees: f32) -> Self {
265        if degrees.is_finite() {
266            self.turn = degrees;
267        }
268        self
269    }
270
271    /// A specimen: hold one shape, without animating.
272    pub fn shape(mut self, shape: LoadingShape) -> Self {
273        self.shape = Some(shape);
274        self
275    }
276
277    /// A caller-owned clock that survives virtual unmounting. This is the
278    /// indicator's one use of the vendored engine's repeat-playback patch,
279    /// kept for callers that pass it: without it the indicator runs on GPUI's
280    /// stock repeating animation, restarting if it unmounts.
281    pub fn playback(mut self, playback: RepeatAnimationPlayback) -> Self {
282        self.playback = Some(playback);
283        self
284    }
285}
286
287/// A frame that holds still until its input changes: the path is reused while
288/// frame, size and origin are unchanged. The indicator owns the cache through
289/// keyed state.
290fn held_canvas(
291    frame: LoadingFrame,
292    ink: Rgba,
293    id: &ElementId,
294    window: &mut Window,
295    cx: &mut App,
296) -> impl IntoElement + use<> {
297    let paths = window.use_keyed_state((id.clone(), "loading-path"), cx, |_, _| {
298        PathCache::<(LoadingFrame, f32, Point<Pixels>)>::default()
299    });
300    canvas(
301        |_, _, _| {},
302        move |bounds, _, window, cx| {
303            let container = f32::from(bounds.size.width.min(bounds.size.height));
304            let center = bounds.center();
305            let path = paths.update(cx, |paths, _| {
306                paths.path((frame, container, center), || {
307                    frame_path(frame, center, container)
308                })
309            });
310            if let Some(path) = path {
311                window.paint_path(path, ink);
312            }
313        },
314    )
315    .size_full()
316}
317
318fn shape_canvas(frame: LoadingFrame, ink: Rgba) -> impl IntoElement {
319    canvas(
320        |_, _, _| {},
321        move |bounds, _, window, _| {
322            let container = f32::from(bounds.size.width.min(bounds.size.height));
323            if let Some(path) = frame_path(frame, bounds.center(), container) {
324                window.paint_path(path, ink);
325            }
326        },
327    )
328    .size_full()
329}
330
331impl RenderOnce for LoadingIndicator {
332    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
333        let ink = color(if self.contained {
334            ColorRole::ON_PRIMARY_CONTAINER
335        } else {
336            ColorRole::PRIMARY
337        });
338        let measured = self
339            .progress
340            .map(|p| if p.is_finite() { p.clamp(0., 1.) } else { 0. });
341        let root = Progress::new(self.id.clone())
342            .accessibility_label(self.label)
343            .indeterminate(measured.is_none())
344            .value(measured.unwrap_or(0.) * 100.)
345            .flex_shrink_0()
346            .size(rem(self.size))
347            .rounded_full()
348            .when(self.contained, |root| {
349                root.bg(color(ColorRole::PRIMARY_CONTAINER))
350            });
351        if let Some(shape) = self.shape {
352            let canvas = held_canvas(LoadingFrame::still(shape), ink, &self.id, window, cx);
353            return root.child(canvas);
354        }
355        if let Some(target) = measured {
356            let shown =
357                crate::motion::value((self.id.clone(), "loading-progress"), target, window, cx)
358                    .clamp(0., 1.);
359            let mut frame = LoadingFrame::determinate(shown);
360            frame.rotation_degrees += self.turn;
361            let canvas = held_canvas(frame, ink, &self.id, window, cx);
362            return root.child(canvas);
363        }
364        let animation = Animation::new(Duration::from_secs_f32(LOOP_MS / 1000.)).repeat();
365        let paint =
366            move |slot: Div, t| slot.child(shape_canvas(loading_indicator_frame(t * LOOP_MS), ink));
367        let slot = div().size_full();
368        match self.playback {
369            // GPUI's stock repeating animation: its clock, and holding the
370            // first frame under reduced motion, are the element's own.
371            None => root.child(slot.with_animation(self.id, animation, paint)),
372            Some(playback) => root.child(
373                slot.with_animation(self.id, animation, paint)
374                    .with_repeat_playback(playback)
375                    .repeat_when_visible(),
376            ),
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use core::prelude::v1::test;
385
386    #[test]
387    fn the_loop_morphs_through_seven_shapes_and_closes_seamlessly() {
388        let start = loading_indicator_frame(0.);
389        assert_eq!(start.from, LoadingShape::SoftBurst);
390        assert_eq!(start.to, LoadingShape::Cookie9Sided);
391        assert_eq!(start.morph, 0.);
392        let shapes: Vec<_> = (0..7)
393            .map(|i| loading_indicator_frame(i as f32 * MORPH_INTERVAL_MS + 1.).from)
394            .collect();
395        assert_eq!(shapes, LoadingShape::SEQUENCE);
396        // Each morph settles on its target before the next starts.
397        let settled = loading_indicator_frame(MORPH_INTERVAL_MS - 0.01);
398        assert!((settled.morph - 1.).abs() < 0.01, "{}", settled.morph);
399        // The end of the loop meets its start, shape and rotation alike.
400        let end = loading_indicator_frame(LOOP_MS - 0.01);
401        assert_eq!(end.to, LoadingShape::SoftBurst);
402        let gap = (end.rotation_degrees - 360.)
403            .abs()
404            .min(end.rotation_degrees);
405        assert!(gap < 0.1, "rotation jumps by {gap}°");
406    }
407
408    #[test]
409    fn the_spring_overshoots_and_each_morph_adds_a_quarter_turn() {
410        let peak = (0..65)
411            .map(|i| loading_indicator_frame(i as f32 * 10.).morph)
412            .fold(0., f32::max);
413        assert!(peak > 1.05 && peak < 1.2, "{peak}");
414        let a = loading_indicator_frame(MORPH_INTERVAL_MS - 0.01).rotation_degrees;
415        let b = loading_indicator_frame(0.).rotation_degrees;
416        let constant = 360. * LOOP_TURNS * MORPH_INTERVAL_MS / LOOP_MS;
417        assert!((a - b - 90. - constant).abs() < 0.5, "{a} {b}");
418    }
419
420    const SAMPLES: usize = 120;
421
422    /// The outline's distance from the center along equal angles, normalized
423    /// so the farthest point is 1.
424    fn radii(shape: LoadingShape) -> [f32; SAMPLES] {
425        // Compose's normalization centers the control-point box, so a shape's
426        // own center can sit slightly off the box's (0.504 for the circle).
427        let (cx, cy) = shape.kind().polygon().center();
428        let mut outline = Vec::new();
429        for cubic in shape.kind().polygon().cubics() {
430            outline.extend((0..24).map(|i| cubic.point_at(i as f32 / 24.)));
431        }
432        let mut radii = [0_f32; SAMPLES];
433        for (i, radius) in radii.iter_mut().enumerate() {
434            let angle = i as f32 * std::f32::consts::TAU / SAMPLES as f32;
435            let (dx, dy) = (angle.cos(), angle.sin());
436            for (j, &(px, py)) in outline.iter().enumerate() {
437                let (qx, qy) = outline[(j + 1) % outline.len()];
438                let (px, py, ex, ey) = (px - cx, py - cy, qx - px, qy - py);
439                // Solve t·d = p + s·e for the ray parameter t and edge parameter s.
440                let det = ex * dy - ey * dx;
441                if det.abs() < 1e-9 {
442                    continue;
443                }
444                let t = (ex * py - ey * px) / det;
445                let s = (dx * py - dy * px) / det;
446                if (-1e-5..=1. + 1e-5).contains(&s) && t > *radius {
447                    *radius = t;
448                }
449            }
450        }
451        let max = radii.iter().copied().fold(0., f32::max).max(1e-6);
452        radii.map(|radius| radius / max)
453    }
454
455    #[test]
456    fn shapes_are_normalized_star_shaped_and_distinct() {
457        let circle = radii(LoadingShape::Circle);
458        assert!(circle.iter().all(|r| (r - 1.).abs() < 0.01));
459        for shape in LoadingShape::SEQUENCE {
460            let r = radii(shape);
461            let max = r.iter().copied().fold(0., f32::max);
462            let min = r.iter().copied().fold(1., f32::min);
463            assert!((max - 1.).abs() < 1e-4, "{shape:?}");
464            assert!(min > 0.3 && min < 0.97, "{shape:?}: {min}");
465        }
466        // The cookie's four lobes repeat every quarter turn; the pill does not.
467        let cookie = radii(LoadingShape::Cookie4Sided);
468        let pill = radii(LoadingShape::Pill);
469        let quarter = SAMPLES / 4;
470        assert!((0..SAMPLES).all(|i| (cookie[i] - cookie[(i + quarter) % SAMPLES]).abs() < 0.02));
471        assert!((0..SAMPLES).any(|i| (pill[i] - pill[(i + quarter) % SAMPLES]).abs() > 0.1));
472    }
473
474    #[test]
475    fn determinate_grows_a_circle_into_the_soft_burst() {
476        assert_eq!(LoadingFrame::determinate(0.).from, LoadingShape::Circle);
477        assert_eq!(LoadingFrame::determinate(f32::NAN).morph, 0.);
478        let full = LoadingFrame::determinate(2.);
479        assert_eq!((full.to, full.morph), (LoadingShape::SoftBurst, 1.));
480        assert_eq!(full.rotation_degrees, 180.);
481        assert!(frame_path(full, point(px(24.), px(24.)), 48.).is_some());
482    }
483
484    #[test]
485    fn every_morph_of_the_sequence_stays_in_the_active_box_while_it_turns() {
486        // Compose's scale keeps the turning shape inside the 38dp box, up to
487        // the outline's corners that the longer of its two axes lets out.
488        let half = 48. * ACTIVE_INDICATOR_SCALE / 2. * indeterminate_factor();
489        let sequence = LoadingShape::SEQUENCE;
490        for (i, from) in sequence.into_iter().enumerate() {
491            let to = sequence[(i + 1) % sequence.len()];
492            let morph = from.kind().morph_to(to.kind());
493            for cubic in morph.as_cubics(0.5) {
494                let (x, y) = cubic.anchor0();
495                let reach = (x - 0.5).hypot(y - 0.5) * 2. * half;
496                assert!(reach < 48. / 2., "{from:?} -> {to:?}: {reach}");
497            }
498        }
499        // The soft burst, the indicator's face, is the size the kit draws
500        // (33-34dp across at rest, Figma "Loading indicator").
501        let burst = ShapeKind::SoftBurst.polygon().bounds(false);
502        let across = (burst[2] - burst[0]) * half * 2.;
503        assert!((30. ..=38.).contains(&across), "{across}");
504    }
505
506    #[test]
507    fn the_determinate_morph_is_the_rotated_circle_into_the_soft_burst() {
508        let (morph, factor) = determinate();
509        assert!((0.5..=1.).contains(factor), "{factor}");
510        let start = morph.as_cubics(0.);
511        // At 0 it is a circle: every anchor is the same distance out.
512        for cubic in &start {
513            let (x, y) = cubic.anchor0();
514            assert!(((x - 0.5).hypot(y - 0.5) - 0.5).abs() < 0.02, "{x} {y}");
515        }
516    }
517
518    #[test]
519    fn shapes_map_onto_the_library() {
520        assert_eq!(LoadingShape::Pill.kind(), ShapeKind::Pill);
521        for shape in LoadingShape::SEQUENCE {
522            assert_ne!(shape.kind(), ShapeKind::Circle);
523        }
524    }
525
526    struct Harness;
527    impl Render for Harness {
528        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
529            window.set_rem_size(px(14.));
530            div()
531                .flex()
532                .items_start()
533                .child(
534                    div()
535                        .debug_selector(|| "loading".into())
536                        .child(LoadingIndicator::new("li", "Loading").contained(true)),
537                )
538                .child(LoadingIndicator::new("li-measured", "Uploading").progress(Some(0.4)))
539                .child(
540                    div()
541                        .debug_selector(|| "loading-large".into())
542                        .child(LoadingIndicator::new("li-large", "Loading").size(96.)),
543                )
544        }
545    }
546
547    #[gpui::test]
548    fn the_indicator_is_a_48dp_progress_node(cx: &mut gpui::TestAppContext) {
549        cx.update(|cx| cx.set_reduce_motion(true));
550        let (_, cx) = cx.add_window_view(|_, _| Harness);
551        cx.update(|window, cx| {
552            window.refresh();
553            window.draw(cx).clear(cx);
554        });
555        let bounds = cx.debug_bounds("loading").unwrap();
556        assert_eq!(bounds.size, size(px(48.), px(48.)));
557        let large = cx.debug_bounds("loading-large").unwrap();
558        assert_eq!(large.size, size(px(96.), px(96.)), "a sized container");
559        // The rendered LoadingIndicator itself carries the progress role and
560        // the caller's label, indeterminate or measured.
561        cx.update(|window, _| {
562            for (id, label) in [("li", "Loading"), ("li-measured", "Uploading")] {
563                let node = gpui_base::test_support::find(window, &[], &ElementId::from(id))
564                    .unwrap_or_else(|| panic!("{id} was not rendered"));
565                assert_eq!(node.role(), Some(Role::ProgressIndicator), "{id}");
566                assert_eq!(node.label(), Some(label), "{id}");
567                assert_eq!(node.bounds().size, size(px(48.), px(48.)), "{id}");
568            }
569        });
570    }
571}