Skip to main content

denise_ui/widgets/
radial.rs

1//! A ring that fills clockwise, with room for a number in the middle.
2
3use alloc::string::{String, ToString};
4
5use denise::{Pen, TURN};
6use denise::{Point, Rect, Role};
7use denise_text::TextStyle;
8
9use crate::widget::{PaintCtx, Widget};
10use crate::widgets::describe::{
11    Describe, DynDescribe, Group, Mismatch, Property, PropertyKind, ROLES, Value,
12};
13use crate::widgets::style::{Align, draw_aligned, interactive_pair};
14
15/// A determinate circular progress indicator, `0.0` to `1.0`.
16///
17/// Not interactive, not focusable, not a tab stop. It reports; it does not take
18/// input. The value contract is [`Progress`](super::Progress)'s exactly — NaN
19/// draws an empty ring, out of range clamps, and [`update`](RadialProgress::update)
20/// reports whether the value actually moved — because the number is `done / total`
21/// here too, and `total` is eventually zero.
22///
23/// ```
24/// # use denise_ui::RadialProgress;
25/// # use denise::theme::Role;
26/// # let (used, capacity) = (7.0f32, 10.0f32);
27/// RadialProgress::new(0.7).with_label("70 %");
28/// RadialProgress::new(used / capacity).with_role(Role::Warning);
29/// ```
30///
31/// # It is a circle, so it inscribes rather than fills
32///
33/// The ring takes `min(width, height) / 2` as its radius and centres itself in
34/// the rectangle. A radial progress squashed into an ellipse is not a radial
35/// progress, and the alternative — demanding a square — would make every caller
36/// do this arithmetic instead.
37///
38/// # It takes a label, and `Progress` deliberately does not
39///
40/// Not an inconsistency. A percentage drawn inside a *bar* sits on the fill at
41/// one end and on the track at the other, so it needs two colours in one string
42/// to stay readable; that is why [`Progress`](super::Progress) has no text.
43///
44/// A ring has an empty middle. The label sits on the panel behind the widget
45/// rather than on the ring, so it is one colour — the same arrangement
46/// [`Divider`](super::Divider)'s label already uses. The two-colour problem
47/// simply does not arise, which is the whole reason this is where the number
48/// goes.
49///
50/// The caller formats it. A widget that turned `0.7` into `"70 %"` would be
51/// choosing decimals and a locale on the caller's behalf, and
52/// [`update`](RadialProgress::update) plus [`set_label`](RadialProgress::set_label)
53/// are two calls in the place that already knows both.
54#[derive(Clone, Debug)]
55pub struct RadialProgress {
56    value: f32,
57    label: String,
58    role: Role,
59    /// `None` derives it from the radius; see [`thickness_for`].
60    thickness: Option<i32>,
61    style: TextStyle,
62}
63
64impl RadialProgress {
65    /// A ring at `value`, which is clamped — see [`RadialProgress::set_value`].
66    pub fn new(value: f32) -> Self {
67        Self {
68            value: clamp(value),
69            label: String::new(),
70            role: Role::Primary,
71            thickness: None,
72            style: TextStyle::built_in(16),
73        }
74    }
75
76    /// Puts text in the middle of the ring.
77    pub fn with_label(mut self, label: impl Into<String>) -> Self {
78        self.label = label.into();
79        self
80    }
81
82    /// Sets the colour of the filled arc.
83    ///
84    /// `Warning` or `Error` for a ring that means something is running out
85    /// rather than something is being achieved.
86    pub fn with_role(mut self, role: Role) -> Self {
87        self.role = role;
88        self
89    }
90
91    /// Sets the ring's thickness in pixels, instead of deriving it.
92    ///
93    /// Clamped to the radius: a thickness at or past it fills to the centre,
94    /// which is a pie chart rather than a ring and leaves no hole for a label.
95    pub fn with_thickness(mut self, thickness: i32) -> Self {
96        self.thickness = Some(thickness);
97        self
98    }
99
100    /// Sets the label's font and size.
101    pub fn with_style(mut self, style: TextStyle) -> Self {
102        self.style = style;
103        self
104    }
105
106    /// The current value, always in `0.0..=1.0`.
107    #[inline]
108    pub const fn value(&self) -> f32 {
109        self.value
110    }
111
112    /// Sets the value, clamped into range, with NaN as zero.
113    ///
114    /// The reasoning is [`Progress::set_value`](super::Progress::set_value)'s: a
115    /// panic inside a paint loop on a kiosk is a black screen with no way to
116    /// report itself, so a nonsense number draws an honest empty ring instead.
117    pub fn set_value(&mut self, value: f32) {
118        self.value = clamp(value);
119    }
120
121    /// Sets the value, reporting whether it actually changed.
122    ///
123    /// A panel writes its readings every cycle whether or not they moved, and
124    /// repainting for a value that did not change is how an idle device stops
125    /// being idle.
126    pub fn update(&mut self, value: f32) -> bool {
127        let value = clamp(value);
128        let changed = value != self.value;
129        self.value = value;
130        changed
131    }
132
133    /// The current label, empty if there is none.
134    #[inline]
135    pub fn label(&self) -> &str {
136        &self.label
137    }
138
139    /// Replaces the label.
140    pub fn set_label(&mut self, label: impl Into<String>) {
141        self.label = label.into();
142    }
143
144    /// Replaces the label, reporting whether it actually changed.
145    pub fn update_label(&mut self, label: &str) -> bool {
146        let changed = self.label != label;
147        if changed {
148            self.label = label.to_string();
149        }
150        changed
151    }
152
153    /// Replaces the colour role.
154    pub fn set_role(&mut self, role: Role) {
155        self.role = role;
156    }
157
158    /// Replaces the label's font and size.
159    pub fn set_style(&mut self, style: TextStyle) {
160        self.style = style;
161    }
162}
163
164impl Default for RadialProgress {
165    fn default() -> Self {
166        Self::new(0.0)
167    }
168}
169
170/// Into `0.0..=1.0`, with NaN as zero.
171///
172/// `f32::clamp` alone will not do: it propagates NaN rather than choosing an
173/// end, so a `0.0 / 0.0` would reach the arc as a sweep of NaN.
174#[inline]
175fn clamp(value: f32) -> f32 {
176    if value.is_nan() {
177        0.0
178    } else {
179        value.clamp(0.0, 1.0)
180    }
181}
182
183/// The largest circle centred in `bounds`: its centre and radius.
184pub(crate) fn ring(bounds: Rect) -> (Point, i32) {
185    let radius = bounds.width.min(bounds.height) / 2;
186    let centre = Point::new(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
187    (centre, radius.max(0))
188}
189
190/// The ring's thickness: what was asked for, or a fifth of the radius.
191///
192/// A fifth is chunky enough to read across a room and thin enough to leave a
193/// hole a number fits in. Clamped to the radius either way, because a thicker
194/// ring than that is a filled disc — [`Canvas::stroke_arc`] draws it happily,
195/// but a label would then have nowhere to go.
196pub(crate) fn thickness_for(radius: i32, requested: Option<i32>) -> i32 {
197    let limit = radius.max(1);
198    match requested {
199        Some(thickness) => thickness.clamp(1, limit),
200        None => (radius / 5).clamp(1, limit),
201    }
202}
203
204/// The track's colour and the moving arc's, for a ring in `role`.
205///
206/// One function because [`Spinner`](super::Spinner) needs exactly the same
207/// answer, and because the disabled case is not obvious. `interactive_pair`
208/// recesses **every** role to `Base200` when disabled, so a disabled ring would
209/// draw its arc in the same colour as its track and lose its value entirely —
210/// the mistake [`RadioGroup`](super::RadioGroup) avoided by keeping a mark in
211/// its disabled disc, and [`List`](super::List) by giving its disabled
212/// selection `Base300`. Same answer here, and found the same way: by looking at
213/// the rendered showcase.
214pub(crate) fn ring_colors(
215    theme: &denise::Theme,
216    state: crate::widget::VisualState,
217    role: Role,
218) -> (denise::Color, denise::Color) {
219    let (track, _) = interactive_pair(theme, Role::Base300, state);
220    if state.contains(crate::widget::VisualState::DISABLED) {
221        // The theme's own next step up from the recessed surface: still plainly
222        // disabled, still plainly showing how far round it got.
223        (track, theme.color(Role::Base300))
224    } else {
225        (track, interactive_pair(theme, role, state).0)
226    }
227}
228
229/// The sweep for `value` on a ring of `radius`, in [`TURN`] units.
230///
231/// The ends are exact: zero draws nothing and one draws the whole ring, which
232/// costs no special case because a sweep of `TURN` *is* the circle.
233///
234/// Between them there is a floor, for [`Progress`](super::Progress)'s reason —
235/// a job that has started must not look like a job that has not. The bar's floor
236/// is one pixel; a ring's has to come from its radius, because the same angle is
237/// a different arc length on a ring of 8 pixels and one of 200. The floor here
238/// is the sweep whose arc is about one pixel long: `TURN / 2πr`, with 6 stood in
239/// for 2π so the answer errs generous rather than invisible.
240fn sweep_of(value: f32, radius: i32) -> i32 {
241    // `is_nan` is not redundant: every comparison with NaN is false, so a NaN
242    // would fall past both guards and reach `as i32`.
243    if value.is_nan() || value <= 0.0 {
244        return 0;
245    }
246    if value >= 1.0 {
247        return TURN;
248    }
249    let exact = (value * TURN as f32) as i32;
250    let floor = (TURN / (6 * radius.max(1))).max(1);
251    exact.clamp(floor, TURN)
252}
253
254impl<M: 'static> Widget<M> for RadialProgress {
255    fn describe(&self) -> Option<&dyn DynDescribe> {
256        Some(self)
257    }
258
259    fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
260        Some(self)
261    }
262    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
263        let bounds = ctx.bounds;
264        if bounds.is_empty() {
265            return;
266        }
267        let (centre, radius) = ring(bounds);
268        if radius <= 0 {
269            return;
270        }
271        let thickness = thickness_for(radius, self.thickness);
272
273        // The track, then the value over it. Both are surfaces drawn on the
274        // panel rather than content drawn on each other, so they take the
275        // surface half of their own pairing — `Progress` does the same.
276        let (track, fill) = ring_colors(ctx.theme, ctx.state, self.role);
277        canvas.stroke_circle(centre, radius, thickness, track);
278
279        let sweep = sweep_of(self.value, radius);
280        if sweep > 0 {
281            // From twelve o'clock, clockwise: the direction every clock and
282            // every progress ring agrees on.
283            canvas.stroke_arc(centre, radius, thickness, 0, sweep, fill);
284        }
285
286        if self.label.is_empty() {
287            return;
288        }
289        // Centred in the bounds, which is centred in the hole — the ring is
290        // concentric with its rectangle, so there is nothing a separate hole-
291        // sized box would move. An earlier version computed the square
292        // inscribed in the hole and centred in *that*, which is the same
293        // pixels; a mutation swapping one for the other changed nothing, which
294        // is how it came out.
295        //
296        // A label wider than the hole therefore overflows onto the ring rather
297        // than being cut off. That is the better failure: an overlap is visible
298        // to whoever set the text, where a truncated "100 %" reading "10" is
299        // not. Size the text down, or give the ring a thinner band.
300        let content = interactive_pair(ctx.theme, Role::Base100, ctx.state).1;
301        draw_aligned(
302            canvas,
303            ctx.text,
304            self.style,
305            bounds,
306            (Align::Center, Align::Center),
307            &self.label,
308            content,
309        );
310    }
311}
312
313impl Describe for RadialProgress {
314    const KIND: &'static str = "radial-progress";
315    const DOC: &'static str = "A ring that fills clockwise, with room for a number inside it.";
316    const GROUP: Group = Group::Indicator;
317    const ICON: &'static denise::icon::Icon = &super::icons::RADIAL_PROGRESS;
318
319    const PROPERTIES: &'static [Property] = &[
320        Property::new(
321            "value",
322            PropertyKind::Float { min: 0.0, max: 1.0 },
323            "How far round the ring is filled, from empty at `0.0` to a full turn at `1.0`.",
324        ),
325        Property::new(
326            "label",
327            PropertyKind::Text,
328            "Text drawn in the middle of the ring; the caller formats it, so the widget chooses neither decimals nor a locale.",
329        ),
330        Property::new(
331            "thickness",
332            PropertyKind::Int { min: 1, max: 64 },
333            "Ring width in pixels; derived from the radius without it.",
334        )
335        .in_pixels(),
336        Property::new(
337            "role",
338            PropertyKind::Enum(ROLES),
339            "Colour of the filled arc; `warning` or `error` for a ring that means something is running out.",
340        ),
341        Property::new(
342            "size",
343            PropertyKind::Int { min: 6, max: 96 },
344            "Text size in logical pixels.",
345        )
346        .in_pixels(),
347    ];
348
349    fn get(&self, name: &str) -> Option<Value> {
350        Some(match name {
351            "value" => Value::Float(self.value),
352            // An empty string is how this widget spells "no label", so there is
353            // nothing to report and nothing for a file to write.
354            "label" if !self.label.is_empty() => Value::text(self.label.as_str()),
355            "thickness" => Value::Int(self.thickness?),
356            "role" => Value::role(self.role),
357            "size" => Value::Int(i32::from(self.style.size_px)),
358            _ => return None,
359        })
360    }
361
362    fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
363        match name {
364            // Through the setter, which is where NaN and out of range are
365            // decided; the range above is only what an inspector offers.
366            "value" => self.set_value(value.as_float()?),
367            "label" => self.set_label(value.as_text()?),
368            // Stored as asked for and clamped to the radius at paint time by
369            // `thickness_for`, because the radius is not known until then.
370            "thickness" => self.thickness = Some(value.as_int()?),
371            "role" => self.role = value.as_role()?,
372            "size" => self.style.size_px = value.as_size()?,
373            _ => return Err(Mismatch::Unknown),
374        }
375        Ok(())
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    /// The ends are exact. A ring that never quite closes at 100% is a control
384    /// that disagrees with the number beside it — and it costs no special case,
385    /// because a sweep of `TURN` is the circle.
386    #[test]
387    fn the_sweep_is_exact_at_both_ends() {
388        for radius in [4, 20, 200] {
389            assert_eq!(sweep_of(0.0, radius), 0, "radius {radius}");
390            assert_eq!(sweep_of(1.0, radius), TURN, "radius {radius}");
391        }
392        assert_eq!(sweep_of(0.5, 100), TURN / 2);
393        assert_eq!(sweep_of(0.25, 100), TURN / 4);
394    }
395
396    /// `done / total` with a zero total is the case this exists for.
397    #[test]
398    fn a_value_that_is_not_a_number_draws_an_empty_ring() {
399        let done = core::hint::black_box(0.0f32);
400        let total = core::hint::black_box(0.0f32);
401        let zero_over_zero = done / total;
402        assert!(zero_over_zero.is_nan(), "the premise");
403        assert_eq!(clamp(zero_over_zero), 0.0);
404        assert_eq!(sweep_of(zero_over_zero, 40), 0);
405
406        let mut ring = RadialProgress::new(0.5);
407        ring.set_value(zero_over_zero);
408        assert_eq!(ring.value(), 0.0, "and it does not keep the old value");
409    }
410
411    /// Infinity is a direction rather than a mistake.
412    #[test]
413    fn infinities_clamp_to_the_end_they_point_at() {
414        assert_eq!(clamp(f32::INFINITY), 1.0);
415        assert_eq!(clamp(f32::NEG_INFINITY), 0.0);
416        assert_eq!(sweep_of(f32::INFINITY, 40), TURN);
417        assert_eq!(sweep_of(f32::NEG_INFINITY, 40), 0);
418    }
419
420    /// Out of range clamps rather than panicking inside a paint loop.
421    #[test]
422    fn values_outside_the_range_are_clamped() {
423        assert_eq!(RadialProgress::new(42.0).value(), 1.0);
424        assert_eq!(RadialProgress::new(-42.0).value(), 0.0);
425    }
426
427    /// A ring that has barely started must not look like one that has not, and
428    /// the floor that guarantees it has to scale with the radius: the same angle
429    /// is a different arc length on a ring of 8 pixels and one of 200.
430    #[test]
431    fn a_value_just_above_zero_shows_an_arc_at_any_radius() {
432        for radius in [4, 8, 20, 60, 200] {
433            let tiny = sweep_of(0.000_01, radius);
434            assert!(tiny > 0, "radius {radius} showed nothing");
435            // About a pixel of arc: length is r·θ, θ = sweep/TURN · 2π.
436            let length = radius as f32 * (tiny as f32 / TURN as f32) * core::f32::consts::TAU;
437            assert!(
438                (0.8..4.0).contains(&length),
439                "radius {radius}: floor is {length} pixels of arc"
440            );
441        }
442    }
443
444    /// Monotonic, and never past a full turn.
445    #[test]
446    fn more_progress_is_never_less_sweep() {
447        for radius in [4, 40, 200] {
448            let mut previous = -1;
449            for step in 0..=1000 {
450                let sweep = sweep_of(step as f32 / 1000.0, radius);
451                assert!(sweep >= previous, "radius {radius} went back at {step}");
452                assert!(sweep <= TURN, "radius {radius} overran at {step}");
453                previous = sweep;
454            }
455        }
456    }
457
458    /// The ring inscribes itself: a wide rectangle gives a circle of the short
459    /// side, centred, not an ellipse and not something that escapes.
460    #[test]
461    fn the_ring_inscribes_itself_in_any_rectangle() {
462        for bounds in [
463            Rect::new(0, 0, 100, 40),
464            Rect::new(10, 20, 40, 100),
465            Rect::new(-5, -5, 60, 60),
466            Rect::new(0, 0, 1, 1),
467        ] {
468            let (centre, radius) = ring(bounds);
469            assert_eq!(radius, bounds.width.min(bounds.height) / 2, "{bounds:?}");
470            let circle = Rect::new(centre.x - radius, centre.y - radius, radius * 2, radius * 2);
471            assert!(
472                bounds.contains_rect(&circle),
473                "{bounds:?}: the ring {circle:?} escaped"
474            );
475        }
476    }
477
478    /// The thickness never exceeds the radius, however absurd the request — past
479    /// that it is a filled disc with nowhere to put a label.
480    #[test]
481    fn the_thickness_never_exceeds_the_radius() {
482        for radius in [0, 1, 2, 5, 20, 200] {
483            for requested in [None, Some(-5), Some(0), Some(1), Some(9_999)] {
484                let t = thickness_for(radius, requested);
485                assert!(t >= 1, "radius {radius} {requested:?} gave {t}");
486                assert!(t <= radius.max(1), "radius {radius} {requested:?} gave {t}");
487            }
488        }
489        assert_eq!(thickness_for(50, None), 10, "a fifth of the radius");
490        assert_eq!(thickness_for(50, Some(4)), 4);
491    }
492
493    /// A panel writes its readings every cycle whether or not they moved.
494    #[test]
495    fn writing_the_same_value_or_label_reports_no_change() {
496        let mut ring = RadialProgress::new(0.0);
497        assert!(ring.update(0.4));
498        assert!(!ring.update(0.4));
499        assert!(ring.update(0.6));
500
501        assert!(ring.update_label("60 %"));
502        assert!(!ring.update_label("60 %"));
503        assert!(ring.update_label("61 %"));
504        assert_eq!(ring.label(), "61 %");
505    }
506
507    /// A disabled ring still shows how far round it got. `interactive_pair`
508    /// recesses every role to the same `Base200` when disabled, so this needs
509    /// its own answer — the same lesson `RadioGroup` learned about its disabled
510    /// disc and `List` about its disabled selection.
511    #[test]
512    fn a_disabled_ring_still_shows_its_value() {
513        use crate::widget::VisualState;
514        use denise::Theme;
515
516        for theme in Theme::BUILT_IN {
517            for role in [Role::Primary, Role::Warning, Role::Success] {
518                let (track, arc) = ring_colors(&theme, VisualState::DISABLED, role);
519                assert_ne!(
520                    track, arc,
521                    "{} {role:?}: a disabled ring lost its value",
522                    theme.name
523                );
524                // And an enabled one is still obviously coloured.
525                let (track, arc) = ring_colors(&theme, VisualState::NONE, role);
526                assert_ne!(track, arc, "{} {role:?} enabled", theme.name);
527            }
528        }
529    }
530
531    /// The label has to stay readable on the panel behind it, in every theme and
532    /// state — the floor every widget here is held to.
533    #[test]
534    fn the_label_is_readable_on_the_panel_in_every_theme() {
535        use crate::widget::VisualState;
536        use denise::Theme;
537        use denise::theme::{AA_LARGE, contrast_x100};
538
539        for theme in Theme::BUILT_IN {
540            for state in [VisualState::NONE, VisualState::DISABLED] {
541                let (surface, content) = interactive_pair(&theme, Role::Base100, state);
542                let ratio = contrast_x100(surface, content);
543                assert!(
544                    ratio >= AA_LARGE,
545                    "{} {state:?}: label on the panel is {ratio}, floor is {AA_LARGE}",
546                    theme.name
547                );
548            }
549        }
550    }
551}