Skip to main content

limit_tui/components/
progress.rs

1// Progress indicators for limit-tui
2//
3// This module provides ProgressBar and Spinner components for displaying
4// progress and loading states in terminal UI applications.
5
6use tracing::debug;
7
8use ratatui::{
9    buffer::Buffer,
10    layout::Rect,
11    style::{Color, Style},
12    widgets::{Gauge, Paragraph, Widget},
13};
14
15/// Default spinner animation frames
16const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
17
18/// Progress bar component that displays a percentage-based progress indicator
19///
20/// The progress bar consists of:
21/// - A label displayed above the bar
22/// - A filled bar showing the percentage of completion
23/// - Percentage text shown on the bar
24#[derive(Debug, Clone)]
25pub struct ProgressBar {
26    /// Current progress value (0.0 to 1.0)
27    value: f32,
28    /// Label displayed above the progress bar
29    label: String,
30    /// Width of the progress bar (in terminal columns)
31    width: u16,
32}
33
34impl ProgressBar {
35    /// Create a new progress bar with a given label
36    ///
37    /// # Arguments
38    ///
39    /// * `label` - The text to display above the progress bar
40    ///
41    /// # Returns
42    ///
43    /// A new `ProgressBar` instance with value set to 0.0
44    ///
45    /// # Example
46    ///
47    /// ```rust
48    /// use limit_tui::components::ProgressBar;
49    ///
50    /// let bar = ProgressBar::new("Downloading");
51    /// ```
52    pub fn new(label: &str) -> Self {
53        debug!(component = %"ProgressBar", "Component created");
54        Self {
55            value: 0.0,
56            label: label.to_string(),
57            width: 40,
58        }
59    }
60
61    /// Set the progress value
62    /// Set the progress value
63    ///
64    /// The value should be between 0.0 (0%) and 1.0 (100%).
65    /// Values outside this range will be clamped.
66    ///
67    /// # Arguments
68    ///
69    /// * `value` - Progress value (0.0 to 1.0)
70    ///
71    /// # Example
72    ///
73    /// ```rust
74    /// use limit_tui::components::ProgressBar;
75    ///
76    /// let mut bar = ProgressBar::new("Loading");
77    /// bar.set_value(0.5); // 50% complete
78    /// ```
79    pub fn set_value(&mut self, value: f32) {
80        // Clamp value between 0.0 and 1.0
81        self.value = value.clamp(0.0, 1.0);
82    }
83
84    /// Get the current progress value
85    ///
86    /// # Returns
87    ///
88    /// The current progress value (0.0 to 1.0)
89    pub fn value(&self) -> f32 {
90        self.value
91    }
92
93    /// Set the width of the progress bar
94    ///
95    /// # Arguments
96    ///
97    /// * `width` - Width in terminal columns
98    pub fn set_width(&mut self, width: u16) {
99        self.width = width;
100    }
101
102    /// Render the progress bar to a buffer
103    ///
104    /// # Arguments
105    ///
106    /// * `area` - The area to render the progress bar in
107    /// * `buf` - The buffer to render to
108    pub fn render(&self, area: Rect, buf: &mut Buffer) {
109        if area.height < 2 {
110            return;
111        }
112
113        // Render label above the bar
114        let label_area = Rect {
115            x: area.x,
116            y: area.y,
117            width: area.width,
118            height: 1,
119        };
120        let label_paragraph = Paragraph::new(self.label.as_str());
121        label_paragraph.render(label_area, buf);
122
123        // Calculate bar area (below the label)
124        let bar_area = Rect {
125            x: area.x,
126            y: area.y + 1,
127            width: self.width.min(area.width),
128            height: 1,
129        };
130
131        // Render the gauge with the progress value
132        let gauge = Gauge::default()
133            .percent((self.value * 100.0) as u16)
134            .style(Style::default().fg(Color::Green))
135            .label(format!("{:.0}%", self.value * 100.0));
136
137        gauge.render(bar_area, buf);
138    }
139}
140
141impl Default for ProgressBar {
142    fn default() -> Self {
143        Self::new("Progress")
144    }
145}
146
147/// Spinner component for displaying loading animations
148///
149/// The spinner consists of:
150/// - A rotating character animation
151/// - A label displayed next to the spinner
152#[derive(Debug, Clone)]
153pub struct Spinner {
154    /// Current frame index
155    current_frame: usize,
156    /// Animation frames for the spinner
157    frames: Vec<String>,
158    /// Label displayed next to the spinner
159    label: String,
160}
161
162impl Spinner {
163    /// Create a new spinner with a given label
164    ///
165    /// # Arguments
166    ///
167    /// * `label` - The text to display next to the spinner
168    ///
169    /// # Returns
170    ///
171    /// A new `Spinner` instance ready for animation
172    ///
173    /// # Example
174    ///
175    /// ```rust
176    /// use limit_tui::components::Spinner;
177    ///
178    /// let spinner = Spinner::new("Loading...");
179    /// ```
180    pub fn new(label: &str) -> Self {
181        debug!(component = %"Spinner", "Component created");
182        Self {
183            current_frame: 0,
184            frames: SPINNER_FRAMES.iter().map(|s| s.to_string()).collect(),
185            label: label.to_string(),
186        }
187    }
188
189    /// Create a new spinner with custom frames
190    /// Create a new spinner with custom frames
191    ///
192    /// # Arguments
193    ///
194    /// * `label` - The text to display next to the spinner
195    /// * `frames` - Vector of animation frames
196    ///
197    /// # Returns
198    ///
199    /// A new `Spinner` instance with custom animation frames
200    pub fn with_frames(label: &str, frames: Vec<String>) -> Self {
201        Self {
202            current_frame: 0,
203            frames,
204            label: label.to_string(),
205        }
206    }
207
208    /// Advance the spinner to the next frame
209    ///
210    /// Call this method at your desired frame rate. For a smooth
211    /// 10 FPS animation, call `tick()` every 100ms.
212    ///
213    /// # Example
214    ///
215    /// ```rust,no_run
216    /// use limit_tui::components::Spinner;
217    /// use std::thread;
218    /// use std::time::Duration;
219    ///
220    /// let mut spinner = Spinner::new("Loading...");
221    /// loop {
222    ///     spinner.tick();
223    ///     // render spinner
224    ///     thread::sleep(Duration::from_millis(100)); // 10 FPS
225    /// }
226    /// ```
227    pub fn tick(&mut self) {
228        self.current_frame = (self.current_frame + 1) % self.frames.len();
229    }
230
231    /// Get the current animation frame
232    ///
233    /// # Returns
234    ///
235    /// The current spinner character
236    pub fn current_frame(&self) -> &str {
237        &self.frames[self.current_frame]
238    }
239
240    /// Render the spinner to a buffer
241    ///
242    /// # Arguments
243    ///
244    /// * `area` - The area to render the spinner in
245    /// * `buf` - The buffer to render to
246    pub fn render(&self, area: Rect, buf: &mut Buffer) {
247        let text = format!("{} {}", self.current_frame(), self.label);
248        let paragraph = Paragraph::new(text.as_str());
249        paragraph.render(area, buf);
250    }
251}
252
253impl Default for Spinner {
254    fn default() -> Self {
255        Self::new("Loading...")
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_progress_bar_new() {
265        let bar = ProgressBar::new("Test");
266        assert_eq!(bar.value(), 0.0);
267        assert_eq!(bar.label, "Test");
268    }
269
270    #[test]
271    fn test_progress_bar_default() {
272        let bar = ProgressBar::default();
273        assert_eq!(bar.value(), 0.0);
274        assert_eq!(bar.label, "Progress");
275    }
276
277    #[test]
278    fn test_progress_bar_set_value() {
279        let mut bar = ProgressBar::new("Test");
280        bar.set_value(0.5);
281        assert_eq!(bar.value(), 0.5);
282    }
283
284    #[test]
285    fn test_progress_bar_set_value_clamps_high() {
286        let mut bar = ProgressBar::new("Test");
287        bar.set_value(1.5);
288        assert_eq!(bar.value(), 1.0);
289    }
290
291    #[test]
292    fn test_progress_bar_set_value_clamps_low() {
293        let mut bar = ProgressBar::new("Test");
294        bar.set_value(-0.5);
295        assert_eq!(bar.value(), 0.0);
296    }
297
298    #[test]
299    fn test_progress_bar_set_width() {
300        let mut bar = ProgressBar::new("Test");
301        bar.set_width(50);
302        assert_eq!(bar.width, 50);
303    }
304
305    #[test]
306    fn test_progress_bar_render() {
307        let mut buffer = Buffer::empty(Rect {
308            x: 0,
309            y: 0,
310            width: 40,
311            height: 2,
312        });
313
314        let mut bar = ProgressBar::new("Test");
315        bar.set_value(0.5);
316        bar.render(
317            Rect {
318                x: 0,
319                y: 0,
320                width: 40,
321                height: 2,
322            },
323            &mut buffer,
324        );
325
326        // Verify that something was rendered by checking the buffer
327        let _cell = buffer.cell((0, 0));
328        // The buffer should have been modified
329        assert!(!buffer.content.is_empty());
330    }
331
332    #[test]
333    fn test_spinner_new() {
334        let spinner = Spinner::new("Loading...");
335        assert_eq!(spinner.label, "Loading...");
336        assert_eq!(spinner.current_frame, 0);
337        assert_eq!(spinner.frames.len(), SPINNER_FRAMES.len());
338    }
339
340    #[test]
341    fn test_spinner_default() {
342        let spinner = Spinner::default();
343        assert_eq!(spinner.label, "Loading...");
344        assert_eq!(spinner.current_frame, 0);
345    }
346
347    #[test]
348    fn test_spinner_with_frames() {
349        let custom_frames = vec!["|".to_string(), "/".to_string(), "-".to_string()];
350        let spinner = Spinner::with_frames("Custom", custom_frames.clone());
351        assert_eq!(spinner.frames, custom_frames);
352    }
353
354    #[test]
355    fn test_spinner_tick() {
356        let mut spinner = Spinner::new("Loading...");
357        let initial_frame = spinner.current_frame();
358        let initial_frame_str = initial_frame.to_string(); // Capture the value
359
360        spinner.tick();
361        assert_eq!(spinner.current_frame, 1);
362        assert_ne!(spinner.current_frame(), initial_frame_str.as_str());
363    }
364
365    #[test]
366    fn test_spinner_tick_wraps() {
367        let custom_frames = vec!["|".to_string(), "/".to_string(), "-".to_string()];
368        let mut spinner = Spinner::with_frames("Custom", custom_frames);
369
370        // Tick through all frames
371        spinner.tick(); // frame 1
372        spinner.tick(); // frame 2
373        spinner.tick(); // back to frame 0
374
375        assert_eq!(spinner.current_frame, 0);
376        assert_eq!(spinner.current_frame(), "|");
377    }
378
379    #[test]
380    fn test_spinner_current_frame() {
381        let spinner = Spinner::new("Loading...");
382        let frame = spinner.current_frame();
383        assert_eq!(frame, SPINNER_FRAMES[0]);
384    }
385
386    #[test]
387    fn test_spinner_render() {
388        let mut buffer = Buffer::empty(Rect {
389            x: 0,
390            y: 0,
391            width: 20,
392            height: 1,
393        });
394
395        let spinner = Spinner::new("Loading...");
396        spinner.render(
397            Rect {
398                x: 0,
399                y: 0,
400                width: 20,
401                height: 1,
402            },
403            &mut buffer,
404        );
405
406        // Verify that something was rendered
407        assert!(!buffer.content.is_empty());
408    }
409}