agg_gui/widgets/
spinner.rs1use 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
31const SPOKES: usize = 12;
33const STEP_MS: u64 = 80;
36const TAIL_ALPHA: f32 = 0.12;
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
41#[cfg_attr(feature = "reflect", derive(bevy_reflect::Reflect))]
42pub enum SpinnerSize {
43 Small,
45 #[default]
47 Regular,
48}
49
50impl SpinnerSize {
51 pub fn diameter(self) -> f64 {
53 match self {
54 SpinnerSize::Small => 16.0,
55 SpinnerSize::Regular => 32.0,
56 }
57 }
58}
59
60pub struct Spinner {
62 bounds: Rect,
63 children: Vec<Box<dyn Widget>>, base: WidgetBase,
65 size: SpinnerSize,
66 color: Option<Color>,
68 anim_start: Instant,
70}
71
72impl Spinner {
73 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 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 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 fn step_for(elapsed: Duration) -> usize {
120 ((elapsed.as_millis() / STEP_MS as u128) % SPOKES as u128) as usize
121 }
122
123 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 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 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 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 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 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}