teksilo_widgets/
spinner.rs1use 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
47pub 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 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 pub fn period(mut self, period: Duration) -> Self {
81 self.period = period;
82 self
83 }
84
85 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 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 pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
103 self.color = color.into();
104 self
105 }
106
107 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 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 canvas.draw_animated_quad(bounds, handle.slot(), AnimatedQuadClass::Procedural);
167 } else {
168 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 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(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 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 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 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 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 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}