Skip to main content

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}