1use 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
26pub const PERIOD: Duration = Duration::from_millis(900);
28
29pub const ARC: f32 = 0.3;
31
32pub const FRAMES: usize = 60;
34
35#[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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
76pub struct Spinner {
77 phase: f32,
78 size: Size,
79}
80
81pub 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 pub fn phase(&self) -> f32 {
97 self.phase
98 }
99}
100
101pub fn advance(phase: f32, elapsed: Duration) -> f32 {
103 wrap(phase + elapsed.as_secs_f32() / PERIOD.as_secs_f32())
104}
105
106pub fn wrap(phase: f32) -> f32 {
108 if !phase.is_finite() {
109 return 0.0;
110 }
111 let wrapped = phase.rem_euclid(1.0);
112 if wrapped >= 1.0 { 0.0 } else { wrapped }
114}
115
116pub 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
122pub fn frame(phase: f32) -> usize {
124 let step = (wrap(phase) * FRAMES as f32).round() as usize;
125 step % FRAMES
126}
127
128pub 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
137pub 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
156fn 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#[derive(Debug, Clone, Copy, PartialEq)]
182pub struct Colours {
183 pub track: Color,
184 pub arc: Color,
185}
186
187pub 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
195fn 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}