Skip to main content

agg_gui/widgets/
spinner.rs

1//! `Spinner` — a small circular *indeterminate* progress indicator, the
2//! macOS `ProgressView()` / `NSProgressIndicator(style: .spinning)` look.
3//!
4//! Twelve short radial spokes around a circle, the "head" spoke at full
5//! ink and the ones behind it fading out; the head advances one spoke
6//! at a time so the ring appears to rotate. Unlike [`ProgressBar`]
7//! (which reports a known fraction) the spinner carries no value — show
8//! it while something of unknown length is in flight (loading a score,
9//! waiting on a device) and hide it (e.g. via `Conditional`) when done.
10//!
11//! Animation follows the `ProgressBar` pulse pattern: each `paint()`
12//! reads the wall clock for the current step and re-arms a ~frame-rate
13//! wake through [`request_draw_after`](crate::animation::request_draw_after),
14//! so the host loop idles the moment the spinner is culled or hidden —
15//! an invisible subtree is never painted and therefore never re-arms.
16//!
17//! [`ProgressBar`]: super::progress_bar::ProgressBar
18
19use std::time::Duration;
20
21use agg_rust::math_stroke::LineCap;
22use web_time::Instant;
23
24use crate::color::Color;
25use crate::draw_ctx::DrawCtx;
26use crate::event::{Event, EventResult};
27use crate::geometry::{Rect, Size};
28use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
29use crate::widget::Widget;
30
31/// Number of spokes around the ring.
32const SPOKES: usize = 12;
33/// Time per step of the head spoke — one revolution in 12 × 80 ms ≈ 1 s,
34/// close to the AppKit cadence.
35const STEP_MS: u64 = 80;
36/// Lowest spoke alpha (the tail), as a fraction of the head's.
37const TAIL_ALPHA: f32 = 0.12;
38
39/// Diameter presets, mirroring SwiftUI `.controlSize`.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
41#[cfg_attr(feature = "reflect", derive(bevy_reflect::Reflect))]
42pub enum SpinnerSize {
43    /// 16 px — `.controlSize(.small)`, fits on a text line.
44    Small,
45    /// 32 px — `.controlSize(.regular)`.
46    #[default]
47    Regular,
48}
49
50impl SpinnerSize {
51    /// Outer diameter of the ring in logical pixels.
52    pub fn diameter(self) -> f64 {
53        match self {
54            SpinnerSize::Small => 16.0,
55            SpinnerSize::Regular => 32.0,
56        }
57    }
58}
59
60/// An indeterminate circular activity indicator. See the module docs.
61pub struct Spinner {
62    bounds: Rect,
63    children: Vec<Box<dyn Widget>>, // always empty
64    base: WidgetBase,
65    size: SpinnerSize,
66    /// Explicit spoke colour; `None` = theme `text_dim`.
67    color: Option<Color>,
68    /// Time origin for the step phase.
69    anim_start: Instant,
70}
71
72impl Spinner {
73    /// A regular-size spinner.
74    pub fn new() -> Self {
75        Self {
76            bounds: Rect::default(),
77            children: Vec::new(),
78            base: WidgetBase::new(),
79            size: SpinnerSize::Regular,
80            color: None,
81            anim_start: Instant::now(),
82        }
83    }
84
85    /// Shorthand for `Spinner::new().with_size(SpinnerSize::Small)`.
86    pub fn small() -> Self {
87        Self::new().with_size(SpinnerSize::Small)
88    }
89
90    pub fn with_size(mut self, size: SpinnerSize) -> Self {
91        self.size = size;
92        self
93    }
94
95    /// Override the spoke colour (default: theme `text_dim`).
96    pub fn with_color(mut self, color: Color) -> Self {
97        self.color = Some(color);
98        self
99    }
100
101    pub fn with_margin(mut self, m: Insets) -> Self {
102        self.base.margin = m;
103        self
104    }
105    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
106        self.base.h_anchor = h;
107        self
108    }
109    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
110        self.base.v_anchor = v;
111        self
112    }
113
114    pub fn size(&self) -> SpinnerSize {
115        self.size
116    }
117
118    /// Index of the head spoke for an elapsed time.
119    fn step_for(elapsed: Duration) -> usize {
120        ((elapsed.as_millis() / STEP_MS as u128) % SPOKES as u128) as usize
121    }
122
123    /// Alpha of spoke `i` when the head is at `head`: 1.0 at the head,
124    /// falling linearly to [`TAIL_ALPHA`] for the spoke just ahead of it.
125    fn spoke_alpha(i: usize, head: usize) -> f32 {
126        let behind = (head + SPOKES - i) % SPOKES;
127        let t = behind as f32 / (SPOKES - 1) as f32;
128        1.0 - t * (1.0 - TAIL_ALPHA)
129    }
130}
131
132impl Default for Spinner {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138impl Widget for Spinner {
139    fn type_name(&self) -> &'static str {
140        "Spinner"
141    }
142    fn bounds(&self) -> Rect {
143        self.bounds
144    }
145    fn set_bounds(&mut self, b: Rect) {
146        self.bounds = b;
147    }
148    fn children(&self) -> &[Box<dyn Widget>] {
149        &self.children
150    }
151    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
152        &mut self.children
153    }
154    fn margin(&self) -> Insets {
155        self.base.margin
156    }
157    fn widget_base(&self) -> Option<&WidgetBase> {
158        Some(&self.base)
159    }
160    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
161        Some(&mut self.base)
162    }
163    fn h_anchor(&self) -> HAnchor {
164        self.base.h_anchor
165    }
166    fn v_anchor(&self) -> VAnchor {
167        self.base.v_anchor
168    }
169    fn min_size(&self) -> Size {
170        self.base.min_size
171    }
172    fn max_size(&self) -> Size {
173        self.base.max_size
174    }
175
176    fn measure_min_height(&self, _available_w: f64) -> f64 {
177        self.size.diameter()
178    }
179
180    fn layout(&mut self, _available: Size) -> Size {
181        let d = self.size.diameter();
182        let size = Size::new(d, d);
183        self.bounds = Rect::new(self.bounds.x, self.bounds.y, d, d);
184        size
185    }
186
187    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
188        let d = self.size.diameter();
189        // Centre in whatever bounds the parent gave us (a stretch anchor
190        // may hand us more room than the ring needs).
191        let cx = self.bounds.width * 0.5;
192        let cy = self.bounds.height * 0.5;
193        let outer_r = d * 0.5;
194        let inner_r = d * 0.27;
195        let line_w = (d * 0.085).max(1.0);
196        let ink = self.color.unwrap_or_else(|| ctx.visuals().text_dim);
197        let head = Self::step_for(self.anim_start.elapsed());
198
199        ctx.set_line_width(line_w);
200        ctx.set_line_cap(LineCap::Round);
201        for i in 0..SPOKES {
202            // Spoke 0 at 12 o'clock, advancing clockwise (Y-up space).
203            let a =
204                std::f64::consts::FRAC_PI_2 - (i as f64) * std::f64::consts::TAU / SPOKES as f64;
205            let (dx, dy) = (a.cos(), a.sin());
206            ctx.set_stroke_color(ink.with_alpha(ink.a * Self::spoke_alpha(i, head)));
207            ctx.begin_path();
208            ctx.move_to(cx + dx * inner_r, cy + dy * inner_r);
209            ctx.line_to(
210                cx + dx * (outer_r - line_w * 0.5),
211                cy + dy * (outer_r - line_w * 0.5),
212            );
213            ctx.stroke();
214        }
215
216        // Re-arm for the next step. Only runs while actually painted, so a
217        // hidden spinner does not keep the loop awake.
218        crate::animation::request_draw_after_tagged(Duration::from_millis(STEP_MS), "spinner.step");
219    }
220
221    fn on_event(&mut self, _event: &Event) -> EventResult {
222        EventResult::Ignored
223    }
224
225    fn properties(&self) -> Vec<(&'static str, String)> {
226        vec![("size", format!("{:?}", self.size))]
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::tests::paint_recorder::PaintRecorder;
234
235    #[test]
236    fn sizes_match_the_control_size_presets() {
237        let mut s = Spinner::small();
238        assert_eq!(s.layout(Size::new(200.0, 200.0)), Size::new(16.0, 16.0));
239        assert_eq!(s.measure_min_height(200.0), 16.0);
240        let mut r = Spinner::new();
241        assert_eq!(r.layout(Size::new(200.0, 200.0)), Size::new(32.0, 32.0));
242        assert_eq!(r.size(), SpinnerSize::Regular);
243    }
244
245    #[test]
246    fn head_advances_one_spoke_per_step_and_wraps() {
247        assert_eq!(Spinner::step_for(Duration::from_millis(0)), 0);
248        assert_eq!(Spinner::step_for(Duration::from_millis(STEP_MS)), 1);
249        assert_eq!(
250            Spinner::step_for(Duration::from_millis(STEP_MS * SPOKES as u64)),
251            0,
252            "a full revolution wraps to the first spoke"
253        );
254        assert_eq!(
255            Spinner::step_for(Duration::from_millis(STEP_MS * (SPOKES as u64 + 3))),
256            3
257        );
258    }
259
260    #[test]
261    fn spoke_alpha_is_full_at_the_head_and_fades_behind_it() {
262        let head = 4;
263        assert_eq!(Spinner::spoke_alpha(head, head), 1.0);
264        let one_behind = Spinner::spoke_alpha(3, head);
265        let two_behind = Spinner::spoke_alpha(2, head);
266        assert!(one_behind < 1.0 && two_behind < one_behind);
267        // The spoke just ahead of the head is the tail (oldest).
268        let tail = Spinner::spoke_alpha(5, head);
269        assert!((tail - TAIL_ALPHA).abs() < 1e-6);
270    }
271
272    #[test]
273    fn paint_strokes_every_spoke_and_keeps_the_loop_awake() {
274        let mut s = Spinner::new();
275        s.layout(Size::new(100.0, 100.0));
276        s.set_bounds(Rect::new(0.0, 0.0, 32.0, 32.0));
277        crate::animation::clear_draw_request();
278        let mut rec = PaintRecorder::new();
279        s.paint(&mut rec);
280        assert_eq!(rec.strokes.len(), SPOKES);
281        assert!(rec.fills.is_empty(), "spokes only — no filled shapes");
282        // Exactly one spoke is drawn at the peak alpha (the head); the
283        // others fade behind it.
284        let peak = rec
285            .strokes
286            .iter()
287            .map(|op| op.color.a)
288            .fold(0.0_f32, f32::max);
289        let at_peak = rec
290            .strokes
291            .iter()
292            .filter(|op| (op.color.a - peak).abs() < 1e-6)
293            .count();
294        assert_eq!(at_peak, 1);
295        assert!(
296            crate::animation::peek_next_draw_deadline().is_some(),
297            "paint re-arms a scheduled draw for the next step"
298        );
299        crate::animation::clear_draw_request();
300    }
301
302    #[test]
303    fn explicit_color_overrides_the_theme_ink() {
304        let mut s = Spinner::small().with_color(Color::rgb(1.0, 0.0, 0.0));
305        s.layout(Size::new(50.0, 50.0));
306        s.set_bounds(Rect::new(0.0, 0.0, 16.0, 16.0));
307        let mut rec = PaintRecorder::new();
308        s.paint(&mut rec);
309        assert!(rec
310            .strokes
311            .iter()
312            .all(|op| op.color.r == 1.0 && op.color.g == 0.0 && op.color.b == 0.0));
313        crate::animation::clear_draw_request();
314    }
315}