Skip to main content

frus_widgets/
spinner.rs

1//! [`CircularProgressIndicator`]: the reference's circular progress indicator — an arc,
2//! determinate or spinning (milestone 630) — and the ring of dots the pull-to-refresh
3//! indicator still draws.
4
5use frus_core::{Color, Curve, Path, Point, Rect, Scene, Stroke, StrokeCap};
6use frus_layout::{Dimension, Style};
7
8use crate::interaction::Status;
9use crate::theme::Theme;
10use crate::widget::Widget;
11
12/// Number of dots in the ring.
13const DOTS: usize = 8;
14
15/// How the ring reads: **turning**, which means work is happening, or **filling**,
16/// which means a gesture is part of the way to asking for some.
17#[derive(Copy, Clone, Debug, PartialEq)]
18pub(crate) enum RingMode {
19    /// A bright head advancing, the dots behind it fading out. `head` is in turns.
20    Spinning { head: f32 },
21    /// The first `progress` of the ring, clockwise from the top. `0..=1`.
22    Filling { progress: f32 },
23}
24
25/// Draws the framework's activity ring: `DOTS` dots on a circle of `radius` about
26/// `(cx, cy)`, each `dot` px in radius.
27///
28/// The pull-to-refresh indicator's; the circular progress indicator draws the reference's
29/// arc since milestone 630.
30pub(crate) fn paint_activity_ring(
31    scene: &mut Scene,
32    cx: f32,
33    cy: f32,
34    radius: f32,
35    dot: f32,
36    color: Color,
37    mode: RingMode,
38) {
39    for i in 0..DOTS {
40        let fraction = i as f32 / DOTS as f32;
41        let angle = fraction * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
42        let alpha = match mode {
43            RingMode::Spinning { head } => {
44                let head = head.fract() * DOTS as f32;
45                // The angular distance behind the head: 0 is the head, about 1 the tail.
46                let behind = (i as f32 - head).rem_euclid(DOTS as f32) / DOTS as f32;
47                0.15 + 0.85 * (1.0 - behind)
48            }
49            // A partial dot at the leading edge, so the fill grows smoothly instead of
50            // clicking round one eighth at a time.
51            RingMode::Filling { progress } => ((progress - fraction) * DOTS as f32).clamp(0.0, 1.0),
52        };
53        if alpha <= 0.0 {
54            continue;
55        }
56        let px = cx + radius * angle.cos();
57        let py = cy + radius * angle.sin();
58        scene.draw_rect(
59            Rect::new(px - dot, py - dot, dot * 2.0, dot * 2.0),
60            color.fade(alpha),
61            dot,
62            0.0,
63            Color::TRANSPARENT,
64        );
65    }
66}
67
68/// One cycle of the indeterminate arc's ends, in seconds (`progress_indicator.dart:1057`:
69/// the controller's 1333 × 2222 ms run is 2222 of these).
70const PATH_PERIOD: f32 = 1.333;
71/// One turn of the whole indeterminate arc, in seconds (1333 of these per run).
72const ROTATION_PERIOD: f32 = 2.222;
73/// The reference never sweeps a whole circle, which its canvas would not draw
74/// (`progress_indicator.dart:705`).
75const EPSILON: f32 = 0.001;
76/// Straight up, where every arc starts (`progress_indicator.dart:708`).
77const START_ANGLE: f32 = -std::f32::consts::FRAC_PI_2;
78
79/// A circular progress indicator: an arc around a circle — **determinate**, sweeping
80/// clockwise from the top to its value, or **indeterminate**, an arc that grows, shrinks
81/// and turns while the length of the work is unknown (`progress_indicator.dart:863`).
82///
83/// The look is the reference's **default** one, its 2023 appearance: a 36 px circle, a
84/// 4 px stroke centred on it, flat ends, no track. [`year2023(false)`](Self::year2023) is
85/// its newer one: a 40 px circle inside 4 px of padding, the stroke inside it, round ends,
86/// and for a determinate indicator a track with a gap at each end of the arc.
87pub struct CircularProgressIndicator {
88    value: Option<f32>,
89    size: Option<f32>,
90    color: Option<Color>,
91    track_color: Option<Color>,
92    stroke_width: Option<f32>,
93    stroke_align: Option<f32>,
94    stroke_cap: Option<StrokeCap>,
95    track_gap: Option<f32>,
96    padding: Option<f32>,
97    year2023: Option<bool>,
98    semantics_label: Option<String>,
99    semantics_value: Option<String>,
100}
101
102impl CircularProgressIndicator {
103    /// An **indeterminate** indicator: the work is under way and how long it will take is
104    /// not known — the reference's default.
105    pub fn new() -> Self {
106        Self::with_value(None)
107    }
108
109    /// A **determinate** indicator, its arc swept to `value`, clamped to `0.0..=1.0`.
110    pub fn determinate(value: f32) -> Self {
111        Self::with_value(Some(value))
112    }
113
114    /// An indicator at `value`, or indeterminate for `None` — the reference's nullable
115    /// `value`.
116    pub fn with_value(value: Option<f32>) -> Self {
117        Self {
118            value: value.map(|v| v.clamp(0.0, 1.0)),
119            size: None,
120            color: None,
121            track_color: None,
122            stroke_width: None,
123            stroke_align: None,
124            stroke_cap: None,
125            track_gap: None,
126            padding: None,
127            year2023: None,
128            semantics_label: None,
129            semantics_value: None,
130        }
131    }
132
133    /// **The side of the circle**, in logical pixels — the reference's `constraints`, as a
134    /// square. Unset, the theme's, then 36 (2023 look) or 40 (newer).
135    pub fn size(mut self, size: f32) -> Self {
136        self.size = Some(size);
137        self
138    }
139
140    /// **The colour of the arc**, over the theme's and `primary`.
141    #[must_use]
142    pub fn color(mut self, color: Color) -> Self {
143        self.color = Some(color);
144        self
145    }
146
147    /// **The circle behind the arc** — the reference's `backgroundColor`. Unset, the
148    /// theme's, then none in the 2023 look, `secondary_container` behind a determinate arc
149    /// in the newer one.
150    #[must_use]
151    pub fn track_color(mut self, color: Color) -> Self {
152        self.track_color = Some(color);
153        self
154    }
155
156    /// **How thick the arc is.** Unset, the theme's, then four.
157    #[must_use]
158    pub fn stroke_width(mut self, width: f32) -> Self {
159        self.stroke_width = Some(width);
160        self
161    }
162
163    /// **Where the line sits on the circle**: `-1` inside, `0` centred, `1` outside — the
164    /// reference's `strokeAlignInside`, `Center` and `Outside`.
165    #[must_use]
166    pub fn stroke_align(mut self, align: f32) -> Self {
167        self.stroke_align = Some(align);
168        self
169    }
170
171    /// **How the arc's ends are drawn.**
172    #[must_use]
173    pub fn stroke_cap(mut self, cap: StrokeCap) -> Self {
174        self.stroke_cap = Some(cap);
175        self
176    }
177
178    /// **The gap between the arc and the track**, in the newer look.
179    #[must_use]
180    pub fn track_gap(mut self, gap: f32) -> Self {
181        self.track_gap = Some(gap);
182        self
183    }
184
185    /// **The room around the circle**, on every side.
186    #[must_use]
187    pub fn padding(mut self, padding: f32) -> Self {
188        self.padding = Some(padding);
189        self
190    }
191
192    /// **Which look**: the 2023 one (`true`, the default, as the reference's) or the newer
193    /// one (`false`).
194    #[must_use]
195    pub fn year2023(mut self, year2023: bool) -> Self {
196        self.year2023 = Some(year2023);
197        self
198    }
199
200    /// **What the indicator is the progress of**, for whoever cannot see it.
201    #[must_use]
202    pub fn semantics_label(mut self, label: impl Into<String>) -> Self {
203        self.semantics_label = Some(label.into());
204        self
205    }
206
207    /// **The value read out**, in place of the percentage.
208    #[must_use]
209    pub fn semantics_value(mut self, value: impl Into<String>) -> Self {
210        self.semantics_value = Some(value.into());
211        self
212    }
213
214    fn year2023_in(&self, theme: &Theme) -> bool {
215        self.year2023
216            .or(theme.widgets.progress.year2023)
217            .unwrap_or(true)
218    }
219
220    /// The circle's side and the room around it: the caller's, the theme's, the reference's.
221    fn extent(&self, theme: &Theme) -> (f32, f32) {
222        let t = &theme.widgets.progress;
223        let y2023 = self.year2023_in(theme);
224        let size = self
225            .size
226            .or(t.circular_size)
227            .unwrap_or(if y2023 { 36.0 } else { 40.0 });
228        let padding = self
229            .padding
230            .or(t.circular_track_padding)
231            .unwrap_or(if y2023 { 0.0 } else { 4.0 });
232        (size, padding)
233    }
234}
235
236impl Default for CircularProgressIndicator {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242/// The indeterminate arc's ends, how far it has been carried round, and how far turned, at
243/// `time` seconds (`progress_indicator.dart:1060`): the head runs the first half of each
244/// cycle on `fastOutSlowIn`, the tail the second half.
245fn indeterminate_phase(time: f32) -> (f32, f32, f32, f32) {
246    let path = (time / PATH_PERIOD).rem_euclid(1.0);
247    let rotation = (time / ROTATION_PERIOD).rem_euclid(1.0);
248    let half = |begin: f32| Curve::Interval {
249        begin,
250        end: begin + 0.5,
251        inner: Box::new(Curve::Cubic {
252            x1: 0.4,
253            y1: 0.0,
254            x2: 0.2,
255            y2: 1.0,
256        }),
257    };
258    (
259        half(0.0).transform(path),
260        half(0.5).transform(path),
261        path,
262        rotation,
263    )
264}
265
266/// Where the arc starts and how far it sweeps, in radians (`progress_indicator.dart:679`).
267fn arc(value: Option<f32>, time: f32) -> (f32, f32) {
268    use std::f32::consts::{PI, TAU};
269    match value {
270        Some(v) => (START_ANGLE, v.clamp(0.0, 1.0) * (TAU - EPSILON)),
271        None => {
272            let (head, tail, offset, rotation) = indeterminate_phase(time);
273            (
274                START_ANGLE + tail * 1.5 * PI + rotation * TAU + offset * 0.5 * PI,
275                (head * 1.5 * PI - tail * 1.5 * PI).max(EPSILON),
276            )
277        }
278    }
279}
280
281/// An arc of `sweep` radians from `start`, around `center`.
282fn arc_path(center: Point, radius: f32, start: f32, sweep: f32) -> Path {
283    Path::new()
284        .move_to(Point::new(
285            center.x + radius * start.cos(),
286            center.y + radius * start.sin(),
287        ))
288        .arc_to(center, radius, start, start + sweep)
289}
290
291impl<Msg> Widget<Msg> for CircularProgressIndicator {
292    fn style(&self) -> Style {
293        Widget::<Msg>::style_themed(self, &Theme::default())
294    }
295
296    fn style_themed(&self, theme: &Theme) -> Style {
297        let (size, padding) = self.extent(theme);
298        let side = size + 2.0 * padding;
299        Style {
300            width: Dimension::Length(side),
301            height: Dimension::Length(side),
302            ..Default::default()
303        }
304    }
305
306    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
307        &[]
308    }
309
310    /// An indeterminate indicator moves for as long as it is shown; a determinate one only
311    /// when its value changes.
312    fn continuous(&self) -> bool {
313        self.value.is_none()
314    }
315
316    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
317        use std::f32::consts::TAU;
318        let t = &theme.widgets.progress;
319        let y2023 = self.year2023_in(theme);
320        let (_, padding) = self.extent(theme);
321        let o = status.opacity;
322        let stroke = self.stroke_width.or(t.stroke_width).unwrap_or(4.0);
323        let align = self
324            .stroke_align
325            .or(t.stroke_align)
326            .unwrap_or(if y2023 { 0.0 } else { -1.0 });
327        let value_color = self.color.or(t.color).unwrap_or(theme.scheme.primary);
328        let track_color = self
329            .track_color
330            .or(t.circular_track_color)
331            .or((!y2023 && self.value.is_some()).then_some(theme.scheme.secondary_container));
332        let gap = if y2023 {
333            None
334        } else {
335            Some(self.track_gap.or(t.track_gap).unwrap_or(4.0))
336        };
337        let cap = self.stroke_cap.or(t.stroke_cap);
338
339        // The circle the line is drawn on: the box less the padding, moved in or out by
340        // the alignment (`progress_indicator.dart:719`).
341        let inset = padding + stroke * 0.5 * -align;
342        let w = (bounds.width - 2.0 * inset).max(0.0);
343        let h = (bounds.height - 2.0 * inset).max(0.0);
344        let center = Point::new(
345            bounds.x + bounds.width * 0.5,
346            bounds.y + bounds.height * 0.5,
347        );
348        let radius = w.min(h) * 0.5;
349        if radius <= 0.0 {
350            return;
351        }
352
353        if let Some(track) = track_color {
354            let track_stroke =
355                Stroke::new(track.fade(o), stroke).with_cap(cap.unwrap_or(StrokeCap::Round));
356            match (gap, self.value) {
357                // The track stops short of both ends of the arc, by the stroke and the gap
358                // (`progress_indicator.dart:731`).
359                (Some(gap), Some(v)) if gap > 0.0 && v > EPSILON => {
360                    let start_gap = (stroke + gap) / radius;
361                    let sweep = (TAU - v * TAU - 2.0 * start_gap).max(0.0);
362                    if sweep > 0.0 {
363                        let start = START_ANGLE + v * TAU + start_gap;
364                        scene.paint_path(
365                            &arc_path(center, radius, start, sweep),
366                            None,
367                            Some(track_stroke),
368                        );
369                    }
370                }
371                _ => scene.paint_path(
372                    &arc_path(center, radius, 0.0, TAU - EPSILON),
373                    None,
374                    Some(track_stroke),
375                ),
376            }
377        }
378
379        let cap = match (y2023, cap, self.value) {
380            (_, Some(cap), _) => cap,
381            (true, None, None) => StrokeCap::Square,
382            (true, None, Some(_)) => StrokeCap::Butt,
383            (false, None, _) => StrokeCap::Round,
384        };
385        let (start, sweep) = arc(self.value, status.time);
386        if self.value == Some(0.0) {
387            return;
388        }
389        scene.paint_path(
390            &arc_path(center, radius, start, sweep),
391            None,
392            Some(Stroke::new(value_color.fade(o), stroke).with_cap(cap)),
393        );
394    }
395
396    fn on_click(&self) -> Option<Msg> {
397        None
398    }
399
400    /// A determinate indicator says its value from 0 to 100; an indeterminate one says only
401    /// that it is busy (`progress_indicator.dart:146`).
402    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
403        let mut s = frus_core::SemanticsProperties::new(frus_core::Role::ProgressBar);
404        if let Some(label) = &self.semantics_label {
405            s = s.label(label.clone());
406        }
407        if let Some(value) = self.value {
408            let pct = (value * 100.0).round();
409            s = s.range(0.0, pct, 100.0);
410            s = s.value(self.semantics_value.clone().unwrap_or(format!("{pct}")));
411        } else if let Some(said) = &self.semantics_value {
412            s = s.value(said.clone());
413        }
414        Some(s)
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use frus_core::{PathVerb, Primitive};
422    use std::f32::consts::{PI, TAU};
423
424    struct Arc {
425        start: Point,
426        end: Point,
427        color: Color,
428        width: f32,
429        cap: StrokeCap,
430    }
431
432    fn arcs(spinner: &CircularProgressIndicator, theme: &Theme, time: f32) -> Vec<Arc> {
433        let side = match Widget::<()>::style_themed(spinner, theme).width {
434            Dimension::Length(s) => s,
435            _ => unreachable!(),
436        };
437        let mut scene = Scene::new();
438        let status = Status {
439            time,
440            ..Default::default()
441        };
442        Widget::<()>::paint(
443            spinner,
444            Rect::new(0.0, 0.0, side, side),
445            status,
446            theme,
447            &mut scene,
448        );
449        scene
450            .primitives()
451            .iter()
452            .filter_map(|p| match p {
453                Primitive::Path {
454                    path,
455                    stroke: Some(s),
456                    ..
457                } => {
458                    let start = match path.verbs()[0] {
459                        PathVerb::MoveTo(p) => p,
460                        _ => unreachable!(),
461                    };
462                    let end = match path.verbs().last() {
463                        Some(PathVerb::CubicTo { to, .. }) => *to,
464                        _ => start,
465                    };
466                    Some(Arc {
467                        start,
468                        end,
469                        color: s.color,
470                        width: s.width,
471                        cap: s.cap,
472                    })
473                }
474                _ => None,
475            })
476            .collect()
477    }
478
479    fn near(a: Point, b: Point) -> bool {
480        (a.x - b.x).abs() < 0.05 && (a.y - b.y).abs() < 0.05
481    }
482
483    fn on_circle(c: f32, r: f32, angle: f32) -> Point {
484        Point::new(c + r * angle.cos(), c + r * angle.sin())
485    }
486
487    /// **The default look is the reference's default**: a 36 px circle, a 4 px stroke
488    /// centred on it, flat ends, no track — an arc, not a ring of dots
489    /// (`progress_indicator.dart:1535`, `:754`).
490    #[test]
491    fn the_default_look_is_the_reference_s_default() {
492        let theme = Theme::default();
493        let quarter = CircularProgressIndicator::determinate(0.25);
494        assert_eq!(
495            Widget::<()>::style_themed(&quarter, &theme).width,
496            Dimension::Length(36.0)
497        );
498        let a = arcs(&quarter, &theme, 0.0);
499        assert_eq!(a.len(), 1, "one arc and no track");
500        let (c, r) = (18.0, 18.0);
501        assert!(near(a[0].start, on_circle(c, r, -PI / 2.0)), "from the top");
502        assert!(
503            near(
504                a[0].end,
505                on_circle(c, r, -PI / 2.0 + 0.25 * (TAU - EPSILON))
506            ),
507            "a quarter clockwise"
508        );
509        assert_eq!((a[0].width, a[0].cap), (4.0, StrokeCap::Butt));
510        assert_eq!(a[0].color, theme.scheme.primary);
511        // Spinning, the ends are square.
512        let spinning = arcs(&CircularProgressIndicator::new(), &theme, 0.3);
513        assert_eq!(spinning[0].cap, StrokeCap::Square);
514    }
515
516    /// **The newer look**: a 40 px circle in 4 px of padding, the stroke inside it, round
517    /// ends, and behind a determinate arc a track that stops a stroke and a gap short of
518    /// each end (`progress_indicator.dart:731`, `:1579`).
519    #[test]
520    fn the_newer_look_is_the_reference_s() {
521        let theme = Theme::default();
522        let half = CircularProgressIndicator::determinate(0.5).year2023(false);
523        assert_eq!(
524            Widget::<()>::style_themed(&half, &theme).width,
525            Dimension::Length(48.0)
526        );
527        let a = arcs(&half, &theme, 0.0);
528        assert_eq!(a.len(), 2, "a track and the arc");
529        let (track, value) = (&a[0], &a[1]);
530        // Inside: the line's centre is half a stroke in from the padded box.
531        let (c, r) = (24.0, 20.0 - 2.0);
532        assert!(near(value.start, on_circle(c, r, -PI / 2.0)));
533        assert_eq!(value.cap, StrokeCap::Round);
534        assert_eq!(track.color, theme.scheme.secondary_container);
535        let gap = (4.0 + 4.0) / r;
536        assert!(near(track.start, on_circle(c, r, -PI / 2.0 + PI + gap)));
537        assert!(near(track.end, on_circle(c, r, -PI / 2.0 + TAU - gap)));
538        // Spinning, there is no track.
539        let spinning = arcs(
540            &CircularProgressIndicator::new().year2023(false),
541            &theme,
542            0.3,
543        );
544        assert_eq!(spinning.len(), 1);
545    }
546
547    /// **The indeterminate arc follows the reference's timing**: its ends on
548    /// `fastOutSlowIn` over 1.333 s halves, the whole turning once in 2.222 s
549    /// (`progress_indicator.dart:1060`, `:679`).
550    #[test]
551    fn an_indeterminate_arc_runs_the_reference_s_timing() {
552        let spinner = CircularProgressIndicator::new();
553        assert!(Widget::<()>::continuous(&spinner));
554        assert!(!Widget::<()>::continuous(
555            &CircularProgressIndicator::determinate(0.3)
556        ));
557        let fast_out_slow_in = Curve::Cubic {
558            x1: 0.4,
559            y1: 0.0,
560            x2: 0.2,
561            y2: 1.0,
562        };
563        // A quarter of the way through a cycle: the head half way along its curve, the tail
564        // not yet moving.
565        let time = 0.25 * PATH_PERIOD;
566        let (head, tail, offset, rotation) = indeterminate_phase(time);
567        assert!((head - fast_out_slow_in.transform(0.5)).abs() < 1e-4);
568        assert_eq!(tail, 0.0);
569        assert!((offset - 0.25).abs() < 1e-4);
570        assert!((rotation - time / ROTATION_PERIOD).abs() < 1e-4);
571        let (start, sweep) = arc(None, time);
572        let expect_start = START_ANGLE + rotation * TAU + offset * 0.5 * PI;
573        assert!((start - expect_start).abs() < 1e-4);
574        assert!((sweep - head * 1.5 * PI).abs() < 1e-4);
575        // At the start of a cycle the arc is the smallest the reference draws.
576        assert_eq!(arc(None, 0.0).1, EPSILON);
577        // And the painted arc starts where the formula says.
578        let a = arcs(&spinner, &Theme::default(), time);
579        assert!(near(a[0].start, on_circle(18.0, 18.0, start)));
580    }
581
582    /// **Everything answers to the theme and to the caller.**
583    #[test]
584    fn an_indicator_answers_to_its_theme_and_to_its_caller() {
585        let mut theme = Theme::default();
586        theme.widgets.progress.color = Some(Color::rgb(0.9, 0.1, 0.1));
587        theme.widgets.progress.circular_track_color = Some(Color::rgb(0.2, 0.2, 0.2));
588        theme.widgets.progress.stroke_width = Some(6.0);
589        theme.widgets.progress.stroke_cap = Some(StrokeCap::Round);
590        theme.widgets.progress.circular_size = Some(50.0);
591        let plain = CircularProgressIndicator::determinate(0.5);
592        assert_eq!(
593            Widget::<()>::style_themed(&plain, &theme).width,
594            Dimension::Length(50.0)
595        );
596        let a = arcs(&plain, &theme, 0.0);
597        assert_eq!(a.len(), 2, "the theme's track, whole, then the arc");
598        assert_eq!((a[0].color, a[0].width), (Color::rgb(0.2, 0.2, 0.2), 6.0));
599        assert_eq!(
600            (a[1].color, a[1].cap),
601            (Color::rgb(0.9, 0.1, 0.1), StrokeCap::Round)
602        );
603
604        let told = CircularProgressIndicator::determinate(0.5)
605            .size(20.0)
606            .padding(2.0)
607            .stroke_width(2.0)
608            .stroke_align(1.0)
609            .stroke_cap(StrokeCap::Butt)
610            .color(Color::rgb(0.0, 0.0, 1.0));
611        assert_eq!(
612            Widget::<()>::style_themed(&told, &theme).width,
613            Dimension::Length(24.0)
614        );
615        let a = arcs(&told, &theme, 0.0);
616        // Outside: the line's centre is half a stroke out from the 20 px circle.
617        assert!(near(a[1].start, on_circle(12.0, 11.0, -PI / 2.0)));
618        assert_eq!((a[1].width, a[1].cap), (2.0, StrokeCap::Butt));
619        assert_eq!(a[1].color, Color::rgb(0.0, 0.0, 1.0));
620    }
621
622    /// **What a reader hears**, as the bar's.
623    #[test]
624    fn a_reader_hears_the_reference_s_values() {
625        let s = Widget::<()>::semantics(
626            &CircularProgressIndicator::determinate(0.426).semantics_label("Sync"),
627        )
628        .unwrap();
629        assert_eq!(s.label.as_deref(), Some("Sync"));
630        assert_eq!(s.value.as_deref(), Some("43"));
631        assert_eq!(
632            Widget::<()>::semantics(&CircularProgressIndicator::new())
633                .unwrap()
634                .value,
635            None
636        );
637    }
638}