Skip to main content

flywheel/widget/
progress_bar.rs

1//! Progress Bar Widget: Horizontal progress indicator.
2//!
3//! A horizontal progress bar with customizable styling and optional
4//! percentage/label display.
5
6use crate::actor::InputEvent;
7use crate::buffer::{Buffer, Cell, Rgb};
8use crate::layout::Rect;
9use super::traits::Widget;
10
11/// Visual style for the progress bar.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[derive(Default)]
14pub enum ProgressStyle {
15    /// Classic solid bar: ████████░░░░
16    Solid,
17    /// ASCII style: [========  ]
18    Ascii,
19    /// Block characters: ▓▓▓▓▓▓░░░░
20    #[default]
21    Block,
22    /// Thin line: ───────────
23    Line,
24}
25
26
27/// Configuration for the progress bar widget.
28#[derive(Debug, Clone)]
29pub struct ProgressBarConfig {
30    /// Style of the bar.
31    pub style: ProgressStyle,
32    /// Filled portion color.
33    pub filled_fg: Rgb,
34    /// Empty portion color.
35    pub empty_fg: Rgb,
36    /// Background color.
37    pub bg: Rgb,
38    /// Whether to show percentage text.
39    pub show_percentage: bool,
40    /// Percentage text color.
41    pub percentage_fg: Rgb,
42    /// Optional label to show.
43    pub label: Option<String>,
44    /// Label color.
45    pub label_fg: Rgb,
46}
47
48impl Default for ProgressBarConfig {
49    fn default() -> Self {
50        Self {
51            style: ProgressStyle::Block,
52            filled_fg: Rgb::new(0, 200, 100),
53            empty_fg: Rgb::new(60, 60, 60),
54            bg: Rgb::new(30, 30, 30),
55            show_percentage: true,
56            percentage_fg: Rgb::WHITE,
57            label: None,
58            label_fg: Rgb::new(150, 150, 150),
59        }
60    }
61}
62
63/// A horizontal progress bar widget.
64#[derive(Debug)]
65pub struct ProgressBar {
66    /// Current progress (0.0 to 1.0).
67    progress: f32,
68    /// Widget bounds.
69    bounds: Rect,
70    /// Configuration.
71    config: ProgressBarConfig,
72    /// Needs redraw flag.
73    dirty: bool,
74}
75
76impl ProgressBar {
77    /// Create a new progress bar with the given bounds.
78    pub fn new(bounds: Rect) -> Self {
79        Self {
80            progress: 0.0,
81            bounds,
82            config: ProgressBarConfig::default(),
83            dirty: true,
84        }
85    }
86
87    /// Create a new progress bar with custom configuration.
88    pub const fn with_config(bounds: Rect, config: ProgressBarConfig) -> Self {
89        Self {
90            progress: 0.0,
91            bounds,
92            config,
93            dirty: true,
94        }
95    }
96
97    /// Set the progress value (clamped to 0.0-1.0).
98    pub const fn set_progress(&mut self, progress: f32) {
99        self.progress = progress.clamp(0.0, 1.0);
100        self.dirty = true;
101    }
102
103    /// Get the current progress value.
104    pub const fn progress(&self) -> f32 {
105        self.progress
106    }
107
108    /// Set the label text.
109    pub fn set_label(&mut self, label: impl Into<String>) {
110        self.config.label = Some(label.into());
111        self.dirty = true;
112    }
113
114    /// Clear the label.
115    pub fn clear_label(&mut self) {
116        self.config.label = None;
117        self.dirty = true;
118    }
119
120    /// Increment progress by a delta (clamped).
121    pub fn increment(&mut self, delta: f32) {
122        self.set_progress(self.progress + delta);
123    }
124
125    /// Check if progress is complete (>= 1.0).
126    pub fn is_complete(&self) -> bool {
127        self.progress >= 1.0
128    }
129
130    /// Get the filled and empty characters for the current style.
131    const fn style_chars(&self) -> (char, char) {
132        match self.config.style {
133            ProgressStyle::Solid => ('█', '░'),
134            ProgressStyle::Ascii => ('=', ' '),
135            ProgressStyle::Block => ('▓', '░'),
136            ProgressStyle::Line => ('─', '─'),
137        }
138    }
139}
140
141impl Widget for ProgressBar {
142    fn bounds(&self) -> Rect {
143        self.bounds
144    }
145
146    fn set_bounds(&mut self, bounds: Rect) {
147        self.bounds = bounds;
148        self.dirty = true;
149    }
150
151    #[allow(clippy::cast_possible_truncation)]
152    #[allow(clippy::cast_sign_loss)]
153    #[allow(clippy::cast_precision_loss)]
154    fn render(&self, buffer: &mut Buffer) {
155        let x = self.bounds.x;
156        let y = self.bounds.y;
157        let width = self.bounds.width as usize;
158
159        // Clear the line with background
160        for i in 0..self.bounds.width {
161            buffer.set(x + i, y, Cell::new(' ').with_bg(self.config.bg));
162        }
163
164        // Calculate space for label and percentage
165        let label_len = self.config.label.as_ref().map_or(0, |l| l.chars().count() + 1);
166        let pct_len = if self.config.show_percentage { 5 } else { 0 }; // " 100%"
167        let bar_width = width.saturating_sub(label_len + pct_len);
168
169        if bar_width == 0 {
170            return;
171        }
172
173        let mut offset = x;
174
175        // Draw label if present
176        if let Some(ref label) = self.config.label {
177            for c in label.chars().take(width / 3) {
178                buffer.set(offset, y, Cell::new(c)
179                    .with_fg(self.config.label_fg)
180                    .with_bg(self.config.bg));
181                offset += 1;
182            }
183            buffer.set(offset, y, Cell::new(' ').with_bg(self.config.bg));
184            offset += 1;
185        }
186
187        // Draw progress bar
188        let (filled_char, empty_char) = self.style_chars();
189        let filled_count = (self.progress * bar_width as f32).round() as usize;
190
191        for i in 0..bar_width {
192            let (c, fg) = if i < filled_count {
193                (filled_char, self.config.filled_fg)
194            } else {
195                (empty_char, self.config.empty_fg)
196            };
197            buffer.set(offset + i as u16, y, Cell::new(c)
198                .with_fg(fg)
199                .with_bg(self.config.bg));
200        }
201        offset += bar_width as u16;
202
203        // Draw percentage
204        if self.config.show_percentage {
205            let pct = format!(" {:>3}%", (self.progress * 100.0).round() as u32);
206            for c in pct.chars() {
207                buffer.set(offset, y, Cell::new(c)
208                    .with_fg(self.config.percentage_fg)
209                    .with_bg(self.config.bg));
210                offset += 1;
211            }
212        }
213    }
214
215    fn handle_input(&mut self, _event: &InputEvent) -> bool {
216        // Progress bar doesn't handle input
217        false
218    }
219
220    fn needs_redraw(&self) -> bool {
221        self.dirty
222    }
223
224    fn clear_redraw(&mut self) {
225        self.dirty = false;
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_progress_bar_basic() {
235        let mut bar = ProgressBar::new(Rect::new(0, 0, 80, 1));
236        
237        assert_eq!(bar.progress(), 0.0);
238        
239        bar.set_progress(0.5);
240        assert_eq!(bar.progress(), 0.5);
241        
242        bar.set_progress(1.5); // Should clamp
243        assert_eq!(bar.progress(), 1.0);
244    }
245
246    #[test]
247    fn test_progress_bar_increment() {
248        let mut bar = ProgressBar::new(Rect::new(0, 0, 80, 1));
249        
250        bar.increment(0.25);
251        assert!((bar.progress() - 0.25).abs() < f32::EPSILON);
252        
253        bar.increment(0.25);
254        assert!((bar.progress() - 0.5).abs() < f32::EPSILON);
255    }
256
257    #[test]
258    fn test_progress_bar_complete() {
259        let mut bar = ProgressBar::new(Rect::new(0, 0, 80, 1));
260        
261        assert!(!bar.is_complete());
262        
263        bar.set_progress(1.0);
264        assert!(bar.is_complete());
265    }
266}