Skip to main content

saorsa_core/widget/
progress_bar.rs

1//! Progress bar widget with determinate and indeterminate modes.
2//!
3//! Supports a filled/empty bar with percentage display (determinate mode)
4//! and an animated wave pattern (indeterminate mode).
5
6use crate::buffer::ScreenBuffer;
7use crate::cell::Cell;
8use crate::geometry::Rect;
9use crate::style::Style;
10
11use super::{BorderStyle, Widget};
12
13/// Progress bar mode.
14#[derive(Clone, Debug, PartialEq)]
15pub enum ProgressMode {
16    /// Determinate progress (0.0 to 1.0).
17    Determinate(f32),
18    /// Indeterminate animated progress.
19    Indeterminate {
20        /// Current animation phase.
21        phase: usize,
22    },
23}
24
25/// A progress bar widget.
26///
27/// Displays a horizontal bar showing progress. In determinate mode,
28/// the bar fills proportionally. In indeterminate mode, an animated
29/// wave pattern moves across the bar.
30pub struct ProgressBar {
31    /// Current progress mode.
32    mode: ProgressMode,
33    /// Style for the filled portion.
34    filled_style: Style,
35    /// Style for the empty portion.
36    empty_style: Style,
37    /// Style for the percentage label.
38    label_style: Style,
39    /// Whether to show the percentage text.
40    show_percentage: bool,
41    /// Border style.
42    border: BorderStyle,
43}
44
45/// Block characters for indeterminate animation (thin to thick).
46const WAVE_CHARS: &[&str] = &["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
47
48impl ProgressBar {
49    /// Create a determinate progress bar.
50    ///
51    /// Progress is clamped to 0.0..=1.0.
52    pub fn new(progress: f32) -> Self {
53        Self {
54            mode: ProgressMode::Determinate(progress.clamp(0.0, 1.0)),
55            filled_style: Style::default().reverse(true),
56            empty_style: Style::default(),
57            label_style: Style::default(),
58            show_percentage: true,
59            border: BorderStyle::None,
60        }
61    }
62
63    /// Create an indeterminate progress bar.
64    pub fn indeterminate() -> Self {
65        Self {
66            mode: ProgressMode::Indeterminate { phase: 0 },
67            filled_style: Style::default().reverse(true),
68            empty_style: Style::default(),
69            label_style: Style::default(),
70            show_percentage: false,
71            border: BorderStyle::None,
72        }
73    }
74
75    /// Set the filled portion style.
76    #[must_use]
77    pub fn with_filled_style(mut self, style: Style) -> Self {
78        self.filled_style = style;
79        self
80    }
81
82    /// Set the empty portion style.
83    #[must_use]
84    pub fn with_empty_style(mut self, style: Style) -> Self {
85        self.empty_style = style;
86        self
87    }
88
89    /// Set the percentage label style.
90    #[must_use]
91    pub fn with_label_style(mut self, style: Style) -> Self {
92        self.label_style = style;
93        self
94    }
95
96    /// Enable or disable percentage display.
97    #[must_use]
98    pub fn with_show_percentage(mut self, show: bool) -> Self {
99        self.show_percentage = show;
100        self
101    }
102
103    /// Set the border style.
104    #[must_use]
105    pub fn with_border(mut self, border: BorderStyle) -> Self {
106        self.border = border;
107        self
108    }
109
110    /// Set the progress value (0.0 to 1.0, clamped).
111    ///
112    /// Switches to determinate mode if currently indeterminate.
113    pub fn set_progress(&mut self, progress: f32) {
114        self.mode = ProgressMode::Determinate(progress.clamp(0.0, 1.0));
115    }
116
117    /// Get the current progress value.
118    ///
119    /// Returns `None` if in indeterminate mode.
120    pub fn progress(&self) -> Option<f32> {
121        match self.mode {
122            ProgressMode::Determinate(p) => Some(p),
123            ProgressMode::Indeterminate { .. } => None,
124        }
125    }
126
127    /// Advance the indeterminate animation phase.
128    ///
129    /// In determinate mode, this is a no-op.
130    pub fn tick(&mut self) {
131        if let ProgressMode::Indeterminate { ref mut phase } = self.mode {
132            *phase = phase.wrapping_add(1);
133        }
134    }
135
136    /// Get the current mode.
137    pub fn mode(&self) -> &ProgressMode {
138        &self.mode
139    }
140}
141
142impl Widget for ProgressBar {
143    fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
144        if area.size.width == 0 || area.size.height == 0 {
145            return;
146        }
147
148        super::border::render_border(area, self.border, self.empty_style.clone(), buf);
149        let inner = super::border::inner_area(area, self.border);
150        if inner.size.width == 0 || inner.size.height == 0 {
151            return;
152        }
153
154        let w = inner.size.width as usize;
155        let y = inner.position.y;
156        let x0 = inner.position.x;
157
158        match &self.mode {
159            ProgressMode::Determinate(progress) => {
160                let filled_count = ((progress * w as f32).round() as usize).min(w);
161
162                // Render filled portion
163                for i in 0..filled_count {
164                    buf.set(x0 + i as u16, y, Cell::new("█", self.filled_style.clone()));
165                }
166
167                // Render empty portion
168                for i in filled_count..w {
169                    buf.set(x0 + i as u16, y, Cell::new("░", self.empty_style.clone()));
170                }
171
172                // Overlay percentage label
173                if self.show_percentage {
174                    let pct = (progress * 100.0).round() as u32;
175                    let label = format!("{pct}%");
176                    let label_len = label.len();
177                    let start = w.saturating_sub(label_len) / 2;
178
179                    for (i, ch) in label.chars().enumerate() {
180                        let col = start + i;
181                        if col < w {
182                            buf.set(
183                                x0 + col as u16,
184                                y,
185                                Cell::new(ch.to_string(), self.label_style.clone()),
186                            );
187                        }
188                    }
189                }
190            }
191            ProgressMode::Indeterminate { phase } => {
192                let wave_len = WAVE_CHARS.len();
193                for i in 0..w {
194                    let char_idx = (i + phase) % (wave_len * 2);
195                    let ch = if char_idx < wave_len {
196                        WAVE_CHARS[char_idx]
197                    } else {
198                        WAVE_CHARS[wave_len * 2 - 1 - char_idx]
199                    };
200                    buf.set(x0 + i as u16, y, Cell::new(ch, self.filled_style.clone()));
201                }
202            }
203        }
204    }
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used)]
209mod tests {
210    use super::*;
211    use crate::geometry::Size;
212
213    #[test]
214    fn create_determinate_zero() {
215        let bar = ProgressBar::new(0.0);
216        assert_eq!(bar.progress(), Some(0.0));
217    }
218
219    #[test]
220    fn create_determinate_half() {
221        let bar = ProgressBar::new(0.5);
222        assert_eq!(bar.progress(), Some(0.5));
223    }
224
225    #[test]
226    fn create_determinate_full() {
227        let bar = ProgressBar::new(1.0);
228        assert_eq!(bar.progress(), Some(1.0));
229    }
230
231    #[test]
232    fn progress_clamped() {
233        let bar = ProgressBar::new(2.0);
234        assert_eq!(bar.progress(), Some(1.0));
235
236        let bar2 = ProgressBar::new(-0.5);
237        assert_eq!(bar2.progress(), Some(0.0));
238    }
239
240    #[test]
241    fn render_determinate_half() {
242        let bar = ProgressBar::new(0.5).with_show_percentage(false);
243        let mut buf = ScreenBuffer::new(Size::new(10, 1));
244        bar.render(Rect::new(0, 0, 10, 1), &mut buf);
245
246        // First 5 should be filled, last 5 empty
247        assert_eq!(buf.get(0, 0).unwrap().grapheme, "█");
248        assert_eq!(buf.get(4, 0).unwrap().grapheme, "█");
249        assert_eq!(buf.get(5, 0).unwrap().grapheme, "░");
250        assert_eq!(buf.get(9, 0).unwrap().grapheme, "░");
251    }
252
253    #[test]
254    fn render_determinate_full() {
255        let bar = ProgressBar::new(1.0).with_show_percentage(false);
256        let mut buf = ScreenBuffer::new(Size::new(10, 1));
257        bar.render(Rect::new(0, 0, 10, 1), &mut buf);
258
259        for i in 0..10 {
260            assert_eq!(buf.get(i, 0).unwrap().grapheme, "█");
261        }
262    }
263
264    #[test]
265    fn percentage_label_shown() {
266        let bar = ProgressBar::new(0.5).with_show_percentage(true);
267        let mut buf = ScreenBuffer::new(Size::new(20, 1));
268        bar.render(Rect::new(0, 0, 20, 1), &mut buf);
269
270        // "50%" should appear somewhere in the row
271        let row: String = (0..20)
272            .map(|x| buf.get(x, 0).map(|c| c.grapheme.as_str()).unwrap_or(" "))
273            .collect();
274        assert!(row.contains("50%"));
275    }
276
277    #[test]
278    fn set_progress_updates() {
279        let mut bar = ProgressBar::new(0.0);
280        bar.set_progress(0.75);
281        assert_eq!(bar.progress(), Some(0.75));
282    }
283
284    #[test]
285    fn indeterminate_mode() {
286        let bar = ProgressBar::indeterminate();
287        assert!(bar.progress().is_none());
288        assert!(matches!(
289            bar.mode(),
290            ProgressMode::Indeterminate { phase: 0 }
291        ));
292    }
293
294    #[test]
295    fn tick_advances_indeterminate() {
296        let mut bar = ProgressBar::indeterminate();
297        bar.tick();
298        assert!(matches!(
299            bar.mode(),
300            ProgressMode::Indeterminate { phase: 1 }
301        ));
302        bar.tick();
303        assert!(matches!(
304            bar.mode(),
305            ProgressMode::Indeterminate { phase: 2 }
306        ));
307    }
308
309    #[test]
310    fn indeterminate_renders() {
311        let bar = ProgressBar::indeterminate();
312        let mut buf = ScreenBuffer::new(Size::new(10, 1));
313        bar.render(Rect::new(0, 0, 10, 1), &mut buf);
314
315        // Should render wave chars, not empty
316        let first = buf.get(0, 0).unwrap().grapheme.clone();
317        assert!(WAVE_CHARS.contains(&first.as_str()) || first == "█" || first == "▏");
318    }
319
320    #[test]
321    fn border_rendering() {
322        let bar = ProgressBar::new(0.5)
323            .with_border(BorderStyle::Single)
324            .with_show_percentage(false);
325        let mut buf = ScreenBuffer::new(Size::new(12, 3));
326        bar.render(Rect::new(0, 0, 12, 3), &mut buf);
327
328        assert_eq!(buf.get(0, 0).unwrap().grapheme, "┌");
329        assert_eq!(buf.get(11, 0).unwrap().grapheme, "┐");
330        // Inner bar at row 1
331        assert_eq!(buf.get(1, 1).unwrap().grapheme, "█");
332    }
333}