Skip to main content

qframe/widgets/
progress_bar.rs

1//! Progress bars.
2
3use super::eighths;
4use super::shimmer_text::sweep_light;
5use crate::color::Rgb;
6use crate::geometry::{Rect, Size};
7use crate::text;
8use crate::widget::{MeasureCx, PaintCx, Widget};
9
10/// Width of the light band of an indeterminate bar, in cells.
11const BAND: f32 = 6.0;
12
13/// A bar that fills as work completes, or sweeps while the amount is unknown.
14///
15/// Determinate bars fill with eighth-cell precision; in ASCII mode they fill whole cells with
16/// colour, rounded to the nearest cell like the charts. Indeterminate bars send a band of light
17/// along the track over `motion.shimmer`.
18/// Style keys: `progress` (`track`, `fill`) with variants such as `progress.success`,
19/// `progress-label`.
20#[derive(Debug, Clone, PartialEq)]
21pub struct ProgressBar {
22    value: Option<f32>,
23    percent: bool,
24    variant: Option<String>,
25}
26
27impl ProgressBar {
28    /// A bar at `value`, from 0 to 1.
29    #[must_use]
30    pub fn new(value: f32) -> Self {
31        Self { value: Some(value.clamp(0.0, 1.0)), percent: true, variant: None }
32    }
33
34    /// A bar for work of unknown size.
35    #[must_use]
36    pub fn indeterminate() -> Self {
37        Self { value: None, percent: false, variant: None }
38    }
39
40    /// Shows or hides the percentage after a determinate bar; shown by default.
41    #[must_use]
42    pub fn percent(mut self, show: bool) -> Self {
43        self.percent = show;
44        self
45    }
46
47    /// Theme variant, e.g. `"success"` when finished or `"danger"` when failing.
48    #[must_use]
49    pub fn variant(mut self, variant: impl Into<String>) -> Self {
50        self.variant = Some(variant.into());
51        self
52    }
53}
54
55impl<Msg: 'static> Widget<Msg> for ProgressBar {
56    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
57        Size::new(available.width, 1.min(available.height))
58    }
59
60    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
61        let style = cx.style("progress", self.variant.as_deref(), &[]);
62        let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
63        let fill = style.color("fill").unwrap_or_else(|| cx.color("accent"));
64        let Some(value) = self.value else {
65            self.paint_sweep(cx, area, track, fill);
66            return;
67        };
68        let label = if self.percent { format!(" {:>3.0}%", value * 100.0) } else { String::new() };
69        let bar_width = area.width.saturating_sub(text::width(&label));
70        let bar = Rect::new(area.x, area.y, bar_width, 1);
71        cx.clear(bar, track);
72        eighths::horizontal(cx, bar, eighths::eighths(value, bar_width), fill);
73        if !label.is_empty() {
74            let label_style = cx.style("progress-label", self.variant.as_deref(), &[]).text();
75            cx.text(area.x + i32::from(bar_width), area.y, &label, label_style, text::width(&label));
76        }
77    }
78}
79
80impl ProgressBar {
81    fn paint_sweep(&self, cx: &mut PaintCx<'_>, area: Rect, track: Rgb, fill: Rgb) {
82        let reduced = cx.reduced_motion();
83        let t = cx.cycle(cx.env().theme().motion().shimmer);
84        for column in 0..area.width {
85            // With reduced motion the whole track rests at a quarter of the light.
86            let intensity = if reduced { 0.25 } else { sweep_light(t, f32::from(area.width), BAND, f32::from(column)) };
87            cx.clear(Rect::new(area.x + i32::from(column), area.y, 1, 1), track.mix(fill, intensity));
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use std::time::Duration;
95
96    use super::*;
97    use crate::runtime::{App, Command, Harness};
98    use crate::widget::View;
99
100    struct Demo(ProgressBar);
101
102    impl App for Demo {
103        type Msg = ();
104        fn update(&mut self, _: ()) -> Command<()> {
105            Command::none()
106        }
107        fn view(&self, ui: &mut View<'_, ()>) {
108            ui.add(self.0.clone()).fill_width();
109        }
110    }
111
112    #[test]
113    fn fills_with_eighth_cells_and_shows_percent() {
114        let h = Harness::new(Demo(ProgressBar::new(0.53)), 15, 1);
115        assert_eq!(h.screen(), "     ▎      53%\n");
116        let theme = h.env().theme();
117        assert_eq!(h.bg(0, 0), theme.color("accent"));
118        assert_eq!(h.bg(8, 0), theme.color("raised"));
119    }
120
121    #[test]
122    fn ascii_rounds_to_the_nearest_whole_cell_like_the_charts() {
123        let theme_fill = |h: &Harness<Demo>| h.env().theme().color("accent");
124        let mut h = Harness::new(Demo(ProgressBar::new(0.55).percent(false)), 10, 1);
125        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
126        assert_eq!(h.screen(), "\n", "no partial glyphs in ASCII mode");
127        let filled = (0..10).filter(|x| h.bg(*x, 0) == theme_fill(&h)).count();
128        assert_eq!(filled, 6, "5.5 cells round up to 6");
129        let mut h = Harness::new(Demo(ProgressBar::new(0.54).percent(false)), 10, 1);
130        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
131        let filled = (0..10).filter(|x| h.bg(*x, 0) == theme_fill(&h)).count();
132        assert_eq!(filled, 5, "5.4 cells round down to 5");
133        assert_eq!(h.bg(5, 0), h.env().theme().color("raised"));
134    }
135
136    #[test]
137    fn indeterminate_band_moves() {
138        let mut h = Harness::new(Demo(ProgressBar::indeterminate()), 20, 1);
139        h.advance(Duration::from_millis(600));
140        let first: Vec<_> = (0..20).map(|x| h.bg(x, 0)).collect();
141        h.advance(Duration::from_millis(300));
142        let second: Vec<_> = (0..20).map(|x| h.bg(x, 0)).collect();
143        assert_ne!(first, second);
144        assert!(h.screen().trim().is_empty());
145    }
146}