Skip to main content

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}