denise_ui/widgets/progress.rs
1//! A track and a fill. The one widget here that is purely an output.
2
3use denise::Pen;
4use denise::{Rect, Role};
5
6use crate::widget::{PaintCtx, Widget};
7use crate::widgets::describe::{
8 Describe, DynDescribe, Group, Mismatch, Property, PropertyKind, ROLES, Value,
9};
10use crate::widgets::style::interactive_pair;
11
12/// A determinate progress bar, `0.0` to `1.0`.
13///
14/// Not interactive, not focusable, not a tab stop. It reports; it does not take
15/// input.
16///
17/// The bar fills the rectangle it is given, rather than centring a fixed
18/// thickness inside it the way [`Checkbox`](super::Checkbox) and
19/// [`Toggle`](super::Toggle) do. There is no theme metric for a bar's thickness,
20/// and inventing one would mean a caller who wants a chunky bar has to fight it.
21/// Give it the rectangle you want filled.
22///
23/// # There is no text on it
24///
25/// A percentage drawn inside the bar sits on the fill at one end and on the track
26/// at the other, so it needs two colours in one string to stay readable. That is
27/// a real amount of machinery for a label, and the alternative is already good: a
28/// [`Label`](super::Label) beside the bar, updated from the same number.
29///
30/// # There is no indeterminate mode
31///
32/// It used to be impossible — an indeterminate bar animates forever, and
33/// `Ui::tick` once animated only the focused widget, which a progress bar never
34/// is. [#19] removed that limitation, so this is now a choice rather than a
35/// wall: an unbounded animation costs a wake per frame for as long as the node
36/// is visible, and the widget that already spends it is
37/// [`Spinner`](super::Spinner). A second way to say *something is happening*,
38/// in a widget whose whole job is saying *how much has happened*, has not
39/// earned its place.
40///
41/// [#19]: https://github.com/bisand/denise/issues/19
42#[derive(Clone, Copy, Debug)]
43pub struct Progress {
44 value: f32,
45 role: Role,
46}
47
48impl Progress {
49 /// A bar at `value`, which is clamped — see [`Progress::set_value`].
50 pub fn new(value: f32) -> Self {
51 Self {
52 value: clamp(value),
53 role: Role::Primary,
54 }
55 }
56
57 /// Sets the colour of the filled portion.
58 ///
59 /// `Warning` or `Error` for a bar that means something is running out rather
60 /// than something is being achieved.
61 pub fn with_role(mut self, role: Role) -> Self {
62 self.role = role;
63 self
64 }
65
66 /// The current value, always in `0.0..=1.0`.
67 #[inline]
68 pub const fn value(&self) -> f32 {
69 self.value
70 }
71
72 /// Sets the value, clamped into range.
73 ///
74 /// **Clamped rather than asserted, and NaN is zero.** The number a caller
75 /// passes is nearly always `done / total`, and `total` is eventually zero —
76 /// on the first frame, on an empty queue, on a job that was cancelled before
77 /// it was measured. A debug assertion would fire on a developer's machine and
78 /// a release build would draw a bar of undefined width; a panic would take
79 /// down a paint loop, and a panic inside a paint loop on a kiosk is a black
80 /// screen with no way to report itself.
81 ///
82 /// So: NaN draws an empty bar, negative draws an empty bar, and anything
83 /// above one draws a full one.
84 pub fn set_value(&mut self, value: f32) {
85 self.value = clamp(value);
86 }
87
88 /// Sets the value, reporting whether it actually changed.
89 ///
90 /// The [`Label::update`](super::Label::update) pattern, and for the same
91 /// reason: a panel writes its readings every cycle whether or not they moved,
92 /// and repainting for a value that did not change is how an idle device stops
93 /// being idle.
94 ///
95 /// Note what this compares. Two values a hundredth apart are *different* and
96 /// will both report `true`, while on a bar 80 pixels wide they are the same
97 /// drawing. A caller updating far faster than its bar is wide should quantise
98 /// before calling — the widget cannot do it, because it does not know how wide
99 /// it is until it paints.
100 pub fn update(&mut self, value: f32) -> bool {
101 let value = clamp(value);
102 let changed = value != self.value;
103 self.value = value;
104 changed
105 }
106
107 /// Replaces the colour role.
108 pub fn set_role(&mut self, role: Role) {
109 self.role = role;
110 }
111}
112
113impl Default for Progress {
114 fn default() -> Self {
115 Self::new(0.0)
116 }
117}
118
119/// Into `0.0..=1.0`, with NaN as zero.
120///
121/// `f32::clamp` alone will not do: it propagates NaN, so a `0.0 / 0.0` would
122/// arrive at the rasteriser as a width of NaN and cast to an unspecified `i32`.
123#[inline]
124fn clamp(value: f32) -> f32 {
125 // NaN first and `f32::clamp` for the rest, because `clamp` alone will not do
126 // it: it *propagates* NaN rather than choosing an end, so a `0.0 / 0.0` would
127 // reach the rasteriser as a width of NaN.
128 if value.is_nan() {
129 0.0
130 } else {
131 value.clamp(0.0, 1.0)
132 }
133}
134
135/// Filled pixels for a track `width` across at `value`.
136///
137/// Truncated rather than rounded, with one exception: any value above zero shows
138/// at least one pixel. A job that has started and shows nothing looks like a job
139/// that has not started, and on a bar of any useful width one pixel is a
140/// rounding error against being wrong about whether anything is happening.
141fn fill_width(width: i32, value: f32) -> i32 {
142 // The `is_nan` is not redundant, and leaving it out is a real bug that looks
143 // like tidy code: **every** comparison with NaN is false, so `value <= 0.0`
144 // lets a NaN through, `value >= 1.0` lets it through again, `NaN as i32`
145 // saturates to zero, and the one-pixel floor below turns that into a bar
146 // claiming a job has started.
147 //
148 // `Progress` only ever calls this with an already-clamped value, so this is
149 // belt and braces — but it is a free function, and a guard that relies on its
150 // only caller staying careful is not a guard.
151 if width <= 0 || value.is_nan() || value <= 0.0 {
152 return 0;
153 }
154 if value >= 1.0 {
155 return width;
156 }
157 let filled = (width as f32 * value) as i32;
158 filled.clamp(1, width)
159}
160
161impl<M: 'static> Widget<M> for Progress {
162 fn describe(&self) -> Option<&dyn DynDescribe> {
163 Some(self)
164 }
165
166 fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
167 Some(self)
168 }
169 fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
170 let bounds = ctx.bounds;
171 if bounds.is_empty() {
172 return;
173 }
174 // A stadium, computed from the height rather than taken from
175 // `Radius::Selector`: the theme's token is a fixed number of pixels, and a
176 // bar six pixels tall with an eight-pixel corner radius is not a bar.
177 // Capped by the width too, so a bar narrower than it is tall does not ask
178 // for a radius wider than the rectangle it is rounding.
179 let radius = (bounds.height / 2).min(bounds.width / 2);
180
181 let (track, _) = interactive_pair(ctx.theme, Role::Base300, ctx.state);
182 canvas.fill_rounded_rect(bounds, radius, track);
183
184 let filled = fill_width(bounds.width, self.value);
185 if filled == 0 {
186 return;
187 }
188 let (fill, _) = interactive_pair(ctx.theme, self.role, ctx.state);
189 let bar = Rect::new(bounds.x, bounds.y, filled, bounds.height);
190 canvas.fill_rounded_rect(bar, radius.min(filled / 2), fill);
191 }
192}
193
194impl Describe for Progress {
195 const KIND: &'static str = "progress";
196 const DOC: &'static str = "A bar that fills to show how far along something is.";
197 const GROUP: Group = Group::Indicator;
198 const ICON: &'static denise::icon::Icon = &super::icons::PROGRESS;
199
200 const PROPERTIES: &'static [Property] = &[
201 Property::new(
202 "value",
203 PropertyKind::Float { min: 0.0, max: 1.0 },
204 "How much of the bar is filled, from empty at `0.0` to full at `1.0`.",
205 ),
206 Property::new(
207 "role",
208 PropertyKind::Enum(ROLES),
209 "Colour of the filled portion; `warning` or `error` for a bar that means something is running out rather than something is being achieved.",
210 ),
211 ];
212
213 fn get(&self, name: &str) -> Option<Value> {
214 Some(match name {
215 "value" => Value::Float(self.value),
216 "role" => Value::role(self.role),
217 _ => return None,
218 })
219 }
220
221 fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
222 match name {
223 // Through the setter rather than the field: the range above is what
224 // an inspector should offer, and `set_value` is the one place that
225 // decides what a number outside it — or a NaN — means.
226 "value" => self.set_value(value.as_float()?),
227 "role" => self.role = value.as_role()?,
228 _ => return Err(Mismatch::Unknown),
229 }
230 Ok(())
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 /// The number a caller passes is nearly always `done / total`, and `total` is
239 /// eventually zero. This is the test that says what happens then.
240 #[test]
241 fn a_value_that_is_not_a_number_draws_an_empty_bar() {
242 // Through `black_box` so this stays the division a caller actually
243 // writes rather than a folded `f32::NAN` constant — the point is that
244 // `done / total` produces this, not that NaN exists.
245 let done = core::hint::black_box(0.0f32);
246 let total = core::hint::black_box(0.0f32);
247 let zero_over_zero = done / total;
248 assert!(zero_over_zero.is_nan(), "the premise");
249 assert_eq!(clamp(zero_over_zero), 0.0);
250 assert_eq!(fill_width(200, zero_over_zero), 0);
251
252 let mut bar = Progress::new(0.5);
253 bar.set_value(zero_over_zero);
254 assert_eq!(
255 bar.value(),
256 0.0,
257 "and it does not keep the old value either"
258 );
259 }
260
261 /// Infinity is a direction rather than a mistake, so it clamps to the end it
262 /// points at rather than to zero.
263 #[test]
264 fn infinities_clamp_to_the_end_they_point_at() {
265 assert_eq!(clamp(f32::INFINITY), 1.0);
266 assert_eq!(clamp(f32::NEG_INFINITY), 0.0);
267 assert_eq!(fill_width(200, f32::INFINITY), 200);
268 assert_eq!(fill_width(200, f32::NEG_INFINITY), 0);
269 }
270
271 /// Out of range is clamped, not asserted. A panic inside a paint loop on a
272 /// kiosk is a black screen with no way to report itself.
273 #[test]
274 fn values_outside_the_range_are_clamped_rather_than_refused() {
275 assert_eq!(clamp(-0.5), 0.0);
276 assert_eq!(clamp(1.5), 1.0);
277 assert_eq!(clamp(1e30), 1.0);
278 assert_eq!(Progress::new(42.0).value(), 1.0);
279 assert_eq!(Progress::new(-42.0).value(), 0.0);
280 }
281
282 /// The ends are exact. Half-full landing on 99 of 200 pixels would be a bar
283 /// that never quite agrees with the number beside it.
284 #[test]
285 fn the_fill_is_exact_at_both_ends_and_in_the_middle() {
286 assert_eq!(fill_width(200, 0.0), 0);
287 assert_eq!(fill_width(200, 1.0), 200);
288 assert_eq!(fill_width(200, 0.5), 100);
289 assert_eq!(fill_width(200, 0.25), 50);
290 assert_eq!(
291 fill_width(101, 1.0),
292 101,
293 "an odd width still fills exactly"
294 );
295 }
296
297 /// Zero and "barely started" have to look different, or a job that has begun
298 /// is indistinguishable from one that has not.
299 #[test]
300 fn a_value_just_above_zero_shows_something() {
301 assert_eq!(fill_width(200, 0.0), 0);
302 assert!(fill_width(200, 0.0001) >= 1);
303 assert!(fill_width(2000, 1.0 / 5000.0) >= 1);
304 }
305
306 /// The fill never escapes the track, at any width including degenerate ones.
307 #[test]
308 fn the_fill_never_exceeds_the_track_at_any_width() {
309 for width in [0, 1, 2, 3, 7, 200, 1920] {
310 for step in 0..=20 {
311 let value = step as f32 / 20.0;
312 let filled = fill_width(width, value);
313 assert!(
314 (0..=width.max(0)).contains(&filled),
315 "width {width} at {value} gave {filled}"
316 );
317 }
318 assert_eq!(fill_width(width, 2.0), width.max(0), "width {width} full");
319 }
320 assert_eq!(fill_width(-5, 0.5), 0, "a negative width fills nothing");
321 }
322
323 /// Monotonic: more progress is never fewer pixels.
324 #[test]
325 fn more_progress_is_never_fewer_pixels() {
326 for width in [1, 7, 200, 1920] {
327 let mut previous = 0;
328 for step in 0..=1000 {
329 let filled = fill_width(width, step as f32 / 1000.0);
330 assert!(
331 filled >= previous,
332 "width {width} went backwards at step {step}"
333 );
334 previous = filled;
335 }
336 }
337 }
338
339 /// A panel writes its readings every cycle whether or not they moved.
340 /// Repainting for a value that did not change is how an idle device stops
341 /// being idle.
342 #[test]
343 fn writing_the_same_value_reports_no_change() {
344 let mut bar = Progress::new(0.0);
345 assert!(bar.update(0.4));
346 assert!(!bar.update(0.4));
347 assert!(bar.update(0.6));
348
349 // And through the clamp: two different out-of-range numbers are the same
350 // value once clamped, so the second is not a change.
351 assert!(bar.update(5.0));
352 assert!(!bar.update(9.0));
353 assert!(!bar.update(f32::INFINITY));
354 }
355}