Skip to main content

frus_widgets/
spinner.rs

1//! [`CircularProgressIndicator`]: an activity indicator **animated continuously**, driven by time. It
2//! declares `continuous()`, so the framework keeps redrawing.
3
4use frus_core::{Color, Rect, Scene};
5use frus_layout::{Dimension, Style};
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11/// Number of dots in the ring.
12const DOTS: usize = 8;
13/// Turns per second.
14const SPEED: f32 = 1.1;
15
16/// How the ring reads: **turning**, which means work is happening, or **filling**,
17/// which means a gesture is part of the way to asking for some.
18#[derive(Copy, Clone, Debug, PartialEq)]
19pub(crate) enum RingMode {
20    /// A bright head advancing, the dots behind it fading out. `head` is in turns.
21    Spinning { head: f32 },
22    /// The first `progress` of the ring, clockwise from the top. `0..=1`.
23    Filling { progress: f32 },
24}
25
26/// Draws the framework's activity ring: `DOTS` dots on a circle of `radius` about
27/// `(cx, cy)`, each `dot` px in radius.
28///
29/// Shared by [`CircularProgressIndicator`] and by the pull-to-refresh indicator, so the two cannot drift
30/// into looking like different frameworks' idea of "busy".
31pub(crate) fn paint_activity_ring(
32    scene: &mut Scene,
33    cx: f32,
34    cy: f32,
35    radius: f32,
36    dot: f32,
37    color: Color,
38    mode: RingMode,
39) {
40    for i in 0..DOTS {
41        let fraction = i as f32 / DOTS as f32;
42        let angle = fraction * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
43        let alpha = match mode {
44            RingMode::Spinning { head } => {
45                let head = head.fract() * DOTS as f32;
46                // The angular distance behind the head: 0 is the head, about 1 the tail.
47                let behind = (i as f32 - head).rem_euclid(DOTS as f32) / DOTS as f32;
48                0.15 + 0.85 * (1.0 - behind)
49            }
50            // A partial dot at the leading edge, so the fill grows smoothly instead of
51            // clicking round one eighth at a time.
52            RingMode::Filling { progress } => ((progress - fraction) * DOTS as f32).clamp(0.0, 1.0),
53        };
54        if alpha <= 0.0 {
55            continue;
56        }
57        let px = cx + radius * angle.cos();
58        let py = cy + radius * angle.sin();
59        scene.draw_rect(
60            Rect::new(px - dot, py - dot, dot * 2.0, dot * 2.0),
61            color.fade(alpha),
62            dot,
63            0.0,
64            Color::TRANSPARENT,
65        );
66    }
67}
68
69/// A circular loading indicator: a ring of dots that spins.
70pub struct CircularProgressIndicator {
71    size: f32,
72    color: Option<Color>,
73    track_color: Option<Color>,
74    stroke_width: Option<f32>,
75}
76
77impl CircularProgressIndicator {
78    /// Creates a spinner, `24` px on a side by default.
79    pub fn new() -> Self {
80        Self {
81            size: 24.0,
82            color: None,
83            track_color: None,
84            stroke_width: None,
85        }
86    }
87
88    /// Sets the size, that is, the square's side, in logical pixels.
89    pub fn size(mut self, size: f32) -> Self {
90        self.size = size;
91        self
92    }
93
94    /// **The colour of the dots**, over the theme's and `primary`.
95    #[must_use]
96    pub fn color(mut self, color: Color) -> Self {
97        self.color = Some(color);
98        self
99    }
100
101    /// **A track behind the ring** — the whole circle drawn once, quietly, so the
102    /// part that is not lit still reads as part of a ring
103    /// (`progress_indicator.dart:1590`). Unset, none is drawn and the ring is only its
104    /// lit dots, which is what this always was.
105    #[must_use]
106    pub fn track_color(mut self, color: Color) -> Self {
107        self.track_color = Some(color);
108        self
109    }
110
111    /// **How thick the dots are.** Unset, the framework's own proportional rule
112    /// — the reference gives a flat four whatever the size, which draws a hairline on
113    /// a large indicator; this keeps the dots in proportion to the ring and lets a
114    /// caller name a number when the proportion is not what they want.
115    #[must_use]
116    pub fn stroke_width(mut self, width: f32) -> Self {
117        self.stroke_width = Some(width);
118        self
119    }
120
121    /// The dot's radius, from the side of the square: the caller's word, then the
122    /// theme's, then the proportional rule.
123    fn dot(&self, side: f32, theme: &Theme) -> f32 {
124        match self.stroke_width.or(theme.widgets.progress.stroke_width) {
125            Some(width) => (width * 0.5).max(0.5),
126            None => (side * 0.12).max(1.0),
127        }
128    }
129}
130
131impl Default for CircularProgressIndicator {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137impl<Msg> Widget<Msg> for CircularProgressIndicator {
138    fn style(&self) -> Style {
139        Style {
140            width: Dimension::Length(self.size),
141            height: Dimension::Length(self.size),
142            ..Default::default()
143        }
144    }
145
146    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
147        &[]
148    }
149
150    fn continuous(&self) -> bool {
151        true
152    }
153
154    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
155        let side = bounds.width.min(bounds.height);
156        let cx = bounds.x + bounds.width * 0.5;
157        let cy = bounds.y + bounds.height * 0.5;
158        let radius = side * 0.36;
159        let dot = self.dot(side, theme);
160        // The track first, when anything named one: every dot at full strength in a
161        // quiet colour, with the lit ones drawn over it.
162        if let Some(track) = self
163            .track_color
164            .or(theme.widgets.progress.circular_track_color)
165        {
166            paint_activity_ring(
167                scene,
168                cx,
169                cy,
170                radius,
171                dot,
172                track.fade(status.opacity),
173                RingMode::Filling { progress: 1.0 },
174            );
175        }
176        paint_activity_ring(
177            scene,
178            cx,
179            cy,
180            radius,
181            dot,
182            self.color
183                .or(theme.widgets.progress.color)
184                .unwrap_or(theme.primary)
185                .fade(status.opacity),
186            RingMode::Spinning {
187                head: status.time * SPEED,
188            },
189        );
190    }
191
192    fn on_click(&self) -> Option<Msg> {
193        None
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use frus_core::Primitive;
201
202    fn dot_alphas(time: f32) -> Vec<f32> {
203        let spinner = CircularProgressIndicator::new().size(40.0);
204        let status = Status {
205            time,
206            ..Default::default()
207        };
208        let mut scene = Scene::new();
209        Widget::<()>::paint(
210            &spinner,
211            Rect::new(0.0, 0.0, 40.0, 40.0),
212            status,
213            &Theme::default(),
214            &mut scene,
215        );
216        scene
217            .primitives()
218            .iter()
219            .filter_map(|p| match p {
220                Primitive::Rect { color, .. } => Some(color.a),
221                _ => None,
222            })
223            .collect()
224    }
225
226    #[test]
227    fn draws_ring_of_dots() {
228        assert_eq!(dot_alphas(0.0).len(), DOTS);
229    }
230
231    #[test]
232    fn animation_depends_on_time() {
233        // The distribution of opacities changes over time, which is the spinning.
234        assert_ne!(dot_alphas(0.0), dot_alphas(0.3));
235    }
236
237    /// **The ring answers to a theme and to its caller** — its colour, how thick its
238    /// dots are, and whether there is a track behind them. It had one builder, a size.
239    ///
240    /// The track is **off** unless something names a colour, so a spinner that says
241    /// nothing draws exactly what it always drew.
242    #[test]
243    fn a_ring_answers_to_its_theme_and_to_its_caller() {
244        let plain = CircularProgressIndicator::new().size(40.0);
245        assert_eq!(
246            dot_alphas(0.0).len(),
247            DOTS,
248            "no track unless one is asked for"
249        );
250
251        let mut theme = Theme::default();
252        theme.widgets.progress.circular_track_color = Some(frus_core::Color::rgb(0.2, 0.2, 0.2));
253        theme.widgets.progress.color = Some(frus_core::Color::rgb(0.9, 0.1, 0.1));
254        theme.widgets.progress.stroke_width = Some(6.0);
255
256        let mut scene = Scene::new();
257        Widget::<()>::paint(
258            &plain,
259            Rect::new(0.0, 0.0, 40.0, 40.0),
260            Status::default(),
261            &theme,
262            &mut scene,
263        );
264        let dots: Vec<(f32, frus_core::Color)> = scene
265            .primitives()
266            .iter()
267            .filter_map(|p| match p {
268                Primitive::Rect { rect, color, .. } => Some((rect.width, *color)),
269                _ => None,
270            })
271            .collect();
272        assert_eq!(dots.len(), 2 * DOTS, "a track ring under the lit one");
273        assert_eq!(dots[0].0, 6.0, "the theme's stroke, as a diameter");
274        assert_eq!(dots[0].1, frus_core::Color::rgb(0.2, 0.2, 0.2));
275        assert_eq!(
276            dots[DOTS].1.r, 0.9,
277            "and the lit dots take the theme's colour"
278        );
279
280        // The caller outranks it.
281        let told = CircularProgressIndicator::new()
282            .size(40.0)
283            .stroke_width(2.0)
284            .color(frus_core::Color::rgb(0.0, 0.0, 1.0));
285        let mut scene = Scene::new();
286        Widget::<()>::paint(
287            &told,
288            Rect::new(0.0, 0.0, 40.0, 40.0),
289            Status::default(),
290            &theme,
291            &mut scene,
292        );
293        let dots: Vec<(f32, frus_core::Color)> = scene
294            .primitives()
295            .iter()
296            .filter_map(|p| match p {
297                Primitive::Rect { rect, color, .. } => Some((rect.width, *color)),
298                _ => None,
299            })
300            .collect();
301        assert_eq!(dots[0].0, 2.0);
302        assert_eq!(dots[DOTS].1.b, 1.0);
303    }
304
305    #[test]
306    fn declares_continuous() {
307        assert!(Widget::<()>::continuous(&CircularProgressIndicator::new()));
308    }
309}