Skip to main content

retroglyph_ui/widget/
progress_bar.rs

1//! [`ProgressBar`]: a horizontal progress bar.
2use retroglyph_core::color::{Color, Style};
3
4use super::Widget;
5use crate::Surface;
6use crate::theme::Theme;
7
8/// A horizontal progress bar that fills `value / max` of the area it's
9/// rendered into.
10///
11/// `filled_style`/`empty_style` default to [`Theme::DARK`] (as if [`ProgressBar::theme`] had been
12/// called); set them with [`ProgressBar::filled_style`]/[`ProgressBar::empty_style`].
13/// `area.height()` is ignored; only the first row is drawn.
14///
15/// # Examples
16///
17/// ```
18/// use retroglyph_core::grid::{Grid, Rect};
19/// use retroglyph_ui::widget::{ProgressBar, Widget};
20/// use retroglyph_ui::Surface;
21///
22/// let area = Rect::new(0, 0, 10, 1);
23/// let mut grid = Grid::new(10, 1);
24/// ProgressBar::new(5, 10).render(&mut Surface::new(&mut grid, area, 0));
25/// ```
26#[derive(Clone, Copy, Debug)]
27pub struct ProgressBar {
28    value: u32,
29    max: u32,
30    filled_style: Style,
31    empty_style: Style,
32}
33
34impl ProgressBar {
35    /// A bar filling `value / max`, styled from [`Theme::DARK`] (as if [`ProgressBar::theme`] had
36    /// been called).
37    #[must_use]
38    pub fn new(value: u32, max: u32) -> Self {
39        Self {
40            value,
41            max,
42            filled_style: Style::new(),
43            empty_style: Style::new(),
44        }
45        .theme(Theme::DARK)
46    }
47
48    /// Set the style of the filled portion.
49    #[must_use]
50    pub const fn filled_style(mut self, style: Style) -> Self {
51        self.filled_style = style;
52        self
53    }
54
55    /// Set the style of the empty portion.
56    #[must_use]
57    pub const fn empty_style(mut self, style: Style) -> Self {
58        self.empty_style = style;
59        self
60    }
61
62    /// Applies `theme`'s named roles to this bar: `filled_style` becomes `theme.accent` (progress
63    /// reads as emphasis, the same role [`super::Tabs::theme`]/[`super::Button::theme`] use for a
64    /// selected/focused state) on `theme.panel_bg`, and `empty_style` becomes `theme.dim` on
65    /// `theme.panel_bg`.
66    ///
67    /// Both set an explicit background rather than leaving it at [`Style::new()`]'s default: an
68    /// unset background isn't "transparent" once a real backend draws it (a bare `Color::Default`
69    /// cell paints as solid black behind the glyph; see `retroglyph-software`'s `DEFAULT_BG`),
70    /// which matters most for `empty_style`'s `'░'` glyph (it doesn't fully cover its cell the way
71    /// `filled_style`'s `'█'` does, so its background actually shows). This widget assumes it's
72    /// drawn on `theme.panel_bg`, true when composed with a themed [`super::Panel`]/
73    /// [`super::Modal`]. Drawing this bar directly on the raw screen background instead needs a
74    /// manual `.filled_style(...)`/`.empty_style(...)` override afterwards.
75    ///
76    /// Call before any manual [`ProgressBar::filled_style`]/[`ProgressBar::empty_style`] override
77    /// you want to keep.
78    #[must_use]
79    pub fn theme(self, theme: Theme) -> Self {
80        self.theme_on(theme, theme.panel_bg)
81    }
82
83    /// Same as [`ProgressBar::theme`], but `filled_style`/`empty_style` are drawn on `bg` instead
84    /// of `theme.panel_bg`: for a bar drawn directly on a backdrop other than a themed
85    /// [`super::Panel`]/[`super::Modal`]'s fill. [`ProgressBar::theme`] is exactly
86    /// `theme_on(theme, theme.panel_bg)`.
87    #[must_use]
88    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
89        self.filled_style = Style::new().fg(theme.accent).bg(bg);
90        self.empty_style = Style::new().fg(theme.dim).bg(bg);
91        self
92    }
93}
94
95impl Widget for ProgressBar {
96    fn render(&self, surface: &mut Surface<'_>) {
97        let width = surface.width();
98        if width == 0 || self.max == 0 {
99            return;
100        }
101        // `value.min(max) <= max`, so `(value.min(max) * width) / max <= width`, itself a `u16`:
102        // the result always narrows back exactly.
103        #[allow(clippy::cast_possible_truncation)]
104        let filled_cells =
105            ((u64::from(self.value.min(self.max)) * u64::from(width)) / u64::from(self.max)) as u16;
106        for x in 0..width {
107            let is_filled = x < filled_cells;
108            let style = if is_filled {
109                self.filled_style
110            } else {
111                self.empty_style
112            };
113            surface.put((x, 0), if is_filled { '█' } else { '░' }, style);
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use retroglyph_core::grid::{Grid, Pos, Rect};
121
122    use super::*;
123
124    #[test]
125    fn fills_proportionally() {
126        let area = Rect::new(0, 0, 10, 1);
127        let mut grid = Grid::new(10, 1);
128        ProgressBar::new(5, 10).render(&mut Surface::new(&mut grid, area, 0));
129
130        for x in 0..5 {
131            assert_eq!(grid[Pos::new(x, 0)].glyph(), '█');
132        }
133        for x in 5..10 {
134            assert_eq!(grid[Pos::new(x, 0)].glyph(), '░');
135        }
136    }
137
138    #[test]
139    fn zero_max_is_a_no_op() {
140        let area = Rect::new(0, 0, 10, 1);
141        let mut grid = Grid::new(10, 1);
142        ProgressBar::new(0, 0).render(&mut Surface::new(&mut grid, area, 0));
143        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
144    }
145
146    #[test]
147    fn filled_and_empty_styles_are_configurable() {
148        use retroglyph_core::color::Color;
149
150        let area = Rect::new(0, 0, 4, 1);
151        let mut grid = Grid::new(4, 1);
152        ProgressBar::new(2, 4)
153            .filled_style(Style::new().fg(Color::WHITE))
154            .empty_style(Style::new().fg(Color::BLACK))
155            .render(&mut Surface::new(&mut grid, area, 0));
156
157        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::WHITE);
158        assert_eq!(grid[Pos::new(3, 0)].style().foreground(), Color::BLACK);
159    }
160
161    #[test]
162    fn theme_maps_named_roles_onto_filled_and_empty_styles() {
163        let area = Rect::new(0, 0, 4, 1);
164        let mut grid = Grid::new(4, 1);
165        ProgressBar::new(2, 4)
166            .theme(Theme::DARK)
167            .render(&mut Surface::new(&mut grid, area, 0));
168
169        assert_eq!(
170            grid[Pos::new(0, 0)].style().foreground(),
171            Theme::DARK.accent
172        );
173        assert_eq!(
174            grid[Pos::new(0, 0)].style().background(),
175            Theme::DARK.panel_bg
176        );
177        assert_eq!(grid[Pos::new(3, 0)].style().foreground(), Theme::DARK.dim);
178        assert_eq!(
179            grid[Pos::new(3, 0)].style().background(),
180            Theme::DARK.panel_bg
181        );
182    }
183
184    #[test]
185    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
186        let area = Rect::new(0, 0, 4, 1);
187        let mut grid = Grid::new(4, 1);
188        ProgressBar::new(2, 4)
189            .theme_on(Theme::DARK, Color::Default)
190            .render(&mut Surface::new(&mut grid, area, 0));
191
192        assert_eq!(
193            grid[Pos::new(0, 0)].style().foreground(),
194            Theme::DARK.accent
195        );
196        assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::Default);
197        assert_eq!(grid[Pos::new(3, 0)].style().foreground(), Theme::DARK.dim);
198        assert_eq!(grid[Pos::new(3, 0)].style().background(), Color::Default);
199    }
200}