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//!
7//! The track and the arc are small SVG images drawn through iced's image
8//! path, which behaves the same on every backend. The arc comes from a fixed
9//! set of [`FRAMES`] pre-rotated images, so spinners at the same phase share
10//! one cached image.
11
12use std::f32::consts::{FRAC_PI_2, TAU};
13use std::sync::LazyLock;
14use std::time::Duration;
15
16use iced::advanced::layout::{self, Layout};
17use iced::advanced::renderer;
18use iced::advanced::svg::{self as advanced_svg, Renderer as _};
19use iced::advanced::widget::{Tree, Widget};
20use iced::widget::svg::Handle;
21use iced::{Color, Element, Length, Radians, Rectangle, Renderer, Theme, mouse};
22
23use crate::icon::opaque;
24use crate::theme::{Tokens, fade};
25
26/// Time for one full turn.
27pub const PERIOD: Duration = Duration::from_millis(900);
28
29/// Share of the circle covered by the moving arc.
30pub const ARC: f32 = 0.3;
31
32/// Number of distinct arc positions in one turn.
33pub const FRAMES: usize = 60;
34
35/// Spinner diameter.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
37pub enum Size {
38    Sm,
39    #[default]
40    Md,
41    Lg,
42}
43
44impl Size {
45    pub const ALL: [Size; 3] = [Size::Sm, Size::Md, Size::Lg];
46
47    /// Diameter in logical pixels.
48    pub const fn diameter(self) -> f32 {
49        match self {
50            Size::Sm => 16.0,
51            Size::Md => 24.0,
52            Size::Lg => 32.0,
53        }
54    }
55
56    /// Stroke width in logical pixels.
57    pub const fn stroke(self) -> f32 {
58        match self {
59            Size::Sm => 2.0,
60            Size::Md => 2.5,
61            Size::Lg => 3.0,
62        }
63    }
64
65    const fn index(self) -> usize {
66        match self {
67            Size::Sm => 0,
68            Size::Md => 1,
69            Size::Lg => 2,
70        }
71    }
72}
73
74/// A spinner builder. Convert it into an [`Element`] to render.
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub struct Spinner {
77    phase: f32,
78    size: Size,
79}
80
81/// Creates a spinner at `phase`, measured in turns (0.0 to 1.0).
82pub fn spinner(phase: f32) -> Spinner {
83    Spinner {
84        phase: wrap(phase),
85        size: Size::default(),
86    }
87}
88
89impl Spinner {
90    pub fn size(mut self, size: Size) -> Self {
91        self.size = size;
92        self
93    }
94
95    /// The normalised phase, from 0.0 up to but not including 1.0.
96    pub fn phase(&self) -> f32 {
97        self.phase
98    }
99}
100
101/// Advances `phase` by the share of [`PERIOD`] that `elapsed` covers.
102pub fn advance(phase: f32, elapsed: Duration) -> f32 {
103    wrap(phase + elapsed.as_secs_f32() / PERIOD.as_secs_f32())
104}
105
106/// Wraps a phase into 0.0..1.0. Non-finite phases become 0.0.
107pub fn wrap(phase: f32) -> f32 {
108    if !phase.is_finite() {
109        return 0.0;
110    }
111    let wrapped = phase.rem_euclid(1.0);
112    // rem_euclid can round up to exactly 1.0 for tiny negative inputs.
113    if wrapped >= 1.0 { 0.0 } else { wrapped }
114}
115
116/// Start and end angles of the moving arc, clockwise from the top.
117pub fn arc_angles(phase: f32) -> (Radians, Radians) {
118    let start = wrap(phase) * TAU - FRAC_PI_2;
119    (Radians(start), Radians(start + ARC * TAU))
120}
121
122/// The arc image drawn for `phase`: the nearest of the [`FRAMES`] positions.
123pub fn frame(phase: f32) -> usize {
124    let step = (wrap(phase) * FRAMES as f32).round() as usize;
125    step % FRAMES
126}
127
128/// SVG markup for the full circle behind the arc.
129pub fn track_svg(size: Size) -> String {
130    let (centre, radius, stroke) = geometry(size);
131    format!(
132        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{d}" height="{d}" viewBox="0 0 {d} {d}"><circle cx="{centre}" cy="{centre}" r="{radius}" fill="none" stroke="black" stroke-width="{stroke}"/></svg>"#,
133        d = size.diameter(),
134    )
135}
136
137/// SVG markup for the arc at `phase`, running clockwise over [`ARC`] of the
138/// circle from the angle [`arc_angles`] gives.
139pub fn arc_svg(size: Size, phase: f32) -> String {
140    let (centre, radius, stroke) = geometry(size);
141    let (start, end) = arc_angles(phase);
142    let point = |angle: Radians| {
143        let x = centre + radius * angle.0.cos();
144        let y = centre + radius * angle.0.sin();
145        format!("{x:.3} {y:.3}")
146    };
147    let large_arc = u8::from(ARC > 0.5);
148    format!(
149        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{d}" height="{d}" viewBox="0 0 {d} {d}"><path d="M {from} A {radius} {radius} 0 {large_arc} 1 {to}" fill="none" stroke="black" stroke-width="{stroke}" stroke-linecap="round"/></svg>"#,
150        d = size.diameter(),
151        from = point(start),
152        to = point(end),
153    )
154}
155
156/// Centre, radius and stroke width, in the SVG's own units.
157fn geometry(size: Size) -> (f32, f32, f32) {
158    let stroke = size.stroke();
159    let centre = size.diameter() / 2.0;
160    (centre, centre - stroke / 2.0, stroke)
161}
162
163struct Images {
164    track: [Handle; 3],
165    arc: [Vec<Handle>; 3],
166}
167
168static IMAGES: LazyLock<Images> = LazyLock::new(|| Images {
169    track: Size::ALL.map(|size| Handle::from_memory(track_svg(size).into_bytes())),
170    arc: Size::ALL.map(|size| {
171        (0..FRAMES)
172            .map(|step| {
173                let phase = step as f32 / FRAMES as f32;
174                Handle::from_memory(arc_svg(size, phase).into_bytes())
175            })
176            .collect()
177    }),
178});
179
180/// Colours of the track and the moving arc.
181#[derive(Debug, Clone, Copy, PartialEq)]
182pub struct Colours {
183    pub track: Color,
184    pub arc: Color,
185}
186
187/// Resolves the spinner colours.
188pub fn colours(tokens: &Tokens) -> Colours {
189    Colours {
190        track: fade(tokens.foreground, if tokens.is_dark { 0.18 } else { 0.12 }),
191        arc: tokens.foreground,
192    }
193}
194
195/// An svg image tinted with `colour`. iced ignores the alpha of a tint, so
196/// the alpha becomes the image opacity.
197fn image(handle: &Handle, colour: Color) -> advanced_svg::Svg {
198    advanced_svg::Svg::new(handle.clone())
199        .color(opaque(colour))
200        .opacity(colour.a)
201}
202
203impl<Message> Widget<Message, Theme, Renderer> for Spinner {
204    fn size(&self) -> iced::Size<Length> {
205        let diameter = Length::Fixed(self.size.diameter());
206        iced::Size::new(diameter, diameter)
207    }
208
209    fn layout(
210        &mut self,
211        _tree: &mut Tree,
212        _renderer: &Renderer,
213        limits: &layout::Limits,
214    ) -> layout::Node {
215        let diameter = Length::Fixed(self.size.diameter());
216        layout::atomic(limits, diameter, diameter)
217    }
218
219    fn draw(
220        &self,
221        _tree: &Tree,
222        renderer: &mut Renderer,
223        theme: &Theme,
224        _style: &renderer::Style,
225        layout: Layout<'_>,
226        _cursor: mouse::Cursor,
227        _viewport: &Rectangle,
228    ) {
229        let bounds = layout.bounds();
230        let colours = colours(&Tokens::of(theme));
231        let index = self.size.index();
232        renderer.draw_svg(image(&IMAGES.track[index], colours.track), bounds, bounds);
233        let Some(arc) = IMAGES.arc[index].get(frame(self.phase)) else {
234            return;
235        };
236        renderer.draw_svg(image(arc, colours.arc), bounds, bounds);
237    }
238}
239
240impl<'a, Message: 'a> From<Spinner> for Element<'a, Message> {
241    fn from(spinner: Spinner) -> Self {
242        Element::new(spinner)
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::theme::{dark, light};
250
251    const EPSILON: f32 = 1e-5;
252
253    #[test]
254    fn default_builder_is_medium() {
255        let s = spinner(0.25);
256        assert_eq!(s.size, Size::Md);
257        assert!((s.phase() - 0.25).abs() < EPSILON);
258    }
259
260    #[test]
261    fn wrap_keeps_phase_in_one_turn() {
262        assert_eq!(wrap(0.0), 0.0);
263        assert!((wrap(1.25) - 0.25).abs() < EPSILON);
264        assert!((wrap(-0.25) - 0.75).abs() < EPSILON);
265        assert_eq!(wrap(1.0), 0.0);
266        assert!(wrap(-1e-9) < 1.0);
267    }
268
269    #[test]
270    fn wrap_maps_non_finite_to_zero() {
271        assert_eq!(wrap(f32::NAN), 0.0);
272        assert_eq!(wrap(f32::INFINITY), 0.0);
273        assert_eq!(spinner(f32::NEG_INFINITY).phase(), 0.0);
274    }
275
276    #[test]
277    fn advance_moves_by_share_of_period() {
278        let half = PERIOD / 2;
279        assert!((advance(0.0, half) - 0.5).abs() < EPSILON);
280        assert!((advance(0.75, half) - 0.25).abs() < EPSILON);
281        assert_eq!(advance(0.4, Duration::ZERO), 0.4);
282    }
283
284    #[test]
285    fn a_full_period_returns_to_the_same_phase() {
286        let phase = advance(0.3, PERIOD);
287        assert!((phase - 0.3).abs() < EPSILON);
288    }
289
290    #[test]
291    fn arc_starts_at_the_top_and_spans_arc_share() {
292        let (start, end) = arc_angles(0.0);
293        assert!((start.0 + FRAC_PI_2).abs() < EPSILON);
294        assert!((end.0 - start.0 - ARC * TAU).abs() < EPSILON);
295
296        let (quarter, _) = arc_angles(0.25);
297        assert!(quarter.0.abs() < EPSILON);
298    }
299
300    #[test]
301    fn frames_round_the_phase_to_the_nearest_step() {
302        assert_eq!(frame(0.0), 0);
303        assert_eq!(frame(0.5), FRAMES / 2);
304        assert_eq!(frame(1.0 / FRAMES as f32 * 0.4), 0);
305        assert_eq!(frame(1.0 / FRAMES as f32 * 0.6), 1);
306        assert_eq!(frame(0.999), 0);
307        assert_eq!(frame(f32::NAN), 0);
308        assert!((0..1000).all(|i| frame(i as f32 / 997.0) < FRAMES));
309    }
310
311    #[test]
312    fn svgs_fit_the_diameter_and_stroke() {
313        for size in Size::ALL {
314            let d = size.diameter();
315            for markup in [track_svg(size), arc_svg(size, 0.4)] {
316                assert!(
317                    markup.contains(&format!(r#"viewBox="0 0 {d} {d}""#)),
318                    "{markup}"
319                );
320                assert!(markup.contains(&format!(r#"stroke-width="{}""#, size.stroke())));
321            }
322            let (centre, radius, stroke) = geometry(size);
323            assert!((centre + radius + stroke / 2.0 - d).abs() < EPSILON);
324        }
325    }
326
327    #[test]
328    fn arc_starts_at_the_top_at_phase_zero_and_runs_clockwise() {
329        let markup = arc_svg(Size::Lg, 0.0);
330        let (centre, radius, _) = geometry(Size::Lg);
331        assert!(
332            markup.contains(&format!("M {centre:.3} {:.3}", centre - radius)),
333            "{markup}"
334        );
335        assert!(markup.contains(r#"stroke-linecap="round""#));
336        assert!(markup.contains(" 0 0 1 "), "short clockwise arc: {markup}");
337    }
338
339    #[test]
340    fn half_a_turn_starts_the_arc_at_the_bottom() {
341        let markup = arc_svg(Size::Md, 0.5);
342        let (centre, radius, _) = geometry(Size::Md);
343        assert!(
344            markup.contains(&format!("M {centre:.3} {:.3}", centre + radius)),
345            "{markup}"
346        );
347    }
348
349    #[test]
350    fn every_frame_has_its_own_image() {
351        for arcs in &IMAGES.arc {
352            assert_eq!(arcs.len(), FRAMES);
353            let ids: std::collections::HashSet<_> = arcs.iter().map(Handle::id).collect();
354            assert_eq!(ids.len(), FRAMES);
355        }
356    }
357
358    #[test]
359    fn spinner_is_sized_to_its_diameter() {
360        for size in Size::ALL {
361            let s = spinner(0.0).size(size);
362            let d = Length::Fixed(size.diameter());
363            assert_eq!(
364                Widget::<(), Theme, Renderer>::size(&s),
365                iced::Size::new(d, d)
366            );
367        }
368    }
369
370    #[test]
371    fn sizes_grow_monotonically() {
372        let diameters = Size::ALL.map(Size::diameter);
373        assert!(diameters.windows(2).all(|w| w[0] < w[1]));
374        let strokes = Size::ALL.map(Size::stroke);
375        assert!(strokes.windows(2).all(|w| w[0] < w[1]));
376    }
377
378    #[test]
379    fn track_is_fainter_than_arc() {
380        for theme in [light(), dark()] {
381            let tokens = Tokens::of(&theme);
382            let colours = colours(&tokens);
383            assert_eq!(colours.arc, tokens.foreground);
384            assert!(colours.track.a < colours.arc.a);
385        }
386    }
387}