Skip to main content

iced_cube/feedback/
spinner.rs

1//! An indeterminate loading spinner.
2//!
3//! The spinner has no clock of its own. The app keeps a phase in its state,
4//! advances it with [`advance`] on each frame while loading (for example from
5//! `iced::window::frames()`), and stops subscribing when it is done.
6
7use std::f32::consts::{FRAC_PI_2, TAU};
8use std::time::Duration;
9
10use iced::widget::canvas::{self, Frame, Geometry, LineCap, Path, Stroke, path::Arc};
11use iced::{Color, Element, Length, Radians, Rectangle, Renderer, Theme, mouse};
12
13use crate::theme::{Tokens, fade};
14
15/// Time for one full turn.
16pub const PERIOD: Duration = Duration::from_millis(900);
17
18/// Share of the circle covered by the moving arc.
19pub const ARC: f32 = 0.3;
20
21/// Spinner diameter.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
23pub enum Size {
24    Sm,
25    #[default]
26    Md,
27    Lg,
28}
29
30impl Size {
31    pub const ALL: [Size; 3] = [Size::Sm, Size::Md, Size::Lg];
32
33    /// Diameter in logical pixels.
34    pub const fn diameter(self) -> f32 {
35        match self {
36            Size::Sm => 16.0,
37            Size::Md => 24.0,
38            Size::Lg => 32.0,
39        }
40    }
41
42    /// Stroke width in logical pixels.
43    pub const fn stroke(self) -> f32 {
44        match self {
45            Size::Sm => 2.0,
46            Size::Md => 2.5,
47            Size::Lg => 3.0,
48        }
49    }
50}
51
52/// A spinner builder. Convert it into an [`Element`] to render.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct Spinner {
55    phase: f32,
56    size: Size,
57}
58
59/// Creates a spinner at `phase`, measured in turns (0.0 to 1.0).
60pub fn spinner(phase: f32) -> Spinner {
61    Spinner {
62        phase: wrap(phase),
63        size: Size::default(),
64    }
65}
66
67impl Spinner {
68    pub fn size(mut self, size: Size) -> Self {
69        self.size = size;
70        self
71    }
72
73    /// The normalised phase, from 0.0 up to but not including 1.0.
74    pub fn phase(&self) -> f32 {
75        self.phase
76    }
77}
78
79/// Advances `phase` by the share of [`PERIOD`] that `elapsed` covers.
80pub fn advance(phase: f32, elapsed: Duration) -> f32 {
81    wrap(phase + elapsed.as_secs_f32() / PERIOD.as_secs_f32())
82}
83
84/// Wraps a phase into 0.0..1.0. Non-finite phases become 0.0.
85pub fn wrap(phase: f32) -> f32 {
86    if !phase.is_finite() {
87        return 0.0;
88    }
89    let wrapped = phase.rem_euclid(1.0);
90    // rem_euclid can round up to exactly 1.0 for tiny negative inputs.
91    if wrapped >= 1.0 { 0.0 } else { wrapped }
92}
93
94/// Start and end angles of the moving arc, clockwise from the top.
95pub fn arc_angles(phase: f32) -> (Radians, Radians) {
96    let start = wrap(phase) * TAU - FRAC_PI_2;
97    (Radians(start), Radians(start + ARC * TAU))
98}
99
100/// Colours of the track and the moving arc.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct Colours {
103    pub track: Color,
104    pub arc: Color,
105}
106
107/// Resolves the spinner colours.
108pub fn colours(tokens: &Tokens) -> Colours {
109    Colours {
110        track: fade(tokens.foreground, if tokens.is_dark { 0.18 } else { 0.12 }),
111        arc: tokens.foreground,
112    }
113}
114
115impl<Message> canvas::Program<Message> for Spinner {
116    type State = ();
117
118    fn draw(
119        &self,
120        _state: &(),
121        renderer: &Renderer,
122        theme: &Theme,
123        bounds: Rectangle,
124        _cursor: mouse::Cursor,
125    ) -> Vec<Geometry> {
126        let colours = colours(&Tokens::of(theme));
127        let width = self.size.stroke();
128        let mut frame = Frame::new(renderer, bounds.size());
129        let center = frame.center();
130        let radius = (bounds.width.min(bounds.height) - width) / 2.0;
131
132        frame.stroke(
133            &Path::circle(center, radius),
134            Stroke::default()
135                .with_color(colours.track)
136                .with_width(width),
137        );
138
139        let (start_angle, end_angle) = arc_angles(self.phase);
140        let arc = Path::new(|builder| {
141            builder.arc(Arc {
142                center,
143                radius,
144                start_angle,
145                end_angle,
146            });
147        });
148        frame.stroke(
149            &arc,
150            Stroke::default()
151                .with_color(colours.arc)
152                .with_width(width)
153                .with_line_cap(LineCap::Round),
154        );
155
156        vec![frame.into_geometry()]
157    }
158}
159
160impl<'a, Message: 'a> From<Spinner> for Element<'a, Message> {
161    fn from(spinner: Spinner) -> Self {
162        let diameter = Length::Fixed(spinner.size.diameter());
163        canvas::Canvas::new(spinner)
164            .width(diameter)
165            .height(diameter)
166            .into()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::theme::{dark, light};
174
175    const EPSILON: f32 = 1e-5;
176
177    #[test]
178    fn default_builder_is_medium() {
179        let s = spinner(0.25);
180        assert_eq!(s.size, Size::Md);
181        assert!((s.phase() - 0.25).abs() < EPSILON);
182    }
183
184    #[test]
185    fn wrap_keeps_phase_in_one_turn() {
186        assert_eq!(wrap(0.0), 0.0);
187        assert!((wrap(1.25) - 0.25).abs() < EPSILON);
188        assert!((wrap(-0.25) - 0.75).abs() < EPSILON);
189        assert_eq!(wrap(1.0), 0.0);
190        assert!(wrap(-1e-9) < 1.0);
191    }
192
193    #[test]
194    fn wrap_maps_non_finite_to_zero() {
195        assert_eq!(wrap(f32::NAN), 0.0);
196        assert_eq!(wrap(f32::INFINITY), 0.0);
197        assert_eq!(spinner(f32::NEG_INFINITY).phase(), 0.0);
198    }
199
200    #[test]
201    fn advance_moves_by_share_of_period() {
202        let half = PERIOD / 2;
203        assert!((advance(0.0, half) - 0.5).abs() < EPSILON);
204        assert!((advance(0.75, half) - 0.25).abs() < EPSILON);
205        assert_eq!(advance(0.4, Duration::ZERO), 0.4);
206    }
207
208    #[test]
209    fn a_full_period_returns_to_the_same_phase() {
210        let phase = advance(0.3, PERIOD);
211        assert!((phase - 0.3).abs() < EPSILON);
212    }
213
214    #[test]
215    fn arc_starts_at_the_top_and_spans_arc_share() {
216        let (start, end) = arc_angles(0.0);
217        assert!((start.0 + FRAC_PI_2).abs() < EPSILON);
218        assert!((end.0 - start.0 - ARC * TAU).abs() < EPSILON);
219
220        let (quarter, _) = arc_angles(0.25);
221        assert!(quarter.0.abs() < EPSILON);
222    }
223
224    #[test]
225    fn sizes_grow_monotonically() {
226        let diameters = Size::ALL.map(Size::diameter);
227        assert!(diameters.windows(2).all(|w| w[0] < w[1]));
228        let strokes = Size::ALL.map(Size::stroke);
229        assert!(strokes.windows(2).all(|w| w[0] < w[1]));
230    }
231
232    #[test]
233    fn track_is_fainter_than_arc() {
234        for theme in [light(), dark()] {
235            let tokens = Tokens::of(&theme);
236            let colours = colours(&tokens);
237            assert_eq!(colours.arc, tokens.foreground);
238            assert!(colours.track.a < colours.arc.a);
239        }
240    }
241}