Skip to main content

retroglyph_widgets/widget/
progress_bar.rs

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