Skip to main content

gpui_kit_material/components/progress/
indicator.rs

1//! M3 Expressive circular progress: flat or wavy, 4dp or thick 8dp,
2//! measured or indeterminate, as a standalone indicator. The Figma kit's
3//! boxes: 40dp flat, 44dp thick flat, 48dp wavy, 52dp thick wavy.
4//! Reference: <https://m3e.kyralabs.dev/components/progress/>.
5use super::{
6    circular::{CircularProgressFrame, circular_indeterminate_frame},
7    linear::{
8        PROGRESS_THICK_THICKNESS, PROGRESS_THICKNESS, ProgressShape, progress_colors,
9        wave_amplitude,
10    },
11    paint::{dot, stroke_polyline},
12};
13use gpui::{prelude::*, *};
14use gpui_base::Progress;
15use std::{f32::consts::TAU, time::Duration};
16
17/// The wavy circle's peak offset from its mean radius.
18const CIRCULAR_WAVE_AMPLITUDE: f32 = 2.;
19/// The wave's length along the circle.
20const CIRCULAR_WAVELENGTH: f32 = 15.;
21/// Clear space between the active arc's caps and the track.
22const TRACK_GAP: f32 = 4.;
23const INDETERMINATE_MS: f32 = 6000.;
24
25/// The indicator box for a shape and thickness, in dp.
26pub fn circular_progress_size(shape: ProgressShape, thickness: Pixels) -> f32 {
27    let thick = f32::from(thickness) - 4.;
28    match shape {
29        ProgressShape::Flat => 40. + thick,
30        ProgressShape::Wavy => 48. + thick,
31    }
32}
33
34/// M3 Expressive circular progress indicator.
35///
36/// `value` makes it determinate (the arc starts at twelve o'clock with a
37/// track and gap after it); `None` plays the reference's six-second growing
38/// arc, whose track is hidden unless [`Self::show_track`] asks for it.
39/// Reduced motion holds still. The caller supplies the accessible label.
40#[derive(gpui::IntoElement)]
41pub struct CircularProgress {
42    id: ElementId,
43    label: SharedString,
44    value: Option<f32>,
45    thickness: Pixels,
46    shape: ProgressShape,
47    show_track: Option<bool>,
48    size: Option<f32>,
49    disabled: bool,
50    playback: Option<RepeatAnimationPlayback>,
51}
52
53impl CircularProgress {
54    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
55        Self {
56            id: id.into(),
57            label: label.into(),
58            value: None,
59            thickness: PROGRESS_THICKNESS,
60            shape: ProgressShape::Flat,
61            show_track: None,
62            size: None,
63            disabled: false,
64            playback: None,
65        }
66    }
67
68    pub fn value(mut self, value: Option<f32>) -> Self {
69        self.value = value;
70        self
71    }
72
73    pub fn thick(mut self) -> Self {
74        self.thickness = PROGRESS_THICK_THICKNESS;
75        self
76    }
77
78    /// The track and indicator thickness (4dp by default).
79    pub fn thickness(mut self, thickness: Pixels) -> Self {
80        self.thickness = thickness;
81        self
82    }
83
84    /// The indicator box in design px, overriding the kit's size for the
85    /// shape and thickness ([`circular_progress_size`]).
86    pub fn size(mut self, size: f32) -> Self {
87        self.size = Some(size);
88        self
89    }
90
91    /// A disabled indicator paints in on-surface at 38% over a 12% track.
92    pub fn disabled(mut self, disabled: bool) -> Self {
93        self.disabled = disabled;
94        self
95    }
96
97    pub fn shape(mut self, shape: ProgressShape) -> Self {
98        self.shape = shape;
99        self
100    }
101
102    pub fn wavy(self) -> Self {
103        self.shape(ProgressShape::Wavy)
104    }
105
106    /// Whether the track shows (default: only while determinate).
107    pub fn show_track(mut self, show: bool) -> Self {
108        self.show_track = Some(show);
109        self
110    }
111
112    /// A caller-owned clock that survives virtual unmounting.
113    pub fn playback(mut self, playback: RepeatAnimationPlayback) -> Self {
114        self.playback = Some(playback);
115        self
116    }
117}
118
119#[derive(Clone, Copy, Debug)]
120struct RingPaint {
121    thickness: f32,
122    amplitude: f32,
123    /// The wave's phase in fractions of a wavelength.
124    phase: f32,
125    show_track: bool,
126    ink: Rgba,
127    track: Rgba,
128}
129
130/// The mean radius of the ring in a `size` box.
131fn ring_radius(size: f32, thickness: f32, wavy: bool) -> f32 {
132    size / 2. - thickness / 2. - if wavy { CIRCULAR_WAVE_AMPLITUDE } else { 0. }
133}
134
135fn paint_ring(
136    bounds: Bounds<Pixels>,
137    frame: CircularProgressFrame,
138    paint: RingPaint,
139    wavy: bool,
140    window: &mut Window,
141) {
142    let size = f32::from(bounds.size.width.min(bounds.size.height));
143    let center = bounds.center();
144    let t = paint.thickness;
145    let radius = ring_radius(size, t, wavy).max(1.);
146    let start = frame.rotation_degrees.to_radians();
147    let sweep = frame.sweep.clamp(0., 1.) * TAU;
148    // The angle between cap centers that leaves TRACK_GAP clear.
149    let gap = if sweep > 0. {
150        ((TRACK_GAP + t) / radius).min(sweep)
151    } else {
152        0.
153    };
154    let at = |angle: f32, r: f32| {
155        point(
156            center.x + px(r * angle.cos()),
157            center.y + px(r * angle.sin()),
158        )
159    };
160    let arc = |from: f32, length: f32, wave: f32| -> Vec<Point<Pixels>> {
161        let steps = ((length * radius / 2.).ceil() as usize).max(2);
162        let waves = (TAU * radius / CIRCULAR_WAVELENGTH).round().max(1.);
163        (0..=steps)
164            .map(|i| {
165                let angle = from + length * i as f32 / steps as f32;
166                let r = radius + wave * (waves * angle - TAU * paint.phase).sin();
167                at(angle, r)
168            })
169            .collect()
170    };
171    if paint.show_track {
172        let length = TAU - sweep - 2. * gap;
173        if sweep <= 0. {
174            stroke_polyline(window, &arc(start, TAU, 0.), t, paint.track);
175        } else if length > 0. {
176            let points = arc(start + sweep + gap, length, 0.);
177            stroke_polyline(window, &points, t, paint.track);
178            dot(window, points[0], t, paint.track);
179            dot(window, *points.last().unwrap(), t, paint.track);
180        }
181    }
182    if sweep > 0. {
183        let points = arc(start, sweep, paint.amplitude);
184        stroke_polyline(window, &points, t, paint.ink);
185        dot(window, points[0], t, paint.ink);
186        dot(window, *points.last().unwrap(), t, paint.ink);
187    }
188}
189
190fn ring_canvas(frame: CircularProgressFrame, paint: RingPaint, wavy: bool) -> impl IntoElement {
191    canvas(
192        |_, _, _| {},
193        move |bounds, _, window, _| paint_ring(bounds, frame, paint, wavy, window),
194    )
195    .size_full()
196}
197
198impl RenderOnce for CircularProgress {
199    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
200        let wavy = self.shape == ProgressShape::Wavy;
201        let box_size = self
202            .size
203            .filter(|size| size.is_finite() && *size > 0.)
204            .unwrap_or_else(|| circular_progress_size(self.shape, self.thickness));
205        let (ink, track) = progress_colors(false, self.disabled);
206        let target = self
207            .value
208            .map(|v| if v.is_finite() { v.clamp(0., 1.) } else { 0. });
209        let mut paint = RingPaint {
210            thickness: f32::from(self.thickness),
211            amplitude: 0.,
212            phase: 0.,
213            show_track: self.show_track.unwrap_or(target.is_some()),
214            ink,
215            track,
216        };
217        let root = Progress::new(self.id.clone())
218            .accessibility_label(self.label)
219            .indeterminate(target.is_none())
220            .value(target.unwrap_or(0.) * 100.)
221            .flex_shrink_0()
222            .size(px(box_size));
223        let (id, playback) = (self.id, self.playback);
224        match target {
225            Some(target) => {
226                let amount = crate::motion::value(id.clone(), target, window, cx).clamp(0., 1.);
227                let frame = CircularProgressFrame::determinate(amount);
228                if !wavy {
229                    return root.child(ring_canvas(frame, paint, false));
230                }
231                paint.amplitude = crate::motion::value(
232                    (id.clone(), "wave-amplitude"),
233                    wave_amplitude(target) / super::linear::WAVE_AMPLITUDE
234                        * CIRCULAR_WAVE_AMPLITUDE,
235                    window,
236                    cx,
237                )
238                .max(0.);
239                if paint.amplitude <= super::linear::WAVE_VISIBLE {
240                    // A flat wave has no phase to show: no repeating frames.
241                    return root.child(div().size_full().child(ring_canvas(frame, paint, true)));
242                }
243                root.child(
244                    div()
245                        .size_full()
246                        .with_animation(
247                            id,
248                            Animation::new(Duration::from_secs(1)).repeat(),
249                            move |slot, t| {
250                                slot.child(ring_canvas(
251                                    frame,
252                                    RingPaint { phase: t, ..paint },
253                                    true,
254                                ))
255                            },
256                        )
257                        .when_some(playback, |a, p| a.with_repeat_playback(p))
258                        .repeat_when_visible(),
259                )
260            }
261            None => {
262                if wavy {
263                    paint.amplitude = CIRCULAR_WAVE_AMPLITUDE;
264                }
265                if cx.reduce_motion() {
266                    return root.child(ring_canvas(circular_indeterminate_frame(0.), paint, wavy));
267                }
268                root.child(
269                    div()
270                        .size_full()
271                        .with_animation(
272                            id,
273                            Animation::new(Duration::from_secs_f32(INDETERMINATE_MS / 1000.))
274                                .repeat(),
275                            move |slot, t| {
276                                let elapsed = t * INDETERMINATE_MS;
277                                slot.child(ring_canvas(
278                                    circular_indeterminate_frame(elapsed),
279                                    // Six wavelengths per loop keep the wave seamless.
280                                    RingPaint {
281                                        phase: t * 6.,
282                                        ..paint
283                                    },
284                                    wavy,
285                                ))
286                            },
287                        )
288                        .when_some(playback, |a, p| a.with_repeat_playback(p))
289                        .repeat_when_visible(),
290                )
291            }
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use core::prelude::v1::test;
300
301    #[test]
302    fn boxes_follow_the_kit_sizes() {
303        let sizes = [
304            (ProgressShape::Flat, PROGRESS_THICKNESS, 40.),
305            (ProgressShape::Flat, PROGRESS_THICK_THICKNESS, 44.),
306            (ProgressShape::Wavy, PROGRESS_THICKNESS, 48.),
307            (ProgressShape::Wavy, PROGRESS_THICK_THICKNESS, 52.),
308        ];
309        for (shape, thickness, size) in sizes {
310            assert_eq!(circular_progress_size(shape, thickness), size);
311        }
312        // The flat ring's stroke touches its box; the wave's peaks do too.
313        assert_eq!(ring_radius(40., 4., false), 18.);
314        assert_eq!(
315            ring_radius(48., 4., true) + CIRCULAR_WAVE_AMPLITUDE + 2.,
316            24.
317        );
318    }
319}