Skip to main content

retroglyph_widgets/widget/
progress_bar.rs

1//! [`ProgressBar`]: a horizontal progress bar.
2use retroglyph_core::{Backend, 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(mut self, theme: Theme) -> Self {
65        self.filled_style = Style::new().fg(theme.accent).bg(theme.panel_bg);
66        self.empty_style = Style::new().fg(theme.dim).bg(theme.panel_bg);
67        self
68    }
69}
70
71impl<B: Backend> Widget<B> for ProgressBar {
72    fn render(self, area: Rect, term: &mut Terminal<B>) {
73        if area.width() == 0 || self.max == 0 {
74            return;
75        }
76        let filled_cells = ((u64::from(self.value.min(self.max)) * u64::from(area.width()))
77            / u64::from(self.max)) as u16;
78        let y = area.top();
79        for x in area.left()..area.right() {
80            let is_filled = x < area.left() + filled_cells;
81            let style = if is_filled {
82                self.filled_style
83            } else {
84                self.empty_style
85            };
86            term.reset_style()
87                .fg(style.foreground())
88                .bg(style.background());
89            term.put(x, y, if is_filled { '█' } else { '░' });
90        }
91        term.reset_style();
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use retroglyph_core::Headless;
98
99    use super::*;
100
101    #[test]
102    fn fills_proportionally() {
103        let area = Rect::new(0, 0, 10, 1);
104        let mut term = Terminal::new(Headless::new(10, 1));
105        ProgressBar::new(5, 10).render(area, &mut term);
106
107        for x in 0..5 {
108            assert_eq!(term.grid().get(x, 0).glyph(), '█');
109        }
110        for x in 5..10 {
111            assert_eq!(term.grid().get(x, 0).glyph(), '░');
112        }
113    }
114
115    #[test]
116    fn zero_max_is_a_no_op() {
117        let area = Rect::new(0, 0, 10, 1);
118        let mut term = Terminal::new(Headless::new(10, 1));
119        ProgressBar::new(0, 0).render(area, &mut term);
120        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
121    }
122
123    #[test]
124    fn filled_and_empty_styles_are_configurable() {
125        use retroglyph_core::Color;
126
127        let area = Rect::new(0, 0, 4, 1);
128        let mut term = Terminal::new(Headless::new(4, 1));
129        ProgressBar::new(2, 4)
130            .filled_style(Style::new().fg(Color::WHITE))
131            .empty_style(Style::new().fg(Color::BLACK))
132            .render(area, &mut term);
133
134        assert_eq!(term.grid().get(0, 0).style().foreground(), Color::WHITE);
135        assert_eq!(term.grid().get(3, 0).style().foreground(), Color::BLACK);
136    }
137
138    #[test]
139    fn theme_maps_named_roles_onto_filled_and_empty_styles() {
140        let area = Rect::new(0, 0, 4, 1);
141        let mut term = Terminal::new(Headless::new(4, 1));
142        ProgressBar::new(2, 4)
143            .theme(Theme::DARK)
144            .render(area, &mut term);
145
146        assert_eq!(
147            term.grid().get(0, 0).style().foreground(),
148            Theme::DARK.accent
149        );
150        assert_eq!(
151            term.grid().get(0, 0).style().background(),
152            Theme::DARK.panel_bg
153        );
154        assert_eq!(term.grid().get(3, 0).style().foreground(), Theme::DARK.dim);
155        assert_eq!(
156            term.grid().get(3, 0).style().background(),
157            Theme::DARK.panel_bg
158        );
159    }
160}