Skip to main content

retroglyph_widgets/widget/
progress_bar.rs

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