Skip to main content

boxmux_lib/components/
progress_bar.rs

1//! Progress Bar Component - Interactive progress bar component for loading states
2//!
3//! This component provides comprehensive progress bar rendering with customizable
4//! styles, animations, and progress tracking. Supports horizontal and vertical bars,
5//! percentage display, and integration with long-running operations.
6
7use crate::model::common::Bounds;
8use crossterm::style::Color;
9use std::time::{Duration, Instant};
10
11/// Configuration for progress bar styling and behavior
12#[derive(Debug, Clone, PartialEq)]
13pub struct ProgressBarConfig {
14    /// Progress bar orientation
15    pub orientation: ProgressBarOrientation,
16    /// Fill character for completed portion
17    pub fill_char: char,
18    /// Background character for uncompleted portion
19    pub background_char: char,
20    /// Color for completed portion
21    pub fill_color: Color,
22    /// Color for uncompleted portion
23    pub background_color: Color,
24    /// Color for percentage text
25    pub text_color: Color,
26    /// Show percentage text
27    pub show_percentage: bool,
28    /// Show progress text (e.g., "Loading...")
29    pub show_progress_text: bool,
30    /// Progress text template
31    pub progress_text_template: String,
32    /// Show animation
33    pub animated: bool,
34    /// Animation speed (frames per second)
35    pub animation_speed: f64,
36    /// Border characters for styling
37    pub border_chars: Option<ProgressBarBorders>,
38    /// Minimum width for horizontal bars
39    pub min_width: usize,
40    /// Minimum height for vertical bars
41    pub min_height: usize,
42}
43
44/// Progress bar orientation
45#[derive(Debug, Clone, PartialEq)]
46pub enum ProgressBarOrientation {
47    Horizontal,
48    Vertical,
49}
50
51/// Border characters for progress bar styling
52#[derive(Debug, Clone, PartialEq)]
53pub struct ProgressBarBorders {
54    pub left: char,
55    pub right: char,
56    pub top: char,
57    pub bottom: char,
58}
59
60impl Default for ProgressBarConfig {
61    fn default() -> Self {
62        Self {
63            orientation: ProgressBarOrientation::Horizontal,
64            fill_char: '█',
65            background_char: '░',
66            fill_color: Color::Green,
67            background_color: Color::DarkGrey,
68            text_color: Color::White,
69            show_percentage: true,
70            show_progress_text: false,
71            progress_text_template: "{text} {percentage}%".to_string(),
72            animated: false,
73            animation_speed: 2.0,
74            border_chars: Some(ProgressBarBorders {
75                left: '[',
76                right: ']',
77                top: '─',
78                bottom: '─',
79            }),
80            min_width: 10,
81            min_height: 3,
82        }
83    }
84}
85
86/// Progress state for tracking current progress
87#[derive(Debug, Clone, PartialEq)]
88pub struct ProgressState {
89    /// Current progress value (0.0 - 1.0)
90    pub progress: f64,
91    /// Optional progress text
92    pub text: Option<String>,
93    /// Start time for calculating duration
94    pub start_time: Instant,
95    /// Last update time for animation
96    pub last_update: Instant,
97    /// Animation frame counter
98    pub animation_frame: usize,
99}
100
101impl Default for ProgressState {
102    fn default() -> Self {
103        let now = Instant::now();
104        Self {
105            progress: 0.0,
106            text: None,
107            start_time: now,
108            last_update: now,
109            animation_frame: 0,
110        }
111    }
112}
113
114impl ProgressState {
115    /// Create new progress state with current time
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Update progress value (0.0 - 1.0)
121    pub fn set_progress(&mut self, progress: f64) {
122        self.progress = progress.clamp(0.0, 1.0);
123        self.last_update = Instant::now();
124    }
125
126    /// Set progress text
127    pub fn set_text(&mut self, text: String) {
128        self.text = Some(text);
129        self.last_update = Instant::now();
130    }
131
132    /// Clear progress text
133    pub fn clear_text(&mut self) {
134        self.text = None;
135        self.last_update = Instant::now();
136    }
137
138    /// Get elapsed time since start
139    pub fn elapsed(&self) -> Duration {
140        self.start_time.elapsed()
141    }
142
143    /// Update animation frame
144    pub fn update_animation(&mut self) {
145        self.animation_frame = self.animation_frame.wrapping_add(1);
146        self.last_update = Instant::now();
147    }
148}
149
150/// Progress Bar Component for interactive progress display
151pub struct ProgressBar {
152    config: ProgressBarConfig,
153    state: ProgressState,
154}
155
156impl ProgressBar {
157    /// Create new progress bar with default configuration
158    pub fn new() -> Self {
159        Self {
160            config: ProgressBarConfig::default(),
161            state: ProgressState::new(),
162        }
163    }
164
165    /// Create progress bar with custom configuration
166    pub fn with_config(config: ProgressBarConfig) -> Self {
167        Self {
168            config,
169            state: ProgressState::new(),
170        }
171    }
172
173    /// Create horizontal progress bar with custom styling
174    pub fn horizontal(fill_color: Color, background_color: Color) -> Self {
175        Self {
176            config: ProgressBarConfig {
177                orientation: ProgressBarOrientation::Horizontal,
178                fill_color,
179                background_color,
180                ..Default::default()
181            },
182            state: ProgressState::new(),
183        }
184    }
185
186    /// Create vertical progress bar with custom styling
187    pub fn vertical(fill_color: Color, background_color: Color) -> Self {
188        Self {
189            config: ProgressBarConfig {
190                orientation: ProgressBarOrientation::Vertical,
191                fill_color,
192                background_color,
193                ..Default::default()
194            },
195            state: ProgressState::new(),
196        }
197    }
198
199    /// Render progress bar within specified bounds
200    pub fn render(&mut self, bounds: &Bounds) -> Vec<String> {
201        // Update animation if enabled
202        if self.config.animated {
203            let frame_duration = Duration::from_secs_f64(1.0 / self.config.animation_speed);
204            if self.state.last_update.elapsed() > frame_duration {
205                self.state.update_animation();
206            }
207        }
208
209        match self.config.orientation {
210            ProgressBarOrientation::Horizontal => self.render_horizontal(bounds),
211            ProgressBarOrientation::Vertical => self.render_vertical(bounds),
212        }
213    }
214
215    /// Render horizontal progress bar
216    fn render_horizontal(&self, bounds: &Bounds) -> Vec<String> {
217        let mut lines = Vec::new();
218        let width = bounds.width().max(self.config.min_width);
219        let height = bounds.height();
220
221        // Calculate progress bar dimensions
222        let border_width = if self.config.border_chars.is_some() {
223            2
224        } else {
225            0
226        };
227        let bar_width = width.saturating_sub(border_width);
228        let filled_width = ((bar_width as f64) * self.state.progress).round() as usize;
229        let remaining_width = bar_width.saturating_sub(filled_width);
230
231        // Generate progress bar lines
232        for row in 0..height {
233            let line = if row == 0 && self.config.show_progress_text {
234                self.generate_progress_text_line(width)
235            } else if row == height.saturating_sub(1) && self.config.show_percentage {
236                self.generate_percentage_line(width)
237            } else {
238                self.generate_bar_line(filled_width, remaining_width, &border_width)
239            };
240            lines.push(line);
241        }
242
243        // Ensure we have at least one line for the progress bar
244        if lines.is_empty() {
245            lines.push(self.generate_bar_line(filled_width, remaining_width, &border_width));
246        }
247
248        lines
249    }
250
251    /// Render vertical progress bar
252    fn render_vertical(&self, bounds: &Bounds) -> Vec<String> {
253        let mut lines = Vec::new();
254        let width = bounds.width();
255        let height = bounds.height().max(self.config.min_height);
256
257        // Calculate progress bar dimensions
258        let border_height = if self.config.border_chars.is_some() {
259            2
260        } else {
261            0
262        };
263        let bar_height = height.saturating_sub(border_height);
264        let filled_height = ((bar_height as f64) * self.state.progress).round() as usize;
265
266        // Generate vertical progress bar lines
267        for row in 0..height {
268            let line = if row == 0 && self.config.show_progress_text {
269                self.generate_progress_text_line(width)
270            } else if row == height.saturating_sub(1) && self.config.show_percentage {
271                self.generate_percentage_line(width)
272            } else {
273                self.generate_vertical_bar_line(
274                    row,
275                    filled_height,
276                    bar_height,
277                    width,
278                    &border_height,
279                )
280            };
281            lines.push(line);
282        }
283
284        lines
285    }
286
287    /// Generate progress text line
288    fn generate_progress_text_line(&self, width: usize) -> String {
289        let text = if let Some(ref progress_text) = self.state.text {
290            let percentage = (self.state.progress * 100.0).round() as u32;
291            self.config
292                .progress_text_template
293                .replace("{text}", progress_text)
294                .replace("{percentage}", &percentage.to_string())
295        } else if self.config.show_percentage {
296            format!("{}%", (self.state.progress * 100.0).round() as u32)
297        } else {
298            "Progress".to_string()
299        };
300
301        // Center the text within the width
302        self.center_text(&text, width)
303    }
304
305    /// Generate percentage line
306    fn generate_percentage_line(&self, width: usize) -> String {
307        let percentage = (self.state.progress * 100.0).round() as u32;
308        let percentage_text = format!("{}%", percentage);
309        self.center_text(&percentage_text, width)
310    }
311
312    /// Generate horizontal bar line
313    fn generate_bar_line(
314        &self,
315        filled_width: usize,
316        remaining_width: usize,
317        _border_width: &usize,
318    ) -> String {
319        let mut line = String::new();
320
321        // Add left border
322        if let Some(ref borders) = self.config.border_chars {
323            line.push(borders.left);
324        }
325
326        // Add filled portion with animation
327        let fill_char = if self.config.animated {
328            self.get_animated_fill_char()
329        } else {
330            self.config.fill_char
331        };
332
333        for _ in 0..filled_width {
334            line.push(fill_char);
335        }
336
337        // Add remaining portion
338        for _ in 0..remaining_width {
339            line.push(self.config.background_char);
340        }
341
342        // Add right border
343        if let Some(ref borders) = self.config.border_chars {
344            line.push(borders.right);
345        }
346
347        line
348    }
349
350    /// Generate vertical bar line
351    fn generate_vertical_bar_line(
352        &self,
353        row: usize,
354        filled_height: usize,
355        bar_height: usize,
356        width: usize,
357        border_height: &usize,
358    ) -> String {
359        let mut line = String::new();
360
361        // Determine if this row should be filled (fill from bottom)
362        let effective_row = if *border_height > 0 {
363            row.saturating_sub(1)
364        } else {
365            row
366        };
367        let is_filled = effective_row >= bar_height.saturating_sub(filled_height);
368
369        // Generate vertical bar character
370        let bar_char = if is_filled {
371            if self.config.animated {
372                self.get_animated_fill_char()
373            } else {
374                self.config.fill_char
375            }
376        } else {
377            self.config.background_char
378        };
379
380        // Add borders and fill
381        if let Some(ref borders) = self.config.border_chars {
382            if row == 0 || row == bar_height + border_height - 1 {
383                // Top or bottom border
384                line = borders.top.to_string().repeat(width);
385            } else {
386                // Side borders with fill
387                line.push(borders.left);
388                for _ in 1..width.saturating_sub(1) {
389                    line.push(bar_char);
390                }
391                line.push(borders.right);
392            }
393        } else {
394            // No borders, just fill
395            line = bar_char.to_string().repeat(width);
396        }
397
398        line
399    }
400
401    /// Get animated fill character
402    fn get_animated_fill_char(&self) -> char {
403        const ANIMATION_CHARS: &[char] = &['█', '▉', '▊', '▋', '▌', '▍', '▎', '▏'];
404        let index = self.state.animation_frame % ANIMATION_CHARS.len();
405        ANIMATION_CHARS[index]
406    }
407
408    /// Center text within specified width
409    fn center_text(&self, text: &str, width: usize) -> String {
410        if text.len() >= width {
411            text.chars().take(width).collect()
412        } else {
413            let padding = (width - text.len()) / 2;
414            let right_padding = width - text.len() - padding;
415            format!(
416                "{}{}{}",
417                " ".repeat(padding),
418                text,
419                " ".repeat(right_padding)
420            )
421        }
422    }
423
424    /// Update progress value (0.0 - 1.0)
425    pub fn set_progress(&mut self, progress: f64) {
426        self.state.set_progress(progress);
427    }
428
429    /// Get current progress value
430    pub fn get_progress(&self) -> f64 {
431        self.state.progress
432    }
433
434    /// Set progress text
435    pub fn set_text(&mut self, text: String) {
436        self.state.set_text(text);
437    }
438
439    /// Clear progress text
440    pub fn clear_text(&mut self) {
441        self.state.clear_text();
442    }
443
444    /// Get elapsed time since creation
445    pub fn elapsed(&self) -> Duration {
446        self.state.elapsed()
447    }
448
449    /// Check if progress bar is complete
450    pub fn is_complete(&self) -> bool {
451        self.state.progress >= 1.0
452    }
453
454    /// Reset progress bar to initial state
455    pub fn reset(&mut self) {
456        self.state = ProgressState::new();
457    }
458
459    /// Get current configuration
460    pub fn get_config(&self) -> &ProgressBarConfig {
461        &self.config
462    }
463
464    /// Update configuration
465    pub fn update_config(&mut self, config: ProgressBarConfig) {
466        self.config = config;
467    }
468
469    /// Get current state
470    pub fn get_state(&self) -> &ProgressState {
471        &self.state
472    }
473}
474
475impl Default for ProgressBar {
476    fn default() -> Self {
477        Self::new()
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    fn create_test_bounds() -> Bounds {
486        Bounds::new(0, 0, 20, 3)
487    }
488
489    #[test]
490    fn test_progress_bar_creation() {
491        let progress_bar = ProgressBar::new();
492        assert_eq!(
493            progress_bar.config.orientation,
494            ProgressBarOrientation::Horizontal
495        );
496        assert_eq!(progress_bar.config.fill_char, '█');
497        assert_eq!(progress_bar.state.progress, 0.0);
498    }
499
500    #[test]
501    fn test_progress_bar_with_config() {
502        let config = ProgressBarConfig {
503            fill_color: Color::Blue,
504            background_color: Color::Red,
505            show_percentage: false,
506            ..Default::default()
507        };
508
509        let progress_bar = ProgressBar::with_config(config.clone());
510        assert_eq!(progress_bar.config.fill_color, Color::Blue);
511        assert_eq!(progress_bar.config.background_color, Color::Red);
512        assert!(!progress_bar.config.show_percentage);
513    }
514
515    #[test]
516    fn test_horizontal_progress_bar() {
517        let progress_bar = ProgressBar::horizontal(Color::Green, Color::DarkGrey);
518        assert_eq!(
519            progress_bar.config.orientation,
520            ProgressBarOrientation::Horizontal
521        );
522        assert_eq!(progress_bar.config.fill_color, Color::Green);
523        assert_eq!(progress_bar.config.background_color, Color::DarkGrey);
524    }
525
526    #[test]
527    fn test_vertical_progress_bar() {
528        let progress_bar = ProgressBar::vertical(Color::Yellow, Color::Black);
529        assert_eq!(
530            progress_bar.config.orientation,
531            ProgressBarOrientation::Vertical
532        );
533        assert_eq!(progress_bar.config.fill_color, Color::Yellow);
534        assert_eq!(progress_bar.config.background_color, Color::Black);
535    }
536
537    #[test]
538    fn test_progress_updates() {
539        let mut progress_bar = ProgressBar::new();
540
541        assert_eq!(progress_bar.get_progress(), 0.0);
542
543        progress_bar.set_progress(0.5);
544        assert_eq!(progress_bar.get_progress(), 0.5);
545
546        progress_bar.set_progress(1.0);
547        assert_eq!(progress_bar.get_progress(), 1.0);
548        assert!(progress_bar.is_complete());
549    }
550
551    #[test]
552    fn test_progress_clamping() {
553        let mut progress_bar = ProgressBar::new();
554
555        // Test values beyond valid range
556        progress_bar.set_progress(-0.5);
557        assert_eq!(progress_bar.get_progress(), 0.0);
558
559        progress_bar.set_progress(1.5);
560        assert_eq!(progress_bar.get_progress(), 1.0);
561    }
562
563    #[test]
564    fn test_progress_text() {
565        let mut progress_bar = ProgressBar::new();
566
567        progress_bar.set_text("Loading files...".to_string());
568        assert_eq!(
569            progress_bar.state.text,
570            Some("Loading files...".to_string())
571        );
572
573        progress_bar.clear_text();
574        assert_eq!(progress_bar.state.text, None);
575    }
576
577    #[test]
578    fn test_horizontal_rendering() {
579        let mut progress_bar = ProgressBar::new();
580        progress_bar.set_progress(0.5);
581
582        let bounds = create_test_bounds();
583        let lines = progress_bar.render(&bounds);
584
585        assert!(!lines.is_empty());
586
587        // Check that rendered lines fit within bounds
588        for line in &lines {
589            assert!(line.chars().count() <= bounds.width());
590        }
591        assert!(lines.len() <= bounds.height());
592    }
593
594    #[test]
595    fn test_vertical_rendering() {
596        let mut progress_bar = ProgressBar::vertical(Color::Blue, Color::DarkGrey);
597        progress_bar.set_progress(0.3);
598
599        let bounds = create_test_bounds();
600        let lines = progress_bar.render(&bounds);
601
602        assert!(!lines.is_empty());
603
604        // Check that rendered lines fit within bounds
605        for line in &lines {
606            assert!(line.chars().count() <= bounds.width());
607        }
608        assert!(lines.len() <= bounds.height());
609    }
610
611    #[test]
612    fn test_progress_bar_reset() {
613        let mut progress_bar = ProgressBar::new();
614        progress_bar.set_progress(0.7);
615        progress_bar.set_text("Almost done...".to_string());
616
617        assert_eq!(progress_bar.get_progress(), 0.7);
618        assert!(progress_bar.state.text.is_some());
619
620        progress_bar.reset();
621
622        assert_eq!(progress_bar.get_progress(), 0.0);
623        assert!(progress_bar.state.text.is_none());
624        assert!(!progress_bar.is_complete());
625    }
626
627    #[test]
628    fn test_text_centering() {
629        let progress_bar = ProgressBar::new();
630
631        let centered = progress_bar.center_text("Test", 10);
632        assert_eq!(centered.len(), 10);
633        assert!(centered.contains("Test"));
634
635        let long_text = progress_bar.center_text("This is a very long text", 10);
636        assert_eq!(long_text.len(), 10);
637        assert_eq!(&long_text, "This is a ");
638    }
639
640    #[test]
641    fn test_animated_fill_char() {
642        let progress_bar = ProgressBar::new();
643
644        // Test that animation characters are valid
645        let anim_char = progress_bar.get_animated_fill_char();
646        assert!(anim_char != '\0');
647    }
648
649    #[test]
650    fn test_config_update() {
651        let mut progress_bar = ProgressBar::new();
652        let original_color = progress_bar.config.fill_color;
653
654        let mut new_config = progress_bar.config.clone();
655        new_config.fill_color = Color::Red;
656
657        progress_bar.update_config(new_config);
658
659        assert_ne!(progress_bar.config.fill_color, original_color);
660        assert_eq!(progress_bar.config.fill_color, Color::Red);
661    }
662
663    #[test]
664    fn test_progress_state_methods() {
665        let mut state = ProgressState::new();
666
667        assert_eq!(state.progress, 0.0);
668        assert!(state.text.is_none());
669
670        state.set_progress(0.8);
671        assert_eq!(state.progress, 0.8);
672
673        state.set_text("Processing...".to_string());
674        assert_eq!(state.text, Some("Processing...".to_string()));
675
676        state.clear_text();
677        assert!(state.text.is_none());
678
679        // Test elapsed time
680        let elapsed = state.elapsed();
681        assert!(elapsed.as_millis() >= 0);
682    }
683}