Skip to main content

teksilo_widgets/
spinner.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Spinner` — a shader-driven circular-arc loading indicator.
5//!
6//! Uses the same per-slot uniform-buffer pipeline as
7//! [`ProgressBar::indeterminate`](crate::ProgressBar) (an
8//! [`AnimatedQuadKind`] variant), so per-frame cost is one
9//! `queue.write_buffer(64 B) + draw_indexed` — the widget's `paint()`
10//! does not re-run between frames and there's no signal-dirty-mark
11//! cascade.
12//!
13//! ```rust
14//! # use teksilo_widgets::Spinner;
15//! # use teksilo_tokens::TextRole;
16//! # use teksilo_i18n::lit;
17//! let _s = Spinner::new(24.0)
18//!     .color(TextRole::Secondary)
19//!     .label(lit!("Loading"));
20//! ```
21//!
22//! Defaults match the typical CSS spinner: a quarter-circle (90°)
23//! arc rotating clockwise from the top, completing one full
24//! rotation every 900 ms.
25//!
26//! Honours `prefers-reduced-motion`: registers no animated quad and
27//! falls back to a static three-quarter arc — the indicator is still
28//! visible (so the user can tell the surface is busy) but doesn't
29//! rotate.
30
31use std::time::Duration;
32
33use teksilo_canvas::{AnimatedQuadClass, Canvas, Path, Rect, Size, SizeProposal, StrokeStyle};
34use teksilo_core::accessibility::AccessNodeBuilder;
35use teksilo_core::animated_quad::{AnimatedQuadHandle, AnimatedQuadKind};
36use teksilo_core::color_prop::ColorProp;
37use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
38use teksilo_core::widget_id::WidgetId;
39use teksilo_i18n::LocalizedString;
40use teksilo_tokens::TextRole;
41
42const DEFAULT_SIZE: f32 = 20.0;
43const DEFAULT_PERIOD: Duration = Duration::from_millis(900);
44const DEFAULT_ARC_FRACTION: f32 = 0.25;
45const DEFAULT_STROKE_FRACTION: f32 = 0.12;
46
47/// A circular-arc loading indicator driven by a GPU shader quad.
48///
49/// Decorative — pair with [`.label`](Self::label) to give screen readers
50/// context. Honours `prefers-reduced-motion` by falling back to a static
51/// three-quarter arc.
52pub struct Spinner {
53    size: f32,
54    period: Duration,
55    arc_fraction: f32,
56    stroke_fraction: f32,
57    color: ColorProp,
58    label: Option<LocalizedString>,
59    handle: Option<AnimatedQuadHandle>,
60}
61
62impl Spinner {
63    /// Construct a spinner of the given square edge length (logical
64    /// pixels). Use small sizes (16–24) for inline spinners and
65    /// larger (32–64) for full-content placeholders.
66    pub fn new(size: f32) -> Self {
67        Self {
68            size,
69            period: DEFAULT_PERIOD,
70            arc_fraction: DEFAULT_ARC_FRACTION,
71            stroke_fraction: DEFAULT_STROKE_FRACTION,
72            color: TextRole::Secondary.into(),
73            label: None,
74            handle: None,
75        }
76    }
77
78    /// Override the rotation period. Default: 900 ms (one full
79    /// rotation per period).
80    pub fn period(mut self, period: Duration) -> Self {
81        self.period = period;
82        self
83    }
84
85    /// Override the arc length as a fraction of the full circle.
86    /// Default: 0.25 (a quarter-circle "comet tail" arc).
87    pub fn arc_fraction(mut self, arc_fraction: f32) -> Self {
88        self.arc_fraction = arc_fraction.clamp(0.0, 1.0);
89        self
90    }
91
92    /// Override the stroke thickness as a fraction of the spinner's
93    /// edge length. Default: 0.12 (so a 24-px spinner has a ~3-px
94    /// stroke).
95    pub fn stroke_fraction(mut self, stroke_fraction: f32) -> Self {
96        self.stroke_fraction = stroke_fraction.clamp(0.0, 0.5);
97        self
98    }
99
100    /// Override the arc colour. Default: `TextRole::Secondary` so the
101    /// spinner picks up theme-aware text-tier styling.
102    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
103        self.color = color.into();
104        self
105    }
106
107    /// Accessible name (e.g. "Loading", "Uploading file"). Without
108    /// this, screen readers announce a bare "progress indicator"
109    /// with no context.
110    pub fn label(mut self, text: impl Into<LocalizedString>) -> Self {
111        let ls: LocalizedString = text.into();
112        self.label = Some(ls);
113        self
114    }
115}
116
117impl std::fmt::Debug for Spinner {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("Spinner")
120            .field("size", &self.size)
121            .field("period", &self.period)
122            .finish()
123    }
124}
125
126impl Widget for Spinner {
127    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
128        // Skip the shader registration entirely under reduced motion
129        // — the static fallback in `paint()` doesn't need a slot, and
130        // the registry not having an entry stops the per-frame phase
131        // tick.
132        if ctx.prefers_reduced_motion() {
133            self.handle = None;
134        } else {
135            self.handle = Some(ctx.animated_quad(AnimatedQuadKind::SpinnerArc {
136                period: self.period,
137                arc_fraction: self.arc_fraction,
138                stroke_fraction: self.stroke_fraction,
139                color: self.color.clone(),
140            }));
141        }
142        vec![]
143    }
144
145    fn layout_response(
146        &self,
147        _proposal: SizeProposal,
148        _ctx: &LayoutContext,
149    ) -> teksilo_core::widget::LayoutResponse {
150        Size::new(self.size, self.size).into()
151    }
152
153    fn place_children(
154        &self,
155        _bounds: Rect,
156        _proposal: SizeProposal,
157        _children: &mut [WidgetPlacement],
158        _ctx: &LayoutContext,
159    ) {
160    }
161
162    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
163        if let Some(handle) = self.handle {
164            // Animated path: emit a single AnimatedQuad. The shader
165            // computes the arc each frame from the per-slot phase.
166            canvas.draw_animated_quad(bounds, handle.slot(), AnimatedQuadClass::Procedural);
167        } else {
168            // Reduced-motion fallback: draw a static three-quarter
169            // arc, leading edge at the top. Communicates "busy"
170            // without rotating.
171            let color = self.color.resolve(ctx.theme, ctx.effective_enabled);
172            let extent = bounds.width.min(bounds.height);
173            let stroke_w = extent * self.stroke_fraction;
174            // Inscribe inside the bounds, leaving room for the stroke
175            // so it doesn't get clipped.
176            let inset = stroke_w * 0.5;
177            let inscribed = Rect::new(
178                bounds.x + inset,
179                bounds.y + inset,
180                bounds.width - inset * 2.0,
181                bounds.height - inset * 2.0,
182            );
183            let mut path = Path::new();
184            // Path::arc_to angles: 0° at 3 o'clock; offset -90° to
185            // start at the top. Sweep the arc clockwise.
186            path.arc_to(inscribed, -90.0, self.arc_fraction * 360.0);
187            canvas.stroke_path(&path, color, StrokeStyle::solid(stroke_w));
188        }
189    }
190
191    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
192        builder.set_role(teksilo_core::accesskit::Role::ProgressIndicator);
193        // Indeterminate (no numeric value); polite live region so
194        // screen readers don't interrupt the user.
195        builder.set_live(teksilo_core::accesskit::Live::Polite);
196        if let Some(ref label) = self.label {
197            builder.set_name(label.clone());
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use teksilo_canvas::DrawCommand;
206    use teksilo_core::widget_tree::WidgetTree;
207    use teksilo_i18n::lit;
208
209    #[test]
210    fn spinner_size() {
211        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
212        let id = tree.add(Spinner::new(32.0));
213        // Pass `None` for both axes so the layout pass uses the
214        // spinner's natural (square) `size_that_fits` instead of
215        // forcing the proposal dimensions.
216        tree.layout(SizeProposal {
217            width: None,
218            height: None,
219        });
220        let b = tree.bounds(id);
221        assert!((b.width - 32.0).abs() < 0.01);
222        assert!((b.height - 32.0).abs() < 0.01);
223    }
224
225    #[test]
226    fn spinner_emits_one_animated_quad_when_motion_allowed() {
227        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
228        tree.add(Spinner::new(24.0));
229        tree.layout(SizeProposal::exact(64.0, 64.0));
230        let frame = tree.render();
231        assert_eq!(
232            frame.animated_quads.len(),
233            1,
234            "spinner with motion enabled should emit exactly one AnimatedQuad"
235        );
236        // No path commands — the shader does the rendering.
237        let path_count = frame
238            .draw_order
239            .iter()
240            .filter(|c| matches!(c, DrawCommand::Path(_)))
241            .count();
242        assert_eq!(path_count, 0);
243    }
244
245    #[test]
246    fn spinner_emits_static_path_under_reduced_motion() {
247        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
248        // high_contrast = false, reduced_motion = true, text_scale = 1.0
249        tree.set_accessibility_preferences(false, true, 1.0);
250        tree.add(Spinner::new(24.0));
251        tree.layout(SizeProposal::exact(64.0, 64.0));
252        let frame = tree.render();
253        assert_eq!(
254            frame.animated_quads.len(),
255            0,
256            "no animated quad should register when reduced-motion is on"
257        );
258        let path_count = frame
259            .draw_order
260            .iter()
261            .filter(|c| matches!(c, DrawCommand::Path(_)))
262            .count();
263        assert!(
264            path_count >= 1,
265            "reduced-motion fallback should emit at least one Path draw command"
266        );
267    }
268
269    #[test]
270    fn spinner_phase_advances_between_frames() {
271        // Same shape as ProgressBar's animation test: the per-slot
272        // phase must change between frames so the shader actually
273        // animates without paint() re-running.
274        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
275        tree.add(Spinner::new(24.0));
276        tree.layout(SizeProposal::exact(64.0, 64.0));
277
278        let frame1 = tree.render();
279        assert_eq!(frame1.animated_quads.len(), 1);
280        let phase1 = frame1.anim_params[frame1.animated_quads[0].slot as usize].phase;
281
282        std::thread::sleep(Duration::from_millis(100));
283        let frame2 = tree.render();
284        let phase2 = frame2.anim_params[frame2.animated_quads[0].slot as usize].phase;
285        assert_ne!(phase1, phase2, "spinner phase must advance between frames");
286    }
287
288    #[test]
289    fn accessibility_role_and_live_region() {
290        let mut tree = WidgetTree::new();
291        let id = tree.add(Spinner::new(24.0).label(lit!("Loading")));
292        tree.layout(SizeProposal::exact(64.0, 64.0));
293        let info = tree.accessibility_node(id);
294        assert_eq!(
295            info.role(),
296            teksilo_core::accesskit::Role::ProgressIndicator
297        );
298        assert_eq!(info.name(), Some("Loading"));
299    }
300}