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