1use std::collections::HashMap;
40use std::time::{Duration, Instant};
41
42use crate::console::{ConsoleOptions, DynRenderable, Renderable};
43use crate::progress_columns::{
44 BarColumn, ProgressColumn, SpinnerColumn, TaskProgressColumn, TextColumn,
45 TimeElapsedColumn,
46};
47use crate::style::Style;
48use crate::table::{Cell, Table};
49
50#[derive(Debug, Clone)]
56pub struct ProgressBar {
57 pub total: Option<f64>,
59 pub completed: f64,
61 pub width: Option<usize>,
63 pub complete_char: char,
65 pub remaining_char: char,
67 pub pulse: bool,
69 pub complete_style: Style,
71 pub remaining_style: Style,
73 pub pulse_style: Style,
75}
76
77impl ProgressBar {
78 pub fn new() -> Self {
80 Self {
81 total: Some(100.0),
82 completed: 0.0,
83 width: None,
84 complete_char: '█',
85 remaining_char: '░',
86 pulse: false,
87 complete_style: Style::new(),
88 remaining_style: Style::new(),
89 pulse_style: Style::new(),
90 }
91 }
92
93 pub fn total(mut self, total: f64) -> Self { self.total = Some(total); self }
95
96 pub fn completed(mut self, completed: f64) -> Self { self.completed = completed; self }
98
99 pub fn width(mut self, width: usize) -> Self { self.width = Some(width); self }
101
102 pub fn complete_style(mut self, style: Style) -> Self { self.complete_style = style; self }
104
105 pub fn remaining_style(mut self, style: Style) -> Self { self.remaining_style = style; self }
107
108 pub fn percentage(&self) -> f64 {
110 if let Some(total) = self.total {
111 if total > 0.0 {
112 (self.completed / total).min(1.0).max(0.0)
113 } else {
114 0.0
115 }
116 } else {
117 0.0
118 }
119 }
120
121 pub fn render(&self, width: usize) -> String {
123 let w = self.width.unwrap_or(width).saturating_sub(2); if w < 3 {
125 return "[]".to_string();
126 }
127
128 if self.pulse || self.total.is_none() {
129 let pos = ((self.completed as usize / 8) % (w - 1)).min(w);
131 let left = " ".repeat(pos);
132 let right = " ".repeat(w.saturating_sub(pos + 1));
133 format!("[{left}⣿{right}]")
134 } else {
135 let pct = self.percentage();
136 let filled = (w as f64 * pct) as usize;
137 let empty = w - filled;
138 let complete_ansi = self.complete_style.to_ansi();
139 let complete_reset = if complete_ansi.is_empty() { "" } else { "\x1b[0m" };
140 format!(
141 "[{complete_ansi}{}{complete_reset}{}]",
142 self.complete_char.to_string().repeat(filled),
143 self.remaining_char.to_string().repeat(empty)
144 )
145 }
146 }
147}
148
149impl Default for ProgressBar {
150 fn default() -> Self {
151 Self::new()
152 }
153}
154
155#[derive(Debug, Clone)]
161pub struct Task {
162 pub id: usize,
163 pub description: String,
164 pub total: Option<f64>,
165 pub completed: f64,
166 pub visible: bool,
167 pub start_time: Instant,
168 pub stop_time: Option<Instant>,
169 pub fields: HashMap<String, String>,
170 pub renderable: Option<DynRenderable>,
172}
173
174impl Task {
175 pub fn new(id: usize, description: impl Into<String>, total: Option<f64>) -> Self {
177 Self {
178 id,
179 description: description.into(),
180 total,
181 completed: 0.0,
182 visible: true,
183 start_time: Instant::now(),
184 stop_time: None,
185 fields: HashMap::new(),
186 renderable: None,
187 }
188 }
189
190 pub fn progress(&self) -> f64 {
192 if let Some(t) = self.total {
193 if t > 0.0 {
194 (self.completed / t).min(1.0).max(0.0)
195 } else {
196 0.0
197 }
198 } else {
199 0.0
200 }
201 }
202
203 pub fn elapsed(&self) -> Duration {
205 self.start_time.elapsed()
206 }
207
208 pub fn time_remaining(&self) -> Option<Duration> {
211 let pct = self.progress();
212 if pct > 0.0 {
213 let elapsed = self.elapsed();
214 let total = elapsed.div_f64(pct);
215 Some(total.saturating_sub(elapsed))
216 } else {
217 None
218 }
219 }
220
221 pub fn is_finished(&self) -> bool {
223 if let Some(t) = self.total {
224 self.completed >= t
225 } else {
226 false
227 }
228 }
229}
230
231pub struct RenderableColumn {
237 pub format: Box<dyn Fn(&Task) -> DynRenderable + Send + Sync>,
238}
239
240impl RenderableColumn {
241 pub fn new<F: Fn(&Task) -> DynRenderable + Send + Sync + 'static>(format: F) -> Self {
243 Self { format: Box::new(format) }
244 }
245}
246
247impl std::fmt::Debug for RenderableColumn {
248 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249 f.debug_struct("RenderableColumn").finish()
250 }
251}
252
253impl ProgressColumn for RenderableColumn {
254 fn render(&self, task: &Task, _width: usize, _elapsed: Duration) -> String {
255 let renderable = (self.format)(task);
256 renderable.render(&ConsoleOptions::default()).to_ansi()
257 }
258}
259
260#[derive(Debug)]
266pub struct Progress {
267 pub tasks: Vec<Task>,
268 pub auto_refresh: bool,
269 pub refresh_per_second: f64,
270 pub transient: bool,
271 pub columns: Option<Vec<Box<dyn crate::progress_columns::ProgressColumn>>>,
273 next_id: usize,
274}
275
276impl Progress {
277 pub fn new() -> Self {
279 Self {
280 tasks: Vec::new(),
281 auto_refresh: true,
282 refresh_per_second: 10.0,
283 transient: false,
284 columns: None,
285 next_id: 1,
286 }
287 }
288
289 pub fn with_columns(mut self, columns: Vec<Box<dyn crate::progress_columns::ProgressColumn>>) -> Self {
293 self.columns = Some(columns);
294 self
295 }
296
297 pub fn add_task(
299 &mut self,
300 description: impl Into<String>,
301 total: Option<f64>,
302 ) -> usize {
303 let id = self.next_id;
304 self.next_id += 1;
305 self.tasks.push(Task::new(id, description, total));
306 id
307 }
308
309 pub fn advance(&mut self, task_id: usize, delta: f64) {
311 if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
312 task.completed += delta;
313 if let Some(total) = task.total {
314 if task.completed > total {
315 task.completed = total;
316 }
317 }
318 }
319 }
320
321 pub fn update(&mut self, task_id: usize, completed: f64) {
323 if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
324 task.completed = completed;
325 }
326 }
327
328 pub fn remove_task(&mut self, task_id: usize) {
330 self.tasks.retain(|t| t.id != task_id);
331 }
332
333 pub fn refresh(&mut self) {
336 }
339
340 pub fn start_task(&mut self, task_id: usize) {
342 if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
343 task.start_time = Instant::now();
344 }
345 }
346
347 pub fn stop_task(&mut self, task_id: usize) {
349 if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
350 task.stop_time = Some(Instant::now());
351 }
352 }
353
354 pub fn reset(&mut self, task_id: usize, total: Option<f64>) {
358 if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
359 task.completed = 0.0;
360 if let Some(t) = total {
361 task.total = Some(t);
362 }
363 }
364 }
365
366 pub fn finished(&self) -> bool {
368 self.tasks.iter().all(|t| t.is_finished())
369 }
370
371 pub fn get_default_columns(&self) -> Vec<Box<dyn ProgressColumn>> {
375 vec![
376 Box::new(TextColumn::new("description")),
377 Box::new(SpinnerColumn::new()),
378 Box::new(BarColumn::new()),
379 Box::new(TaskProgressColumn::new()),
380 Box::new(TimeElapsedColumn::new()),
381 ]
382 }
383
384 pub fn get_renderable(&self, task_id: usize) -> Option<&dyn Renderable> {
386 self.tasks
387 .iter()
388 .find(|t| t.id == task_id)
389 .and_then(|t| t.renderable.as_ref())
390 .map(|dr| dr as &dyn Renderable)
391 }
392
393 pub fn get_renderables(&self) -> Vec<&dyn Renderable> {
395 self.tasks
396 .iter()
397 .filter_map(|t| t.renderable.as_ref())
398 .map(|dr| dr as &dyn Renderable)
399 .collect()
400 }
401
402 pub fn make_tasks_table(&self, columns: &[Box<dyn ProgressColumn>]) -> Table {
407 let now = Instant::now();
408 let mut table = Table::new();
409 table.show_header = false;
410 table.show_edge = false;
411 table.padding = (0, 1, 0, 0);
412
413 for (i, _col) in columns.iter().enumerate() {
415 table.add_column(crate::table::Column::new(format!("Col {}", i)));
416 }
417
418 for task in &self.tasks {
419 if !task.visible {
420 continue;
421 }
422 let elapsed = now.duration_since(task.start_time);
423 let cells: Vec<Cell> = columns
424 .iter()
425 .map(|col| Cell::new(col.render(task, 20, elapsed)))
426 .collect();
427 table.add_row(cells);
428 }
429
430 table
431 }
432
433 pub fn render(&self, width: usize) -> String {
435 if let Some(ref columns) = self.columns {
436 self.render_with_columns(width, columns)
437 } else {
438 self.render_default(width)
439 }
440 }
441
442 fn render_with_columns(&self, _width: usize, columns: &[Box<dyn crate::progress_columns::ProgressColumn>]) -> String {
444 let mut out = String::new();
445 let now = std::time::Instant::now();
446 for task in &self.tasks {
447 if !task.visible {
448 continue;
449 }
450 let elapsed = now.duration_since(task.start_time);
451 let mut line = String::new();
452 for (i, col) in columns.iter().enumerate() {
453 if i > 0 { line.push(' '); }
454 line.push_str(&col.render(task, 20, elapsed));
455 }
456 out.push_str(&line);
457 out.push('\n');
458 }
459 out
460 }
461
462 fn render_default(&self, width: usize) -> String {
464 let mut out = String::new();
465 for task in &self.tasks {
466 if !task.visible {
467 continue;
468 }
469 let bar_width = width.saturating_sub(30).max(10);
470 let bar = self.render_task_bar(task, bar_width);
471 let pct = (task.progress() * 100.0) as usize;
472 let elapsed = format_duration(&task.elapsed());
473 let remaining = task
474 .time_remaining()
475 .map(|d| format_duration(&d))
476 .unwrap_or_else(|| "?".to_string());
477
478 out.push_str(&format!(
479 "{desc:<20} {pct:>3}% {bar} {elapsed}<{remaining}\n",
480 desc = task.description.chars().take(20).collect::<String>(),
481 ));
482 }
483 out
484 }
485
486 fn render_task_bar(&self, task: &Task, width: usize) -> String {
487 let w = width.saturating_sub(2);
488 if w < 3 {
489 return "[]".to_string();
490 }
491 let pct = task.progress();
492 let filled = (w as f64 * pct) as usize;
493 let empty = w - filled;
494 format!("[{}░{}]",
495 "█".repeat(filled),
496 " ".repeat(empty.saturating_sub(1))
497 )
498 }
499
500 pub fn track<I: IntoIterator>(
504 &mut self,
505 sequence: I,
506 description: &str,
507 total: Option<f64>,
508 ) -> TrackIterator<I::IntoIter> {
509 let iter = sequence.into_iter();
510 let (lower, upper) = iter.size_hint();
511 let total = total.unwrap_or(upper.unwrap_or(lower) as f64);
512 let task_id = self.add_task(description, Some(total));
513
514 TrackIterator {
515 inner: iter,
516 progress_id: task_id,
517 count: 0,
518 total,
519 }
520 }
521
522 pub fn advance_bytes(&mut self, task_id: usize, bytes: u64) {
524 self.advance(task_id, bytes as f64);
525 }
526
527 pub fn open(
531 &mut self,
532 path: impl AsRef<std::path::Path>,
533 description: impl Into<String>,
534 ) -> std::io::Result<ProgressFile> {
535 let path = path.as_ref();
536 let metadata = std::fs::metadata(path)?;
537 let total = metadata.len();
538 let file = std::fs::File::open(path)?;
539 let desc = description.into();
540 Ok(self.wrap_file(file, &desc, Some(total)))
541 }
542
543 pub fn wrap_file(
545 &mut self,
546 file: std::fs::File,
547 description: &str,
548 total: Option<u64>,
549 ) -> ProgressFile {
550 let total_val = total.unwrap_or(0) as f64;
551 let task_id = self.add_task(description, Some(total_val));
552 ProgressFile::new(file, task_id, total.unwrap_or(0))
553 }
554}
555
556impl Default for Progress {
557 fn default() -> Self {
558 Self::new()
559 }
560}
561
562pub fn track<T: IntoIterator>(sequence: T, _description: &str, total: Option<f64>) -> TrackIterator<T::IntoIter> {
568 let iter = sequence.into_iter();
569 let (lower, upper) = iter.size_hint();
570 let total_val = total.unwrap_or(upper.unwrap_or(lower) as f64);
571 TrackIterator {
572 inner: iter,
573 progress_id: 0,
574 count: 0,
575 total: total_val,
576 }
577}
578
579pub fn wrap_file(file: std::fs::File, _description: &str, total: Option<u64>) -> ProgressFile {
585 ProgressFile::new(file, 0, total.unwrap_or(0))
586}
587
588pub struct TrackIterator<I: Iterator> {
595 inner: I,
596 pub progress_id: usize,
598 count: usize,
599 total: f64,
600}
601
602impl<I: Iterator> Iterator for TrackIterator<I> {
603 type Item = I::Item;
604
605 fn next(&mut self) -> Option<Self::Item> {
606 let item = self.inner.next();
607 if item.is_some() {
608 self.count += 1;
609 }
610 item
611 }
612
613 fn size_hint(&self) -> (usize, Option<usize>) {
614 self.inner.size_hint()
615 }
616}
617
618impl<I: Iterator> TrackIterator<I> {
619 pub fn count(&self) -> usize { self.count }
621
622 pub fn total(&self) -> f64 { self.total }
624}
625
626#[derive(Debug)]
632pub struct ProgressFile {
633 inner: std::fs::File,
634 task_id: usize,
635 total: u64,
636 bytes_read: u64,
637}
638
639impl ProgressFile {
640 pub fn new(file: std::fs::File, task_id: usize, total: u64) -> Self {
642 Self { inner: file, task_id, total, bytes_read: 0 }
643 }
644
645 pub fn bytes_read(&self) -> u64 { self.bytes_read }
647
648 pub fn total(&self) -> u64 { self.total }
650
651 pub fn task_id(&self) -> usize { self.task_id }
653
654 pub fn sync(&self, progress: &mut Progress) {
656 if let Some(task) = progress.tasks.iter_mut().find(|t| t.id == self.task_id) {
657 task.completed = self.bytes_read as f64;
658 }
659 }
660
661 pub fn inner(&self) -> &std::fs::File { &self.inner }
663
664 pub fn inner_mut(&mut self) -> &mut std::fs::File { &mut self.inner }
666
667 pub fn into_inner(self) -> std::fs::File { self.inner }
669}
670
671impl std::io::Read for ProgressFile {
672 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
673 let n = self.inner.read(buf)?;
674 self.bytes_read += n as u64;
675 Ok(n)
676 }
677}
678
679fn format_duration(d: &Duration) -> String {
682 let secs = d.as_secs();
683 if secs < 60 {
684 format!("0:{secs:02}")
685 } else if secs < 3600 {
686 format!("{}:{:02}", secs / 60, secs % 60)
687 } else {
688 format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn test_progress_bar_render() {
698 let bar = ProgressBar::new().total(100.0).completed(50.0);
699 let r = bar.render(20);
700 assert!(r.contains('█'));
701 }
702
703 #[test]
704 fn test_progress_add_task() {
705 let mut p = Progress::new();
706 let id = p.add_task("Download", Some(100.0));
707 assert_eq!(id, 1);
708 p.advance(1, 50.0);
709 assert_eq!(p.tasks[0].completed, 50.0);
710 }
711
712 #[test]
713 fn test_advance_bytes() {
714 let mut p = Progress::new();
715 let id = p.add_task("Download", Some(1000.0));
716 p.advance_bytes(id, 256);
717 assert_eq!(p.tasks[0].completed, 256.0);
718 }
719
720 #[test]
721 fn test_progress_file_wrap_and_read() {
722 use std::io::Read;
723 let data = b"hello world";
724 let dir = std::env::temp_dir();
725 let path = dir.join("rusty_rich_test_progress.txt");
726
727 std::fs::write(&path, data).unwrap();
729
730 let mut p = Progress::new();
731 let mut pf = p.open(&path, "test file").unwrap();
732 assert_eq!(pf.total(), 11);
733 assert_eq!(pf.bytes_read(), 0);
734
735 let mut buf = [0u8; 5];
737 let n = pf.read(&mut buf).unwrap();
738 assert_eq!(n, 5);
739 assert_eq!(pf.bytes_read(), 5);
740
741 pf.sync(&mut p);
743 assert_eq!(p.tasks[0].completed, 5.0);
744
745 let mut buf = Vec::new();
747 pf.read_to_end(&mut buf).unwrap();
748 assert_eq!(pf.bytes_read(), 11);
749
750 pf.sync(&mut p);
752 assert_eq!(p.tasks[0].completed, 11.0);
753
754 drop(pf);
755 std::fs::remove_file(&path).unwrap();
756 }
757
758 #[test]
759 fn test_progress_file_wrap_existing() {
760 let data = b"test data for wrap";
761 let dir = std::env::temp_dir();
762 let path = dir.join("rusty_rich_test_wrap.txt");
763 std::fs::write(&path, data).unwrap();
764
765 let file = std::fs::File::open(&path).unwrap();
766 let mut p = Progress::new();
767 let pf = p.wrap_file(file, "wrapped", Some(data.len() as u64));
768 assert_eq!(pf.total(), data.len() as u64);
769 assert_eq!(pf.task_id(), 1);
770
771 drop(pf);
772 std::fs::remove_file(&path).unwrap();
773 }
774
775 #[test]
778 fn test_start_task() {
779 let mut p = Progress::new();
780 let id = p.add_task("test", Some(100.0));
781 p.start_task(id);
783 assert!(!p.tasks[0].elapsed().is_zero());
784 }
785
786 #[test]
787 fn test_stop_task() {
788 let mut p = Progress::new();
789 let id = p.add_task("test", Some(100.0));
790 p.stop_task(id);
791 assert!(p.tasks[0].stop_time.is_some());
792 }
793
794 #[test]
795 fn test_reset_task() {
796 let mut p = Progress::new();
797 let id = p.add_task("test", Some(100.0));
798 p.advance(id, 50.0);
799 assert_eq!(p.tasks[0].completed, 50.0);
800 p.reset(id, Some(200.0));
801 assert_eq!(p.tasks[0].completed, 0.0);
802 assert_eq!(p.tasks[0].total, Some(200.0));
803 }
804
805 #[test]
806 fn test_finished() {
807 let mut p = Progress::new();
808 p.add_task("a", Some(100.0));
809 p.add_task("b", Some(100.0));
810 assert!(!p.finished());
811 p.update(1, 100.0);
812 p.update(2, 100.0);
813 assert!(p.finished());
814 }
815
816 #[test]
817 fn test_get_default_columns() {
818 let p = Progress::new();
819 let cols = p.get_default_columns();
820 assert_eq!(cols.len(), 5);
821 }
822
823 #[test]
824 fn test_refresh() {
825 let mut p = Progress::new();
826 p.add_task("test", Some(100.0));
827 p.refresh();
829 }
830
831 #[test]
832 fn test_track_method() {
833 let mut p = Progress::new();
834 let items = vec![1, 2, 3];
835 let tracker = p.track(items, "counting", Some(3.0));
836 assert_eq!(tracker.progress_id, 1);
837 assert_eq!(p.tasks.len(), 1);
838 }
839
840 #[test]
841 fn test_standalone_track() {
842 let items = vec![1, 2, 3];
843 let tracker = track(items, "counting", Some(3.0));
844 assert_eq!(tracker.progress_id, 0);
845 }
846
847 #[test]
848 fn test_standalone_wrap_file() {
849 let data = b"hello";
850 let dir = std::env::temp_dir();
851 let path = dir.join("rusty_rich_test_standalone_wrap.txt");
852 std::fs::write(&path, data).unwrap();
853 let file = std::fs::File::open(&path).unwrap();
854 let pf = wrap_file(file, "standalone", Some(data.len() as u64));
855 assert_eq!(pf.total(), 5);
856 std::fs::remove_file(&path).unwrap();
857 }
858
859 #[test]
860 fn test_renderable_column() {
861 let col = RenderableColumn::new(|task: &Task| {
862 DynRenderable::new(task.description.clone())
863 });
864 let task = Task::new(1, "hello", Some(100.0));
865 let result = col.render(&task, 20, Duration::from_secs(0));
866 assert!(result.contains("hello"));
867 }
868
869 #[test]
870 fn test_make_tasks_table() {
871 let mut p = Progress::new();
872 p.add_task("task1", Some(100.0));
873 p.add_task("task2", Some(50.0));
874 let cols = p.get_default_columns();
875 let table = p.make_tasks_table(&cols);
876 assert_eq!(table.row_count(), 2);
877 }
878
879 #[test]
880 fn test_get_renderable() {
881 let mut p = Progress::new();
882 let id = p.add_task("test", Some(100.0));
883 assert!(p.get_renderable(id).is_none());
885 }
886
887 #[test]
888 fn test_get_renderables() {
889 let mut p = Progress::new();
890 p.add_task("a", Some(100.0));
891 p.add_task("b", Some(50.0));
892 let renderables = p.get_renderables();
893 assert!(renderables.is_empty());
894 }
895
896 #[test]
897 fn test_auto_refresh_default() {
898 let p = Progress::new();
899 assert!(p.auto_refresh);
900 }
901
902 #[test]
903 fn test_refresh_per_second_default() {
904 let p = Progress::new();
905 assert!((p.refresh_per_second - 10.0).abs() < f64::EPSILON);
906 }
907}