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}