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