1use crate::model::common::Bounds;
8use crossterm::style::Color;
9use std::time::{Duration, Instant};
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct ProgressBarConfig {
14 pub orientation: ProgressBarOrientation,
16 pub fill_char: char,
18 pub background_char: char,
20 pub fill_color: Color,
22 pub background_color: Color,
24 pub text_color: Color,
26 pub show_percentage: bool,
28 pub show_progress_text: bool,
30 pub progress_text_template: String,
32 pub animated: bool,
34 pub animation_speed: f64,
36 pub border_chars: Option<ProgressBarBorders>,
38 pub min_width: usize,
40 pub min_height: usize,
42}
43
44#[derive(Debug, Clone, PartialEq)]
46pub enum ProgressBarOrientation {
47 Horizontal,
48 Vertical,
49}
50
51#[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#[derive(Debug, Clone, PartialEq)]
88pub struct ProgressState {
89 pub progress: f64,
91 pub text: Option<String>,
93 pub start_time: Instant,
95 pub last_update: Instant,
97 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 pub fn new() -> Self {
117 Self::default()
118 }
119
120 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 pub fn set_text(&mut self, text: String) {
128 self.text = Some(text);
129 self.last_update = Instant::now();
130 }
131
132 pub fn clear_text(&mut self) {
134 self.text = None;
135 self.last_update = Instant::now();
136 }
137
138 pub fn elapsed(&self) -> Duration {
140 self.start_time.elapsed()
141 }
142
143 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
150pub struct ProgressBar {
152 config: ProgressBarConfig,
153 state: ProgressState,
154}
155
156impl ProgressBar {
157 pub fn new() -> Self {
159 Self {
160 config: ProgressBarConfig::default(),
161 state: ProgressState::new(),
162 }
163 }
164
165 pub fn with_config(config: ProgressBarConfig) -> Self {
167 Self {
168 config,
169 state: ProgressState::new(),
170 }
171 }
172
173 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 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 pub fn render(&mut self, bounds: &Bounds) -> Vec<String> {
201 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 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 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 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 if lines.is_empty() {
245 lines.push(self.generate_bar_line(filled_width, remaining_width, &border_width));
246 }
247
248 lines
249 }
250
251 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 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 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 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 self.center_text(&text, width)
303 }
304
305 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 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 if let Some(ref borders) = self.config.border_chars {
323 line.push(borders.left);
324 }
325
326 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 for _ in 0..remaining_width {
339 line.push(self.config.background_char);
340 }
341
342 if let Some(ref borders) = self.config.border_chars {
344 line.push(borders.right);
345 }
346
347 line
348 }
349
350 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 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 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 if let Some(ref borders) = self.config.border_chars {
382 if row == 0 || row == bar_height + border_height - 1 {
383 line = borders.top.to_string().repeat(width);
385 } else {
386 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 line = bar_char.to_string().repeat(width);
396 }
397
398 line
399 }
400
401 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 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 pub fn set_progress(&mut self, progress: f64) {
426 self.state.set_progress(progress);
427 }
428
429 pub fn get_progress(&self) -> f64 {
431 self.state.progress
432 }
433
434 pub fn set_text(&mut self, text: String) {
436 self.state.set_text(text);
437 }
438
439 pub fn clear_text(&mut self) {
441 self.state.clear_text();
442 }
443
444 pub fn elapsed(&self) -> Duration {
446 self.state.elapsed()
447 }
448
449 pub fn is_complete(&self) -> bool {
451 self.state.progress >= 1.0
452 }
453
454 pub fn reset(&mut self) {
456 self.state = ProgressState::new();
457 }
458
459 pub fn get_config(&self) -> &ProgressBarConfig {
461 &self.config
462 }
463
464 pub fn update_config(&mut self, config: ProgressBarConfig) {
466 self.config = config;
467 }
468
469 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 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 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 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 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 let elapsed = state.elapsed();
681 assert!(elapsed.as_millis() >= 0);
682 }
683}