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}