Skip to main content

gpui_kit_material/components/progress/
linear.rs

1//! M3 Expressive linear progress: flat or wavy, 4dp or thick 8dp, measured
2//! or indeterminate. Reference: <https://m3e.kyralabs.dev/components/progress/>
3//! and the Figma kit's linear progress sets.
4use super::paint::{dot, rounded_bar, stroke_polyline};
5use crate::{color::ColorRole, theme::color};
6use gpui::{prelude::*, *};
7use gpui_base::{Easing, Progress};
8use std::time::Duration;
9
10/// The default and thick track/indicator thickness.
11pub const PROGRESS_THICKNESS: Pixels = px(4.);
12pub const PROGRESS_THICK_THICKNESS: Pixels = px(8.);
13/// Space between the active indicator's end and the track (track-active-space).
14const TRACK_GAP: f32 = 4.;
15/// The stop indicator at the track's end.
16const STOP_SIZE: f32 = 4.;
17/// The wave's peak offset from the centerline: a 10dp wave height for the
18/// 4dp indicator and 14dp for the thick one.
19pub(super) const WAVE_AMPLITUDE: f32 = 3.;
20/// Determinate and indeterminate wavelengths.
21const DETERMINATE_WAVELENGTH: f32 = 40.;
22const INDETERMINATE_WAVELENGTH: f32 = 20.;
23/// The indeterminate loop.
24pub const LINEAR_INDETERMINATE_MS: f32 = 1750.;
25/// A wave travels one wavelength per second.
26const WAVE_PERIOD: Duration = Duration::from_secs(1);
27
28/// The shape of a progress indicator's active part.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum ProgressShape {
31    #[default]
32    Flat,
33    /// M3 Expressive: the active indicator is a travelling wave.
34    Wavy,
35}
36
37/// The two indeterminate lines `elapsed_ms` into the loop, each as
38/// (tail, head) fractions of the track. A line is visible while its head
39/// leads its tail. Compose's keyframes: the first line's head runs over
40/// 0-1000ms and tail over 250-1250ms; the second's head over 650-1500ms and
41/// tail over 900-1750ms, each on its own cubic easing.
42pub fn linear_indeterminate_lines(elapsed_ms: f32) -> [(f32, f32); 2] {
43    let t = if elapsed_ms.is_finite() {
44        elapsed_ms.rem_euclid(LINEAR_INDETERMINATE_MS)
45    } else {
46        0.
47    };
48    let key = |delay: f32, duration: f32, (x1, y1, x2, y2): (f32, f32, f32, f32)| {
49        let progress = ((t - delay) / duration).clamp(0., 1.);
50        Easing::cubic_bezier(x1, y1, x2, y2)
51            .map(|easing| easing.sample(progress))
52            .unwrap_or(progress)
53    };
54    [
55        (
56            key(250., 1000., (0.4, 0., 1., 1.)),
57            key(0., 1000., (0.2, 0., 0.8, 1.)),
58        ),
59        (
60            key(900., 850., (0.1, 0., 0.45, 1.)),
61            key(650., 850., (0., 0., 0.65, 1.)),
62        ),
63    ]
64}
65
66/// M3 Expressive linear progress indicator.
67///
68/// `value` makes it determinate: the active indicator, a 4dp gap, the track
69/// and the stop dot at its end; `None` plays the two-line indeterminate
70/// motion. A wavy indicator flattens near empty and full (at or below 10%,
71/// at or above 95%). Reduced motion holds the wave still and shows one
72/// fixed indeterminate frame. The caller supplies the accessible label.
73#[derive(gpui::IntoElement)]
74pub struct LinearProgress {
75    id: ElementId,
76    label: Option<SharedString>,
77    value: Option<f32>,
78    thickness: Pixels,
79    shape: ProgressShape,
80    muted: bool,
81    disabled: bool,
82    stop_indicator: bool,
83    buffer: Option<f32>,
84    playback: Option<RepeatAnimationPlayback>,
85}
86
87/// Disabled indicators: on-surface at 38%, the track at 12%.
88pub(super) const DISABLED_INK: f32 = 0.38;
89pub(super) const DISABLED_TRACK: f32 = 0.12;
90
91/// The (indicator, track) colors.
92pub(super) fn progress_colors(muted: bool, disabled: bool) -> (Rgba, Rgba) {
93    if disabled {
94        let ink = color(ColorRole::ON_SURFACE);
95        return (ink.opacity(DISABLED_INK), ink.opacity(DISABLED_TRACK));
96    }
97    (
98        color(if muted {
99            ColorRole::OUTLINE
100        } else {
101            ColorRole::PRIMARY
102        }),
103        color(ColorRole::SECONDARY_CONTAINER),
104    )
105}
106
107impl LinearProgress {
108    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
109        Self::unlabelled(id).label(label)
110    }
111
112    pub(super) fn unlabelled(id: impl Into<ElementId>) -> Self {
113        Self {
114            id: id.into(),
115            label: None,
116            value: None,
117            thickness: PROGRESS_THICKNESS,
118            shape: ProgressShape::Flat,
119            muted: false,
120            disabled: false,
121            stop_indicator: true,
122            buffer: None,
123            playback: None,
124        }
125    }
126
127    pub(super) fn label(mut self, label: impl Into<SharedString>) -> Self {
128        self.label = Some(label.into());
129        self
130    }
131
132    /// A measured fraction (0-1), or `None` for indeterminate.
133    pub fn value(mut self, value: Option<f32>) -> Self {
134        self.value = value;
135        self
136    }
137
138    /// The track and indicator thickness (4dp by default).
139    pub fn thickness(mut self, thickness: Pixels) -> Self {
140        self.thickness = thickness;
141        self
142    }
143
144    /// The 8dp thick indicator.
145    pub fn thick(self) -> Self {
146        self.thickness(PROGRESS_THICK_THICKNESS)
147    }
148
149    pub fn shape(mut self, shape: ProgressShape) -> Self {
150        self.shape = shape;
151        self
152    }
153
154    pub fn wavy(self) -> Self {
155        self.shape(ProgressShape::Wavy)
156    }
157
158    /// A paused or stopping process keeps its fraction in the outline color.
159    pub fn muted(mut self, muted: bool) -> Self {
160        self.muted = muted;
161        self
162    }
163
164    /// A disabled indicator paints in on-surface at 38% over a 12% track.
165    pub fn disabled(mut self, disabled: bool) -> Self {
166        self.disabled = disabled;
167        self
168    }
169
170    /// Whether a determinate indicator shows the stop dot at the track's
171    /// end (default true; M3 requires it when the track's contrast is
172    /// below 3:1).
173    pub fn stop_indicator(mut self, show: bool) -> Self {
174        self.stop_indicator = show;
175        self
176    }
177
178    /// A determinate indicator's buffer (0-1): the track ends there, as
179    /// content loads ahead of playback. Never less than the value.
180    pub fn buffer(mut self, buffer: Option<f32>) -> Self {
181        self.buffer = buffer;
182        self
183    }
184
185    /// A caller-owned clock that survives virtual unmounting.
186    pub fn playback(mut self, playback: RepeatAnimationPlayback) -> Self {
187        self.playback = Some(playback);
188        self
189    }
190}
191
192/// Everything one painted frame needs.
193#[derive(Clone, Copy, Debug)]
194struct BarPaint {
195    thickness: f32,
196    amplitude: f32,
197    wavelength: f32,
198    /// The wave's phase, in fractions of a wavelength.
199    phase: f32,
200    ink: Rgba,
201    track: Rgba,
202    stop: bool,
203    /// Where a determinate track ends, as a fraction.
204    buffer: f32,
205}
206
207/// The track pieces up to `end` px.
208pub(super) fn clip_pieces(pieces: Vec<(f32, f32)>, end: f32) -> Vec<(f32, f32)> {
209    pieces
210        .into_iter()
211        .filter_map(|(a, b)| {
212            let b = b.min(end);
213            (b - a > 0.5).then_some((a, b))
214        })
215        .collect()
216}
217
218/// The active intervals of one frame, in fractions of the track.
219#[derive(Clone, Copy, Debug)]
220enum Active {
221    Measured(f32),
222    Lines([(f32, f32); 2]),
223}
224
225/// The track pieces left between the active intervals (px), each kept
226/// `TRACK_GAP` clear of an adjacent active interval.
227pub(super) fn track_pieces(width: f32, active: &[(f32, f32)]) -> Vec<(f32, f32)> {
228    let mut active: Vec<(f32, f32)> = active.iter().copied().filter(|(a, b)| b > a).collect();
229    active.sort_by(|a, b| a.0.total_cmp(&b.0));
230    let mut pieces = Vec::new();
231    let mut start = 0.;
232    let mut start_gap = 0.;
233    for (a, b) in active {
234        let end = a - TRACK_GAP.min(b - a);
235        if end - (start + start_gap) > 0.5 {
236            pieces.push((start + start_gap, end));
237        }
238        start = start.max(b);
239        start_gap = TRACK_GAP.min(b - a);
240    }
241    if width - (start + start_gap) > 0.5 {
242        pieces.push((start + start_gap, width));
243    }
244    pieces
245}
246
247fn paint_bar(bounds: Bounds<Pixels>, active: Active, paint: BarPaint, window: &mut Window) {
248    let width = f32::from(bounds.size.width);
249    let x0 = f32::from(bounds.origin.x);
250    let cy = f32::from(bounds.center().y);
251    let t = paint.thickness;
252    let intervals: Vec<(f32, f32)> = match active {
253        Active::Measured(amount) => vec![(0., width * amount)],
254        Active::Lines(lines) => lines
255            .iter()
256            .map(|&(tail, head)| (width * tail, width * head))
257            .collect(),
258    };
259    for (a, b) in clip_pieces(track_pieces(width, &intervals), width * paint.buffer) {
260        rounded_bar(window, x0 + a, x0 + b, cy, t, paint.track);
261    }
262    if let Active::Measured(amount) = active
263        && paint.stop
264        && amount < 0.995
265    {
266        // The stop dot sits centered in the track's rounded end.
267        let size = STOP_SIZE.min(t);
268        dot(
269            window,
270            point(px(x0 + width - t / 2.), px(cy)),
271            size,
272            paint.ink,
273        );
274    }
275    for (a, b) in intervals {
276        if b - a <= 0.5 {
277            continue;
278        }
279        if paint.amplitude <= 0.01 {
280            rounded_bar(window, x0 + a, x0 + b, cy, t, paint.ink);
281            continue;
282        }
283        // The wave's centerline, inset by the round caps.
284        let (from, to) = (a + t / 2., b - t / 2.);
285        if to <= from {
286            dot(
287                window,
288                point(px(x0 + (a + b) / 2.), px(cy)),
289                t.min(b - a),
290                paint.ink,
291            );
292            continue;
293        }
294        let k = std::f32::consts::TAU / paint.wavelength;
295        let y = |x: f32| cy + paint.amplitude * (k * x - std::f32::consts::TAU * paint.phase).sin();
296        let steps = ((to - from) / 2.).ceil().max(1.) as usize;
297        let points: Vec<Point<Pixels>> = (0..=steps)
298            .map(|i| {
299                let x = from + (to - from) * i as f32 / steps as f32;
300                point(px(x0 + x), px(y(x)))
301            })
302            .collect();
303        stroke_polyline(window, &points, t, paint.ink);
304        dot(window, points[0], t, paint.ink);
305        dot(window, points[steps], t, paint.ink);
306    }
307}
308
309fn bar_canvas(active: Active, paint: BarPaint) -> impl IntoElement {
310    canvas(
311        |_, _, _| {},
312        move |bounds, _, window, _| paint_bar(bounds, active, paint, window),
313    )
314    .size_full()
315}
316
317/// Below this amplitude (dp) a determinate wave is flat to the eye, so its
318/// phase stops repeating and the window can idle.
319pub(super) const WAVE_VISIBLE: f32 = 0.01;
320
321/// Determinate amplitude: flat at or below 10% and at or above 95%.
322pub(super) fn wave_amplitude(amount: f32) -> f32 {
323    if amount > 0.1 && amount < 0.95 {
324        WAVE_AMPLITUDE
325    } else {
326        0.
327    }
328}
329
330impl RenderOnce for LinearProgress {
331    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
332        let wavy = self.shape == ProgressShape::Wavy;
333        let thickness = f32::from(self.thickness).max(1.);
334        let height = if wavy {
335            thickness + 2. * WAVE_AMPLITUDE
336        } else {
337            thickness
338        };
339        let target = self
340            .value
341            .map(|v| if v.is_finite() { v.clamp(0., 1.) } else { 0. });
342        let (ink, track) = progress_colors(self.muted, self.disabled);
343        let buffer = match (target, self.buffer) {
344            (Some(target), Some(buffer)) if buffer.is_finite() => buffer.clamp(target, 1.),
345            _ => 1.,
346        };
347        let mut paint = BarPaint {
348            thickness,
349            amplitude: 0.,
350            wavelength: DETERMINATE_WAVELENGTH,
351            phase: 0.,
352            ink,
353            track,
354            stop: self.stop_indicator,
355            buffer,
356        };
357        let root = Progress::new(self.id.clone())
358            .when_some(self.label, |p, label| p.accessibility_label(label))
359            .indeterminate(target.is_none())
360            .value(target.unwrap_or(0.) * 100.)
361            .w_full()
362            .h(px(height));
363        let id = self.id;
364        let playback = self.playback;
365        match target {
366            Some(target) => {
367                // Spatial overshoot must not extend a fraction outside its track.
368                let amount = crate::motion::value(id.clone(), target, window, cx).clamp(0., 1.);
369                if !wavy {
370                    return root.child(bar_canvas(Active::Measured(amount), paint));
371                }
372                paint.amplitude = crate::motion::value(
373                    (id.clone(), "wave-amplitude"),
374                    wave_amplitude(target),
375                    window,
376                    cx,
377                )
378                .max(0.);
379                if paint.amplitude <= WAVE_VISIBLE {
380                    // A flat wave has no phase to show: no repeating frames.
381                    return root.child(
382                        div()
383                            .size_full()
384                            .child(bar_canvas(Active::Measured(amount), paint)),
385                    );
386                }
387                root.child(
388                    div()
389                        .size_full()
390                        .with_animation(id, Animation::new(WAVE_PERIOD).repeat(), move |slot, t| {
391                            slot.child(bar_canvas(
392                                Active::Measured(amount),
393                                BarPaint { phase: t, ..paint },
394                            ))
395                        })
396                        .when_some(playback, |a, p| a.with_repeat_playback(p))
397                        .repeat_when_visible(),
398                )
399            }
400            None => {
401                if wavy {
402                    paint.amplitude = WAVE_AMPLITUDE;
403                    paint.wavelength = INDETERMINATE_WAVELENGTH;
404                }
405                if cx.reduce_motion() {
406                    // One representative frame: the first line mid-track.
407                    let lines = linear_indeterminate_lines(600.);
408                    return root.child(bar_canvas(Active::Lines(lines), paint));
409                }
410                let loop_ms = LINEAR_INDETERMINATE_MS;
411                root.child(
412                    div()
413                        .size_full()
414                        .with_animation(
415                            id,
416                            Animation::new(Duration::from_secs_f32(loop_ms / 1000.)).repeat(),
417                            move |slot, t| {
418                                #[cfg(test)]
419                                super::tests::record_repeat_phase(t);
420                                let elapsed = t * loop_ms;
421                                slot.child(bar_canvas(
422                                    Active::Lines(linear_indeterminate_lines(elapsed)),
423                                    BarPaint {
424                                        phase: elapsed / 1000.,
425                                        ..paint
426                                    },
427                                ))
428                            },
429                        )
430                        .when_some(playback, |a, p| a.with_repeat_playback(p))
431                        .repeat_when_visible(),
432                )
433            }
434        }
435    }
436}
437
438#[cfg(test)]
439mod linear_tests {
440    use super::*;
441    use core::prelude::v1::test;
442
443    #[test]
444    fn two_lines_follow_the_reference_keyframes() {
445        let at = linear_indeterminate_lines;
446        assert_eq!(at(0.), [(0., 0.), (0., 0.)]);
447        // The first line's head leads; its tail starts at 250ms.
448        let [(tail, head), second] = at(250.);
449        assert_eq!(tail, 0.);
450        assert!(head > 0.1 && head < 0.5, "{head}");
451        assert_eq!(second, (0., 0.));
452        // The second line starts at 650ms and ends with the loop.
453        assert!(at(800.)[1].1 > 0.);
454        assert_eq!(at(1250.)[0], (1., 1.));
455        let end = at(LINEAR_INDETERMINATE_MS - 0.01)[1];
456        assert!(end.1 - end.0 < 0.001, "{end:?}");
457        for ms in (0..1750).step_by(25) {
458            for (tail, head) in at(ms as f32) {
459                assert!((0. ..=1.).contains(&tail) && (0. ..=1.).contains(&head));
460                assert!(head >= tail - 1e-6, "{ms}: {tail} {head}");
461            }
462        }
463    }
464
465    #[test]
466    fn track_pieces_keep_a_4dp_gap_beside_active_lines() {
467        assert_eq!(track_pieces(300., &[(0., 0.)]), vec![(0., 300.)]);
468        assert_eq!(track_pieces(300., &[(0., 150.)]), vec![(154., 300.)]);
469        assert_eq!(
470            track_pieces(300., &[(200., 260.), (30., 90.)]),
471            vec![(0., 26.), (94., 196.), (264., 300.)]
472        );
473        // A line shorter than the gap only clears its own length.
474        assert_eq!(track_pieces(100., &[(0., 2.)]), vec![(4., 100.)]);
475        assert!(track_pieces(100., &[(0., 100.)]).is_empty());
476    }
477
478    #[test]
479    fn a_buffer_ends_the_track_and_disabled_dims_both_parts() {
480        let pieces = track_pieces(300., &[(0., 90.)]);
481        assert_eq!(clip_pieces(pieces.clone(), 300.), vec![(94., 300.)]);
482        assert_eq!(clip_pieces(pieces.clone(), 210.), vec![(94., 210.)]);
483        // A buffer at the value leaves no track.
484        assert!(clip_pieces(pieces, 90.).is_empty());
485        let (ink, track) = progress_colors(false, true);
486        let on_surface = color(ColorRole::ON_SURFACE);
487        assert_eq!(ink, on_surface.opacity(DISABLED_INK));
488        assert_eq!(track, on_surface.opacity(DISABLED_TRACK));
489        assert_eq!(progress_colors(true, false).0, color(ColorRole::OUTLINE));
490    }
491
492    #[test]
493    fn the_wave_flattens_near_empty_and_full() {
494        assert_eq!(wave_amplitude(0.1), 0.);
495        assert_eq!(wave_amplitude(0.5), WAVE_AMPLITUDE);
496        assert_eq!(wave_amplitude(0.95), 0.);
497        assert_eq!(WAVE_AMPLITUDE * 2. + 4., 10.);
498        assert_eq!(WAVE_AMPLITUDE * 2. + 8., 14.);
499    }
500}