1use 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
15pub const PERIOD: Duration = Duration::from_millis(900);
17
18pub const ARC: f32 = 0.3;
20
21#[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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct Spinner {
55 phase: f32,
56 size: Size,
57}
58
59pub 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 pub fn phase(&self) -> f32 {
75 self.phase
76 }
77}
78
79pub fn advance(phase: f32, elapsed: Duration) -> f32 {
81 wrap(phase + elapsed.as_secs_f32() / PERIOD.as_secs_f32())
82}
83
84pub fn wrap(phase: f32) -> f32 {
86 if !phase.is_finite() {
87 return 0.0;
88 }
89 let wrapped = phase.rem_euclid(1.0);
90 if wrapped >= 1.0 { 0.0 } else { wrapped }
92}
93
94pub 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#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct Colours {
103 pub track: Color,
104 pub arc: Color,
105}
106
107pub 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}