Skip to main content

elegance/
spinner.rs

1//! Animated loading spinner — a sweeping arc inside a faint track ring.
2//!
3//! The arc rotates steadily while its sweep length "breathes" between a
4//! short tick and a three-quarter circle, giving a Material Design-like
5//! loading animation. The spinner requests continuous repaints while
6//! visible so the animation keeps running.
7
8use std::f32::consts::TAU;
9
10use egui::{
11    epaint::{PathShape, PathStroke},
12    pos2, Color32, Response, Sense, Ui, Vec2, Widget, WidgetInfo, WidgetType,
13};
14
15use crate::theme::{with_alpha, Accent, Theme};
16
17/// A themed loading spinner.
18///
19/// ```no_run
20/// # use elegance::{Accent, Spinner};
21/// # egui::__run_test_ui(|ui| {
22/// ui.add(Spinner::new());
23/// ui.add(Spinner::new().size(28.0).accent(Accent::Green));
24/// # });
25/// ```
26#[derive(Debug, Clone, Copy)]
27#[must_use = "Add with `ui.add(...)`."]
28pub struct Spinner {
29    size: f32,
30    thickness: Option<f32>,
31    color: Option<Color32>,
32    accent: Option<Accent>,
33}
34
35impl Default for Spinner {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl Spinner {
42    /// Create a spinner with the default size (18 pt) and the theme's sky accent.
43    pub fn new() -> Self {
44        Self {
45            size: 18.0,
46            thickness: None,
47            color: None,
48            accent: None,
49        }
50    }
51
52    /// Diameter of the spinner in points. Default: 18.
53    pub fn size(mut self, size: f32) -> Self {
54        self.size = size;
55        self
56    }
57
58    /// Stroke thickness of the arc. Defaults to ~12 % of `size` (min 1.5 pt).
59    pub fn thickness(mut self, thickness: f32) -> Self {
60        self.thickness = Some(thickness);
61        self
62    }
63
64    /// Paint the arc with an explicit colour. Clears any previously set accent.
65    pub fn color(mut self, color: Color32) -> Self {
66        self.color = Some(color);
67        self.accent = None;
68        self
69    }
70
71    /// Pick the arc colour from one of the theme's accents. Clears any
72    /// previously set explicit colour.
73    pub fn accent(mut self, accent: Accent) -> Self {
74        self.accent = Some(accent);
75        self.color = None;
76        self
77    }
78}
79
80impl Widget for Spinner {
81    fn ui(self, ui: &mut Ui) -> Response {
82        let theme = Theme::current(ui.ctx());
83        let color = match (self.color, self.accent) {
84            (Some(c), _) => c,
85            (_, Some(a)) => theme.palette.accent_fill(a),
86            _ => theme.palette.sky,
87        };
88        let thickness = self.thickness.unwrap_or((self.size * 0.12).max(1.5));
89
90        let (rect, response) = ui.allocate_exact_size(Vec2::splat(self.size), Sense::hover());
91
92        if ui.is_rect_visible(rect) {
93            crate::request_repaint_at_rate(ui.ctx(), 30.0);
94            let painter = ui.painter();
95            let center = rect.center();
96            let radius = (self.size * 0.5) - thickness * 0.5 - 1.0;
97            let point_at = |a: f32| {
98                let (sin, cos) = a.sin_cos();
99                pos2(center.x + radius * cos, center.y + radius * sin)
100            };
101
102            // Dim track ring. Built as a polyline (not `circle_stroke`)
103            // so it lives on the same primitive as the arc below and
104            // lands on identical pixels.
105            let n_full: usize = 96;
106            let track_points: Vec<_> = (0..n_full)
107                .map(|i| point_at((i as f32 / n_full as f32) * TAU))
108                .collect();
109            painter.add(PathShape::closed_line(
110                track_points,
111                PathStroke::new(thickness, with_alpha(color, 40)),
112            ));
113
114            // Sweeping arc: rotates steadily while its length breathes
115            // between ~29° and ~270°. The base rotation is fast enough
116            // that even while the arc is contracting, its trailing end
117            // still advances (never visually spins backward).
118            let t = ui.input(|i| i.time) as f32;
119            let phase = 0.5 - 0.5 * (t * 1.3).cos();
120            let sweep_min = TAU * 0.08;
121            let sweep_max = TAU * 0.75;
122            let sweep = sweep_min + (sweep_max - sweep_min) * phase;
123            let rotation = t * TAU * 0.85;
124
125            let n_points = 48;
126            let points: Vec<_> = (0..=n_points)
127                .map(|i| point_at(rotation + (i as f32 / n_points as f32) * sweep))
128                .collect();
129
130            // Rounded caps, since PathShape strokes are butt-ended.
131            painter.circle_filled(points[0], thickness * 0.5, color);
132            painter.circle_filled(points[n_points], thickness * 0.5, color);
133            painter.add(PathShape::line(points, PathStroke::new(thickness, color)));
134        }
135
136        response
137            .widget_info(|| WidgetInfo::labeled(WidgetType::ProgressIndicator, true, "loading"));
138        response
139    }
140}