Skip to main content

ratatui_interact/components/
progress.rs

1//! Progress bar widget
2//!
3//! A styled progress bar with label and step counter support.
4//!
5//! # Example
6//!
7//! ```rust
8//! use ratatui_interact::components::{Progress, ProgressStyle};
9//! use ratatui::layout::Rect;
10//! use ratatui::buffer::Buffer;
11//! use ratatui::widgets::Widget;
12//!
13//! // Simple progress bar
14//! let progress = Progress::new(0.75)
15//!     .label("Processing");
16//!
17//! // With step counter
18//! let progress = Progress::new(0.5)
19//!     .label("Building")
20//!     .steps(5, 10);
21//!
22//! // Custom style
23//! let progress = Progress::new(0.25)
24//!     .style(ProgressStyle::warning());
25//! ```
26
27use ratatui::{
28    buffer::Buffer,
29    layout::Rect,
30    style::{Color, Modifier, Style},
31    text::Span,
32    widgets::{Block, Borders, Gauge, Widget},
33};
34
35/// Style configuration for progress bars
36#[derive(Debug, Clone)]
37pub struct ProgressStyle {
38    /// Foreground color of the filled portion
39    pub filled_color: Color,
40    /// Background color of the unfilled portion
41    pub unfilled_color: Color,
42    /// Style for the label text
43    pub label_style: Style,
44    /// Whether to show borders
45    pub bordered: bool,
46}
47
48impl Default for ProgressStyle {
49    fn default() -> Self {
50        Self {
51            filled_color: Color::Green,
52            unfilled_color: Color::DarkGray,
53            label_style: Style::default()
54                .fg(Color::White)
55                .add_modifier(Modifier::BOLD),
56            bordered: true,
57        }
58    }
59}
60
61impl From<&crate::theme::Theme> for ProgressStyle {
62    fn from(theme: &crate::theme::Theme) -> Self {
63        let p = &theme.palette;
64        Self {
65            filled_color: p.success,
66            unfilled_color: p.text_disabled,
67            label_style: Style::default().fg(p.text).add_modifier(Modifier::BOLD),
68            bordered: true,
69        }
70    }
71}
72
73impl ProgressStyle {
74    /// Create a new progress style with custom colors
75    pub fn new(filled: Color, unfilled: Color) -> Self {
76        Self {
77            filled_color: filled,
78            unfilled_color: unfilled,
79            ..Default::default()
80        }
81    }
82
83    /// Success style (green)
84    pub fn success() -> Self {
85        Self::default()
86    }
87
88    /// Warning style (yellow)
89    pub fn warning() -> Self {
90        Self {
91            filled_color: Color::Yellow,
92            ..Default::default()
93        }
94    }
95
96    /// Error style (red)
97    pub fn error() -> Self {
98        Self {
99            filled_color: Color::Red,
100            ..Default::default()
101        }
102    }
103
104    /// Info style (cyan)
105    pub fn info() -> Self {
106        Self {
107            filled_color: Color::Cyan,
108            ..Default::default()
109        }
110    }
111
112    /// Set whether to show borders
113    pub fn bordered(mut self, bordered: bool) -> Self {
114        self.bordered = bordered;
115        self
116    }
117}
118
119/// A progress bar widget with label and step counter support.
120///
121/// The progress value should be between 0.0 and 1.0.
122#[derive(Debug, Clone)]
123pub struct Progress<'a> {
124    /// Progress value (0.0 to 1.0)
125    ratio: f64,
126    /// Optional label text
127    label: Option<&'a str>,
128    /// Optional step counter (current, total)
129    steps: Option<(usize, usize)>,
130    /// Style configuration
131    style: ProgressStyle,
132}
133
134impl<'a> Progress<'a> {
135    /// Create a new progress bar with the given ratio (0.0 to 1.0)
136    pub fn new(ratio: f64) -> Self {
137        Self {
138            ratio: ratio.clamp(0.0, 1.0),
139            label: None,
140            steps: None,
141            style: ProgressStyle::default(),
142        }
143    }
144
145    /// Create a progress bar from current/total values
146    pub fn from_steps(current: usize, total: usize) -> Self {
147        let ratio = if total > 0 {
148            current as f64 / total as f64
149        } else {
150            0.0
151        };
152        Self::new(ratio).steps(current, total)
153    }
154
155    /// Set the label text
156    pub fn label(mut self, label: &'a str) -> Self {
157        self.label = Some(label);
158        self
159    }
160
161    /// Set the step counter (current step, total steps)
162    pub fn steps(mut self, current: usize, total: usize) -> Self {
163        self.steps = Some((current, total));
164        self
165    }
166
167    /// Set the style
168    pub fn style(mut self, style: ProgressStyle) -> Self {
169        self.style = style;
170        self
171    }
172
173    /// Apply a theme to derive the style
174    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
175        self.style(ProgressStyle::from(theme))
176    }
177
178    /// Build the label string
179    fn build_label(&self) -> String {
180        let percent = (self.ratio * 100.0) as u16;
181
182        match (&self.label, &self.steps) {
183            (Some(label), Some((current, total))) => {
184                format!("{} - {}/{} steps ({}%)", label, current, total, percent)
185            }
186            (Some(label), None) => {
187                format!("{} ({}%)", label, percent)
188            }
189            (None, Some((current, total))) => {
190                format!("{}/{} ({}%)", current, total, percent)
191            }
192            (None, None) => {
193                format!("{}%", percent)
194            }
195        }
196    }
197}
198
199impl Widget for Progress<'_> {
200    fn render(self, area: Rect, buf: &mut Buffer) {
201        let label = self.build_label();
202        let label_span = Span::styled(label, self.style.label_style);
203
204        let mut gauge = Gauge::default()
205            .gauge_style(
206                Style::default()
207                    .fg(self.style.filled_color)
208                    .bg(self.style.unfilled_color),
209            )
210            .percent((self.ratio * 100.0) as u16)
211            .label(label_span);
212
213        if self.style.bordered {
214            gauge = gauge.block(Block::default().borders(Borders::ALL));
215        }
216
217        gauge.render(area, buf);
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_progress_new() {
227        let p = Progress::new(0.5);
228        assert!((p.ratio - 0.5).abs() < 0.001);
229    }
230
231    #[test]
232    fn test_progress_clamp() {
233        let p = Progress::new(1.5);
234        assert!((p.ratio - 1.0).abs() < 0.001);
235
236        let p = Progress::new(-0.5);
237        assert!((p.ratio - 0.0).abs() < 0.001);
238    }
239
240    #[test]
241    fn test_progress_from_steps() {
242        let p = Progress::from_steps(5, 10);
243        assert!((p.ratio - 0.5).abs() < 0.001);
244        assert_eq!(p.steps, Some((5, 10)));
245    }
246
247    #[test]
248    fn test_progress_label() {
249        let p = Progress::new(0.75).label("Building");
250        assert_eq!(p.build_label(), "Building (75%)");
251    }
252
253    #[test]
254    fn test_progress_label_with_steps() {
255        let p = Progress::new(0.5).label("Processing").steps(5, 10);
256        assert_eq!(p.build_label(), "Processing - 5/10 steps (50%)");
257    }
258
259    #[test]
260    fn test_progress_render() {
261        let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
262        let progress = Progress::new(0.5).label("Test");
263        progress.render(Rect::new(0, 0, 40, 3), &mut buf);
264        // Just verify it doesn't panic
265    }
266}