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