denise_ui/widgets/spinner.rs
1//! A rotating arc, for when there is nothing to report but that something is
2//! happening.
3
4use denise::Role;
5use denise::{Pen, TURN};
6
7use crate::motion::{Motion, Wake};
8use crate::widget::{Animation, PaintCtx, Widget};
9use crate::widgets::describe::{
10 Describe, DynDescribe, Group, Mismatch, Property, PropertyKind, ROLES, Value,
11};
12use crate::widgets::radial::{ring, ring_colors, thickness_for};
13
14/// How long one revolution takes by default.
15const PERIOD_MS: u64 = 1_000;
16
17/// The shortest revolution a spinner will accept.
18///
19/// A period below the sampling interval would turn more than a full circle
20/// between frames, which is a spinner that looks stopped or, worse, looks like
21/// it is going backwards. The tree's interval is not knowable here — it belongs
22/// to [`Motion`] and can be changed after this widget is built — so the clamp
23/// uses the default one, which is the fastest rate anybody is likely to set.
24const MIN_PERIOD_MS: u64 = Motion::DEFAULT_INTERVAL_MS;
25
26/// How much of the ring the moving arc covers.
27///
28/// Three quarters: enough gap to see it turning, enough arc to read as a ring
29/// rather than as a fragment.
30const SWEEP: i32 = TURN * 3 / 4;
31
32/// An indeterminate activity indicator: an arc that goes round and round.
33///
34/// Not interactive, not focusable, not a tab stop, and it holds no value — a
35/// spinner that could show progress would be
36/// [`RadialProgress`](super::RadialProgress).
37///
38/// # It must be started, and it must be stopped
39///
40/// **This is the widget that can keep a device awake.** It is unbounded by
41/// nature: [`animate`](Widget::animate) never answers [`Wake::Never`] while the
42/// node is visible, which is exactly what
43/// [`Ui::request_animation`](crate::Ui::request_animation) says it is allowed to
44/// do and exactly what it costs.
45///
46/// So it does not start itself. A spinner receives no events, so it cannot ask
47/// for frames from an event handler; the application asks, at the moment it
48/// decides something is loading:
49///
50/// ```
51/// # use denise::{Rect, Size, theme};
52/// # use denise_ui::{Ui, widgets::Panel};
53/// # #[derive(Clone, Debug)] enum Msg { Noop }
54/// # fn demo() -> Option<()> {
55/// # let mut ui: Ui<Msg> = Ui::new(Size::new(1920, 1080), theme::DARK);
56/// # let root = ui.root();
57/// # use denise_ui::Spinner;
58/// let id = ui.add(root, Spinner::new(), Rect::new(100, 80, 48, 48))?;
59/// ui.request_animation(id);
60/// # Some(()) }
61/// ```
62///
63/// That is not an awkwardness to paper over with a constructor that does it
64/// invisibly. Keeping a CPU awake is a decision, and this puts it at the line
65/// where somebody made it.
66///
67/// **Stopping is hiding.** `Ui::set_visible(id, false)` — or removing the node
68/// — takes it out of the animating set, and
69/// [`Ui::animating`](crate::Ui::animating) drops back to zero. A spinner left
70/// visible on a screen nobody is looking at is a device that never idles, and
71/// nothing in the toolkit will notice on your behalf.
72///
73/// # Shape
74///
75/// A faint full ring with a brighter arc turning inside it, inscribed in the
76/// rectangle it is given like [`RadialProgress`](super::RadialProgress) and
77/// sharing its geometry — the same centre, radius and thickness rules, so a
78/// spinner and a ring of the same size are the same ring.
79#[derive(Clone, Copy, Debug)]
80pub struct Spinner {
81 role: Role,
82 thickness: Option<i32>,
83 period_ms: u64,
84 /// This spinner's own sampling interval, overriding the tree's.
85 ///
86 /// `None` — the usual case — means it turns at whatever rate
87 /// [`Motion`](crate::Motion) says, along with everything else.
88 frame_ms: Option<u64>,
89 /// How far into the current revolution the arc is, in milliseconds.
90 ///
91 /// **Time accumulates, not angle.** Adding a per-frame angle would truncate
92 /// once per frame and lose a little of every lap — at 20 fps and a one
93 /// second period that is 16 units of 65536 a lap, which is invisible and
94 /// still wrong. Accumulating the milliseconds and deriving the angle from
95 /// them means a whole period is exactly a whole turn, forever.
96 phase_ms: u64,
97 /// The clock reading `angle` was computed at, or `None` before the first
98 /// frame.
99 last_ms: Option<u64>,
100}
101
102impl Spinner {
103 /// A spinner in [`Role::Primary`], one revolution a second.
104 pub fn new() -> Self {
105 Self {
106 role: Role::Primary,
107 thickness: None,
108 period_ms: PERIOD_MS,
109 frame_ms: None,
110 phase_ms: 0,
111 last_ms: None,
112 }
113 }
114
115 /// Sets the colour of the moving arc.
116 pub fn with_role(mut self, role: Role) -> Self {
117 self.role = role;
118 self
119 }
120
121 /// Sets the ring's thickness in pixels, instead of deriving it from the
122 /// radius.
123 pub fn with_thickness(mut self, thickness: i32) -> Self {
124 self.thickness = Some(thickness);
125 self
126 }
127
128 /// Sets how long one revolution takes.
129 ///
130 /// Clamped to the default sampling interval,
131 /// [`Motion::DEFAULT_INTERVAL_MS`](crate::Motion::DEFAULT_INTERVAL_MS): a
132 /// period shorter than a frame turns more than a full circle between them,
133 /// which looks stopped or, worse, looks like it is going backwards.
134 pub fn with_period_ms(mut self, period_ms: u64) -> Self {
135 self.period_ms = period_ms.max(MIN_PERIOD_MS);
136 self
137 }
138
139 /// Gives this spinner its own sampling interval, in milliseconds.
140 ///
141 /// **Almost nothing should call this.** The rate belongs to the tree —
142 /// [`Ui::set_motion`](crate::Ui::set_motion) — so that one decision covers
143 /// every moving thing on the panel and a deployment can turn all of it down
144 /// at once. This is the escape hatch for the spinner that genuinely differs
145 /// from everything around it: a ring that must keep turning smoothly on a
146 /// panel whose other animation has been coarsened, or a decorative one that
147 /// should cost less than the rest.
148 ///
149 /// It overrides [`Motion::Every`] and is overridden by
150 /// [`Motion::None`](crate::Motion::None) in turn — reduced motion is a
151 /// person's decision, and a widget does not get to opt out of it.
152 pub fn with_frame_ms(mut self, frame_ms: u64) -> Self {
153 self.frame_ms = Some(frame_ms.max(1));
154 self
155 }
156
157 /// Where the arc currently starts, in [`TURN`] units.
158 #[inline]
159 pub fn angle(&self) -> i32 {
160 angle_at(self.phase_ms, self.period_ms)
161 }
162
163 /// Replaces the colour role.
164 pub fn set_role(&mut self, role: Role) {
165 self.role = role;
166 }
167
168 /// Replaces the revolution period, clamped as [`Spinner::with_period_ms`].
169 pub fn set_period_ms(&mut self, period_ms: u64) {
170 self.period_ms = period_ms.max(MIN_PERIOD_MS);
171 }
172
173 /// Sets or clears this spinner's own sampling interval — see
174 /// [`Spinner::with_frame_ms`], which is where the argument for not using it
175 /// is written down.
176 pub fn set_frame_ms(&mut self, frame_ms: Option<u64>) {
177 self.frame_ms = frame_ms.map(|ms| ms.max(1));
178 }
179}
180
181impl Default for Spinner {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187/// The arc's start angle at `phase_ms` into a revolution of `period_ms`.
188///
189/// A pure function of the phase, which is what makes a whole period exactly a
190/// whole turn: nothing is accumulated in [`TURN`] units, so nothing rounds
191/// twice.
192fn angle_at(phase_ms: u64, period_ms: u64) -> i32 {
193 let period = period_ms.max(1);
194 ((phase_ms % period) * TURN as u64 / period) as i32
195}
196
197impl<M: 'static> Widget<M> for Spinner {
198 fn describe(&self) -> Option<&dyn DynDescribe> {
199 Some(self)
200 }
201
202 fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
203 Some(self)
204 }
205 fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
206 let bounds = ctx.bounds;
207 if bounds.is_empty() {
208 return;
209 }
210 let (centre, radius) = ring(bounds);
211 if radius <= 0 {
212 return;
213 }
214 let thickness = thickness_for(radius, self.thickness);
215
216 // Shared with `RadialProgress` so a spinner and a ring of the same size
217 // are the same ring — including when disabled, where the arc has to
218 // stay distinguishable from the track it sits on.
219 let (track, arc) = ring_colors(ctx.theme, ctx.state, self.role);
220 canvas.stroke_circle(centre, radius, thickness, track);
221 canvas.stroke_arc(centre, radius, thickness, self.angle(), SWEEP, arc);
222 }
223
224 fn animate(&mut self, now_ms: u64) -> Animation {
225 // The first frame establishes the epoch and moves nothing: without this
226 // the spinner would jump by however long the application had been
227 // running before somebody asked it to spin.
228 let elapsed = match self.last_ms {
229 Some(last) => now_ms.saturating_sub(last),
230 None => 0,
231 };
232 self.last_ms = Some(now_ms);
233
234 // Capped at one period, so a spinner hidden for an hour and shown again
235 // resumes rather than winding an hour of rotation forward to land in the
236 // same place.
237 //
238 // The modulo here bounds the *field*, and `angle_at` takes it again to
239 // stay a total function of whatever it is handed. Either alone would
240 // draw the same pixels — a mutation removing this one changes nothing
241 // observable, which is how that was established — and both stay for the
242 // reason `Progress::fill_width` keeps its own belt-and-braces guard: a
243 // check that relies on its only caller staying careful is not a check.
244 let before = self.angle();
245 let period = self.period_ms.max(1);
246 self.phase_ms = (self.phase_ms + elapsed.min(period)) % period;
247
248 Animation {
249 // A frame that moved nothing owes no repaint. The tree wakes for the
250 // most impatient animation and asks everybody, so a spinner is
251 // routinely asked before the time it wanted.
252 repaint: self.angle() != before,
253 // Never `Wake::Never`. This is the unbounded case #19 made
254 // expressible, and the only thing that stops it is the node going
255 // away — or motion being turned off, which is what [`Widget::snap`] is for.
256 //
257 // How fast "animating" is belongs to the tree. The override says a
258 // time instead, saturating because the clock is the application's
259 // and its value is not this widget's to assume anything about.
260 next: match self.frame_ms {
261 None => Wake::Animating,
262 Some(ms) => Wake::At(now_ms.saturating_add(ms)),
263 },
264 }
265 }
266
267 /// Nothing to land: a spinner has no end state to arrive at, so under
268 /// [`Motion::None`](crate::Motion::None) it simply stops turning and leaves
269 /// a still ring. That is the honest reading of "no motion" for the one
270 /// widget that is nothing but motion.
271 fn snap(&mut self, _now_ms: u64) -> Animation {
272 Animation::NONE
273 }
274}
275
276impl Describe for Spinner {
277 const KIND: &'static str = "spinner";
278 const DOC: &'static str =
279 "A turning arc, for when all there is to say is that something is happening.";
280 const GROUP: Group = Group::Indicator;
281 const ICON: &'static denise::icon::Icon = &super::icons::SPINNER;
282
283 const PROPERTIES: &'static [Property] = &[
284 Property::new(
285 "role",
286 PropertyKind::Enum(ROLES),
287 "Colour of the moving arc. The faint track behind it is derived from the same role.",
288 ),
289 Property::new(
290 "thickness",
291 PropertyKind::Int { min: 1, max: 64 },
292 "Ring width in pixels. Derived from the node's size without it.",
293 )
294 .in_pixels(),
295 Property::new(
296 "period-ms",
297 // The floor is the clamp `set_period_ms` applies, named rather than
298 // repeated, so an editor cannot offer a period the widget refuses.
299 PropertyKind::Int {
300 min: MIN_PERIOD_MS as i32,
301 max: 10_000,
302 },
303 "How long one full turn takes, in milliseconds.",
304 ),
305 Property::new(
306 "frame-ms",
307 PropertyKind::Int { min: 1, max: 1_000 },
308 "This spinner's own sampling interval, overriding the tree's — a coarse one gives a ticking rather than a sweeping hand. Almost nothing should set it.",
309 ),
310 ];
311
312 fn get(&self, name: &str) -> Option<Value> {
313 Some(match name {
314 "role" => Value::role(self.role),
315 // Both of these are derived when unset, and a derived value is not
316 // one to write into a file as though somebody had chosen it.
317 "thickness" => Value::Int(self.thickness?),
318 "period-ms" => Value::Int(i32::try_from(self.period_ms).unwrap_or(i32::MAX)),
319 "frame-ms" => Value::Int(i32::try_from(self.frame_ms?).unwrap_or(i32::MAX)),
320 _ => return None,
321 })
322 }
323
324 fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
325 match name {
326 "role" => self.role = value.as_role()?,
327 // A ring thinner than a pixel is a ring nobody can see.
328 "thickness" => self.thickness = Some(value.as_int()?.max(1)),
329 // Through the setters, which hold the two clamps that keep a spinner
330 // watchable: a period shorter than a frame looks stopped or
331 // backwards, and an interval of zero is a busy loop.
332 "period-ms" => self.set_period_ms(value.as_millis()?),
333 "frame-ms" => self.set_frame_ms(Some(value.as_millis()?)),
334 _ => return Err(Mismatch::Unknown),
335 }
336 Ok(())
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 /// The interval these tests step the clock by: whatever the tree's default
345 /// rate is, since that is what a spinner asking for [`Wake::Animating`]
346 /// will actually be given.
347 const FRAME_MS: u64 = Motion::DEFAULT_INTERVAL_MS;
348
349 /// `animate` comes from `Widget<M>`, and a `Spinner` is not generic over
350 /// the message type — so the tests pick one for it.
351 fn tick(spinner: &mut Spinner, now_ms: u64) -> Animation {
352 Widget::<()>::animate(spinner, now_ms)
353 }
354
355 /// A full period is exactly one revolution, and the arc lands back where it
356 /// started rather than drifting by a few units a lap.
357 #[test]
358 fn one_period_is_one_revolution() {
359 let mut spinner = Spinner::new().with_period_ms(1_000);
360 // The first frame sets the epoch and moves nothing.
361 tick(&mut spinner, 5_000);
362 assert_eq!(spinner.angle(), 0, "the first frame must not jump");
363
364 // Twenty frames of 50 ms is one second is one turn, back to zero.
365 for frame in 1..=20 {
366 tick(&mut spinner, 5_000 + frame * 50);
367 }
368 assert_eq!(spinner.angle(), 0, "a lap must land where it started");
369 }
370
371 /// The first frame establishes the epoch and moves nothing — whatever the
372 /// application's clock happened to read when somebody asked it to spin.
373 ///
374 /// Asserted at a reading *below* one period on purpose. Above one, the cap
375 /// that stops a long gap winding forward lands the phase on zero anyway, so
376 /// a spinner that wrongly jumped by the whole clock would look correct: the
377 /// first version of this test picked 5000 ms with a 1000 ms period and
378 /// passed with the guard removed.
379 #[test]
380 fn the_first_frame_does_not_jump_by_the_applications_clock() {
381 let mut spinner = Spinner::new().with_period_ms(1_000);
382 tick(&mut spinner, 300);
383 assert_eq!(spinner.angle(), 0, "a spinner starts where it starts");
384
385 // And from there it turns by the time that has actually passed.
386 tick(&mut spinner, 550);
387 assert_eq!(spinner.angle(), TURN / 4, "250 ms of a second is a quarter");
388 }
389
390 /// The angle only ever moves forwards, and stays inside one turn however
391 /// long it runs — the arithmetic mistake available to a widget that runs
392 /// forever is the one that matters.
393 #[test]
394 fn the_angle_wraps_cleanly_and_never_leaves_the_turn() {
395 let mut spinner = Spinner::new();
396 tick(&mut spinner, 0);
397 let mut previous = spinner.angle();
398 let mut wraps = 0;
399 for frame in 1..=2_000u64 {
400 tick(&mut spinner, frame * FRAME_MS);
401 let angle = spinner.angle();
402 assert!(
403 (0..TURN).contains(&angle),
404 "frame {frame}: angle {angle} left the turn"
405 );
406 if angle < previous {
407 wraps += 1;
408 }
409 previous = angle;
410 }
411 // 2 000 frames of `FRAME_MS` against the default one-second period, so
412 // one lap per second of simulated time. Stated as the arithmetic rather
413 // than as a number, because the frame rate is a tuning decision and this
414 // test is not about what it happens to be.
415 let laps = 2_000 * FRAME_MS / PERIOD_MS;
416 assert!(
417 wraps >= laps - laps / 10,
418 "{laps} laps expected from 2000 frames of {FRAME_MS} ms, saw {wraps}"
419 );
420 }
421
422 /// The period is honoured: a slower spinner turns less per frame.
423 #[test]
424 fn a_longer_period_turns_more_slowly() {
425 let step = |period: u64| {
426 let mut spinner = Spinner::new().with_period_ms(period);
427 tick(&mut spinner, 0);
428 tick(&mut spinner, FRAME_MS);
429 spinner.angle()
430 };
431 let fast = step(500);
432 let slow = step(4_000);
433 assert!(fast > slow, "{fast} is not more per frame than {slow}");
434 // One frame of a period is that fraction of the turn, whatever the frame
435 // rate is: `FRAME_MS / period`.
436 assert_eq!(fast, (TURN as u64 * FRAME_MS / 500) as i32);
437 assert_eq!(slow, (TURN as u64 * FRAME_MS / 4_000) as i32);
438 }
439
440 /// A period below one frame is clamped: turning more than a full circle
441 /// between frames looks stopped, or backwards.
442 #[test]
443 fn an_impossibly_short_period_is_clamped_to_a_frame() {
444 for asked in [0, 1, 10, MIN_PERIOD_MS - 1] {
445 let spinner = Spinner::new().with_period_ms(asked);
446 assert_eq!(spinner.period_ms, MIN_PERIOD_MS, "asked for {asked}");
447 }
448 let mut spinner = Spinner::new();
449 spinner.set_period_ms(0);
450 assert_eq!(spinner.period_ms, MIN_PERIOD_MS);
451 }
452
453 /// The default is the tree's rate, and the override is a time — which is
454 /// what lets one spinner differ from everything around it without any
455 /// widget carrying a frame-rate constant.
456 #[test]
457 fn a_spinner_asks_for_the_trees_rate_unless_told_otherwise() {
458 let mut spinner = Spinner::new();
459 assert_eq!(tick(&mut spinner, 1_000).next, Wake::Animating);
460
461 let mut own = Spinner::new().with_frame_ms(100);
462 assert_eq!(tick(&mut own, 1_000).next, Wake::At(1_100));
463
464 // Zero is a busy loop, not a rate.
465 let mut zero = Spinner::new().with_frame_ms(0);
466 assert_eq!(tick(&mut zero, 1_000).next, Wake::At(1_001));
467
468 // And the override can be given back.
469 own.set_frame_ms(None);
470 assert_eq!(tick(&mut own, 1_100).next, Wake::Animating);
471 }
472
473 /// A spinner has no end state, so turning motion off stops it rather than
474 /// landing it somewhere — and, importantly, drops it out of the animating
475 /// set instead of leaving it asking for frames nobody will deliver.
476 #[test]
477 fn no_motion_stops_a_spinner_rather_than_landing_it() {
478 let mut spinner = Spinner::new();
479 tick(&mut spinner, 1_000);
480 let angle = spinner.angle();
481 assert_eq!(
482 Widget::<()>::snap(&mut spinner, 2_000),
483 Animation::NONE,
484 "a still ring wants nothing"
485 );
486 assert_eq!(spinner.angle(), angle, "and it did not jump on the way");
487 }
488
489 /// It never stops asking. This is the unbounded case, and the test says so
490 /// out loud so that a future change making it terminate is a decision
491 /// somebody takes rather than one that happens.
492 #[test]
493 fn a_spinner_never_hands_the_cpu_back_on_its_own() {
494 let mut spinner = Spinner::new();
495 for frame in 0..200u64 {
496 let animation = tick(&mut spinner, frame * FRAME_MS);
497 assert_ne!(
498 animation.next,
499 Wake::Never,
500 "frame {frame}: a spinner must keep asking"
501 );
502 }
503 }
504
505 /// Frames that arrive early or out of order do not move the arc backwards.
506 /// The tree wakes for the most impatient animation and asks everybody, so a
507 /// spinner is routinely asked before the time it requested.
508 #[test]
509 fn an_early_or_repeated_frame_never_rewinds_the_arc() {
510 let mut spinner = Spinner::new();
511 tick(&mut spinner, 1_000);
512 let start = spinner.angle();
513
514 // Asked again at the same instant: no time passed, nothing moved, and
515 // the widget says so rather than claiming a repaint is owed.
516 let animation = tick(&mut spinner, 1_000);
517 assert_eq!(spinner.angle(), start);
518 assert!(!animation.repaint, "no time passed, so nothing to repaint");
519
520 // A clock that went backwards saturates to zero elapsed rather than
521 // subtracting a turn.
522 tick(&mut spinner, 500);
523 assert_eq!(spinner.angle(), start, "a backwards clock must not rewind");
524 }
525
526 /// A spinner hidden for an hour and shown again resumes, rather than
527 /// computing an hour of rotation.
528 #[test]
529 fn a_long_gap_resumes_rather_than_catching_up() {
530 assert_eq!(angle_at(0, 1_000), 0);
531 assert_eq!(angle_at(500, 1_000), TURN / 2);
532 assert_eq!(angle_at(1_000, 1_000), 0, "a whole period is a whole turn");
533 assert_eq!(angle_at(1_500, 1_000), TURN / 2, "and it wraps by phase");
534 // And the widget wraps that to nothing, so it lands where it was.
535 let mut spinner = Spinner::new();
536 tick(&mut spinner, 0);
537 tick(&mut spinner, 60 * 60 * 1_000);
538 assert_eq!(spinner.angle(), 0);
539 }
540
541 /// The moving arc leaves a visible gap: a sweep of a full turn is a ring
542 /// that never appears to move, however fast it spins.
543 ///
544 /// A `const` assertion, so it is checked when the constant is edited rather
545 /// than when the suite is run.
546 #[test]
547 fn the_arc_leaves_a_gap_to_see_it_turn_by() {
548 const { assert!(SWEEP < TURN, "a full sweep cannot be seen rotating") };
549 const { assert!(SWEEP > TURN / 2, "and too short a one is a fragment") };
550 }
551}