Skip to main content

photon_ui/components/
progress_bar.rs

1//! Progress bar component.
2//!
3//! Renders as a bracketed bar with filled and empty segments, optionally
4//! followed by a percentage label.
5
6use crate::{
7    Component,
8    RenderError,
9    Rendered,
10    theme::{
11        Style,
12        Theme,
13        stylize,
14    },
15};
16
17/// A non-interactive progress bar.
18///
19/// Renders as `[██████░░░░]  60%` (or without the percentage if hidden).
20/// The filled segment uses the theme's accent color; the empty segment uses
21/// the theme's default border color.
22pub struct ProgressBar {
23    label: String,
24    value: f32,
25    width: u16,
26    show_percent: bool,
27}
28
29impl ProgressBar {
30    /// Create a new progress bar with the given label and value.
31    ///
32    /// `value` is clamped to the range `0.0..=1.0`.
33    pub fn new(label: impl Into<String>, value: f32) -> Self {
34        Self {
35            label: label.into(),
36            value: value.clamp(0.0, 1.0),
37            width: 20,
38            show_percent: true,
39        }
40    }
41
42    /// Set the total bar width in columns (including brackets).
43    pub fn width(mut self, width: u16) -> Self {
44        self.width = width;
45        self
46    }
47
48    /// Hide the percentage label.
49    pub fn hide_percent(mut self) -> Self {
50        self.show_percent = false;
51        self
52    }
53}
54
55impl Component for ProgressBar {
56    fn render(&self, _width: u16) -> Result<Rendered, RenderError> {
57        let theme = Theme::palette();
58        let accent_style = Style::new().fg(theme.accent());
59        let empty_style = Style::new().fg(theme.border());
60
61        let inner_width = self.width.saturating_sub(2) as usize;
62        let filled = (self.value * inner_width as f32).round() as usize;
63        let filled = filled.min(inner_width);
64        let empty = inner_width.saturating_sub(filled);
65
66        let filled_str = "█".repeat(filled);
67        let empty_str = "░".repeat(empty);
68
69        let filled_styled = stylize(&filled_str, &accent_style);
70        let empty_styled = stylize(&empty_str, &empty_style);
71
72        let bar = format!("[{}{}]", filled_styled, empty_styled);
73
74        let mut line = if self.label.is_empty() {
75            bar
76        } else {
77            format!("{} {}", self.label, bar)
78        };
79
80        if self.show_percent {
81            let percent = (self.value * 100.0).round() as u8;
82            line.push_str(&format!("  {}%", percent));
83        }
84
85        Ok(Rendered {
86            lines: vec![line],
87            cursor: None,
88            images: Vec::new(),
89        })
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::theme::Theme;
97
98    #[test]
99    fn renders_with_percent() {
100        Theme::with(Theme::Light, || {
101            let pb = ProgressBar::new("", 0.6).width(10);
102            let rendered = pb.render(80).unwrap();
103            assert_eq!(rendered.lines.len(), 1);
104            let line = &rendered.lines[0];
105            assert!(line.contains('['));
106            assert!(line.contains(']'));
107            assert!(line.contains("60%"));
108        });
109    }
110
111    #[test]
112    fn hides_percent() {
113        Theme::with(Theme::Light, || {
114            let pb = ProgressBar::new("", 0.6).width(10).hide_percent();
115            let rendered = pb.render(80).unwrap();
116            assert!(!rendered.lines[0].contains('%'));
117        });
118    }
119
120    #[test]
121    fn label_is_prepended() {
122        Theme::with(Theme::Light, || {
123            let pb = ProgressBar::new("Loading", 0.5).width(10);
124            let rendered = pb.render(80).unwrap();
125            assert!(rendered.lines[0].starts_with("Loading "));
126        });
127    }
128
129    #[test]
130    fn value_is_clamped() {
131        Theme::with(Theme::Light, || {
132            let pb = ProgressBar::new("", 1.5).width(10);
133            let rendered = pb.render(80).unwrap();
134            assert!(rendered.lines[0].contains("100%"));
135        });
136    }
137
138    #[test]
139    fn zero_value_renders_empty() {
140        Theme::with(Theme::Light, || {
141            let pb = ProgressBar::new("", 0.0).width(10);
142            let rendered = pb.render(80).unwrap();
143            let line = &rendered.lines[0];
144            let start = line.find('[').unwrap();
145            let end = line.find(']').unwrap();
146            let inner = &line[start + 1..end];
147            // No filled blocks inside the brackets
148            assert!(!inner.contains('█'));
149        });
150    }
151
152    #[test]
153    fn full_value_renders_full() {
154        Theme::with(Theme::Light, || {
155            let pb = ProgressBar::new("", 1.0).width(10);
156            let rendered = pb.render(80).unwrap();
157            let line = &rendered.lines[0];
158            let start = line.find('[').unwrap();
159            let end = line.find(']').unwrap();
160            let inner = &line[start + 1..end];
161            // No empty blocks inside the brackets
162            assert!(!inner.contains('░'));
163        });
164    }
165
166    #[test]
167    fn uses_accent_color() {
168        Theme::with(Theme::Light, || {
169            let pb = ProgressBar::new("", 0.5).width(10);
170            let rendered = pb.render(80).unwrap();
171            // Light theme accent is SUNBEAM_ORANGE (#fa520f = 250,82,15)
172            assert!(rendered.lines[0].contains("\x1b[38;2;250;82;15m"));
173        });
174    }
175}