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, TimeElapsedColumn,
45};
46use crate::style::Style;
47use crate::table::{Cell, Table};
48
49#[derive(Debug, Clone)]
55pub struct ProgressBar {
56 pub total: Option<f64>,
58 pub completed: f64,
60 pub width: Option<usize>,
62 pub complete_char: char,
64 pub remaining_char: char,
66 pub pulse: bool,
68 pub complete_style: Style,
70 pub remaining_style: Style,
72 pub pulse_style: Style,
74}
75
76impl ProgressBar {
77 pub fn new() -> Self {
79 Self {
80 total: Some(100.0),
81 completed: 0.0,
82 width: None,
83 complete_char: '█',
84 remaining_char: '░',
85 pulse: false,
86 complete_style: Style::new(),
87 remaining_style: Style::new(),
88 pulse_style: Style::new(),
89 }
90 }
91
92 pub fn total(mut self, total: f64) -> Self {
94 self.total = Some(total);
95 self
96 }
97
98 pub fn completed(mut self, completed: f64) -> Self {
100 self.completed = completed;
101 self
102 }
103
104 pub fn width(mut self, width: usize) -> Self {
106 self.width = Some(width);
107 self
108 }
109
110 pub fn complete_style(mut self, style: Style) -> Self {
112 self.complete_style = style;
113 self
114 }
115
116 pub fn remaining_style(mut self, style: Style) -> Self {
118 self.remaining_style = style;
119 self
120 }
121
122 pub fn percentage(&self) -> f64 {
124 if let Some(total) = self.total {
125 if total > 0.0 {
126 (self.completed / total).clamp(0.0, 1.0)
127 } else {
128 0.0
129 }
130 } else {
131 0.0
132 }
133 }
134
135 pub fn render(&self, width: usize) -> String {
137 let w = self.width.unwrap_or(width).saturating_sub(2); if w < 3 {
139 return "[]".to_string();
140 }
141
142 if self.pulse || self.total.is_none() {
143 let pos = ((self.completed as usize / 8) % w.max(1)).min(w.saturating_sub(1));
145 let left = " ".repeat(pos);
146 let right = " ".repeat(w.saturating_sub(pos + 1));
147 format!("[{left}⣿{right}]")
148 } else {
149 let pct = self.percentage();
150 let filled = (w as f64 * pct) as usize;
151 let empty = w - filled;
152 let complete_ansi = self.complete_style.to_ansi();
153 let complete_reset = if complete_ansi.is_empty() {
154 ""
155 } else {
156 "\x1b[0m"
157 };
158 format!(
159 "[{complete_ansi}{}{complete_reset}{}]",
160 self.complete_char.to_string().repeat(filled),
161 self.remaining_char.to_string().repeat(empty)
162 )
163 }
164 }
165}
166
167impl Default for ProgressBar {
168 fn default() -> Self {
169 Self::new()
170 }
171}
172
173#[derive(Debug, Clone)]
179pub struct Task {
180 pub id: usize,
181 pub description: String,
182 pub total: Option<f64>,
183 pub completed: f64,
184 pub visible: bool,
185 pub start_time: Instant,
186 pub stop_time: Option<Instant>,
187 pub fields: HashMap<String, String>,
188 pub renderable: Option<DynRenderable>,
190}
191
192impl Task {
193 pub fn new(id: usize, description: impl Into<String>, total: Option<f64>) -> Self {
195 Self {
196 id,
197 description: description.into(),
198 total,
199 completed: 0.0,
200 visible: true,
201 start_time: Instant::now(),
202 stop_time: None,
203 fields: HashMap::new(),
204 renderable: None,
205 }
206 }
207
208 pub fn progress(&self) -> f64 {
210 if let Some(t) = self.total {
211 if t > 0.0 {
212 (self.completed / t).clamp(0.0, 1.0)
213 } else {
214 0.0
215 }
216 } else {
217 0.0
218 }
219 }
220
221 pub fn elapsed(&self) -> Duration {
223 self.start_time.elapsed()
224 }
225
226 pub fn time_remaining(&self) -> Option<Duration> {
229 let pct = self.progress();
230 if pct > 0.0 {
231 let elapsed = self.elapsed();
232 let total = elapsed.div_f64(pct);
233 Some(total.saturating_sub(elapsed))
234 } else {
235 None
236 }
237 }
238
239 pub fn is_finished(&self) -> bool {
241 if let Some(t) = self.total {
242 self.completed >= t
243 } else {
244 false
245 }
246 }
247}
248
249pub struct RenderableColumn {
255 pub format: Box<dyn Fn(&Task) -> DynRenderable + Send + Sync>,
256}
257
258impl RenderableColumn {
259 pub fn new<F: Fn(&Task) -> DynRenderable + Send + Sync + 'static>(format: F) -> Self {
261 Self {
262 format: Box::new(format),
263 }
264 }
265}
266
267impl std::fmt::Debug for RenderableColumn {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 f.debug_struct("RenderableColumn").finish()
270 }
271}
272
273impl ProgressColumn for RenderableColumn {
274 fn render(&self, task: &Task, _width: usize, _elapsed: Duration) -> String {
275 let renderable = (self.format)(task);
276 renderable.render(&ConsoleOptions::default()).to_ansi()
277 }
278}
279
280#[derive(Debug)]
289pub struct Progress {
290 pub tasks: HashMap<usize, Task>,
291 pub task_order: Vec<usize>,
293 pub auto_refresh: bool,
294 pub refresh_per_second: f64,
295 pub transient: bool,
296 pub columns: Option<Vec<Box<dyn crate::progress_columns::ProgressColumn>>>,
298 next_id: usize,
299}
300
301impl Progress {
302 pub fn new() -> Self {
304 Self {
305 tasks: HashMap::new(),
306 task_order: Vec::new(),
307 auto_refresh: true,
308 refresh_per_second: 10.0,
309 transient: false,
310 columns: None,
311 next_id: 1,
312 }
313 }
314
315 pub fn with_columns(
319 mut self,
320 columns: Vec<Box<dyn crate::progress_columns::ProgressColumn>>,
321 ) -> Self {
322 self.columns = Some(columns);
323 self
324 }
325
326 pub fn add_task(&mut self, description: impl Into<String>, total: Option<f64>) -> usize {
328 let id = self.next_id;
329 self.next_id += 1;
330 self.tasks.insert(id, Task::new(id, description, total));
331 self.task_order.push(id);
332 id
333 }
334
335 pub fn advance(&mut self, task_id: usize, delta: f64) {
337 if let Some(task) = self.tasks.get_mut(&task_id) {
338 task.completed += delta;
339 if let Some(total) = task.total {
340 if task.completed > total {
341 task.completed = total;
342 }
343 }
344 }
345 }
346
347 pub fn update(&mut self, task_id: usize, completed: f64) {
349 if let Some(task) = self.tasks.get_mut(&task_id) {
350 task.completed = if let Some(total) = task.total {
352 completed.clamp(0.0, total)
353 } else {
354 completed.max(0.0)
355 };
356 }
357 }
358
359 pub fn remove_task(&mut self, task_id: usize) {
361 self.tasks.remove(&task_id);
362 self.task_order.retain(|id| *id != task_id);
363 }
364
365 pub fn refresh(&mut self) {
368 }
371
372 pub fn start_task(&mut self, task_id: usize) {
374 if let Some(task) = self.tasks.get_mut(&task_id) {
375 task.start_time = Instant::now();
376 }
377 }
378
379 pub fn stop_task(&mut self, task_id: usize) {
381 if let Some(task) = self.tasks.get_mut(&task_id) {
382 task.stop_time = Some(Instant::now());
383 }
384 }
385
386 pub fn reset(&mut self, task_id: usize, total: Option<f64>) {
390 if let Some(task) = self.tasks.get_mut(&task_id) {
391 task.completed = 0.0;
392 if let Some(t) = total {
393 task.total = Some(t);
394 }
395 }
396 }
397
398 pub fn finished(&self) -> bool {
400 self.tasks.values().all(|t| t.is_finished())
401 }
402
403 pub fn get_default_columns(&self) -> Vec<Box<dyn ProgressColumn>> {
407 vec![
408 Box::new(TextColumn::new("description")),
409 Box::new(SpinnerColumn::new()),
410 Box::new(BarColumn::new()),
411 Box::new(TaskProgressColumn::new()),
412 Box::new(TimeElapsedColumn::new()),
413 ]
414 }
415
416 pub fn get_renderable(&self, task_id: usize) -> Option<&dyn Renderable> {
418 self.tasks
419 .get(&task_id)
420 .and_then(|t| t.renderable.as_ref())
421 .map(|dr| dr as &dyn Renderable)
422 }
423
424 pub fn get_renderables(&self) -> Vec<&dyn Renderable> {
426 self.tasks
427 .values()
428 .filter_map(|t| t.renderable.as_ref())
429 .map(|dr| dr as &dyn Renderable)
430 .collect()
431 }
432
433 pub fn make_tasks_table(&self, columns: &[Box<dyn ProgressColumn>]) -> Table {
438 let now = Instant::now();
439 let mut table = Table::new();
440 table.show_header = false;
441 table.show_edge = false;
442 table.padding = (0, 1, 0, 0);
443
444 for (i, _col) in columns.iter().enumerate() {
446 table.add_column(crate::table::Column::new(format!("Col {}", i)));
447 }
448
449 for id in &self.task_order {
450 if let Some(task) = self.tasks.get(id) {
451 if !task.visible {
452 continue;
453 }
454 let elapsed = now.duration_since(task.start_time);
455 let cells: Vec<Cell> = columns
456 .iter()
457 .map(|col| Cell::new(col.render(task, 20, elapsed)))
458 .collect();
459 table.add_row(cells);
460 }
461 }
462
463 table
464 }
465
466 pub fn render(&self, width: usize) -> String {
468 if let Some(ref columns) = self.columns {
469 self.render_with_columns(width, columns)
470 } else {
471 self.render_default(width)
472 }
473 }
474
475 fn render_with_columns(
477 &self,
478 _width: usize,
479 columns: &[Box<dyn crate::progress_columns::ProgressColumn>],
480 ) -> String {
481 let mut out = String::new();
482 let now = std::time::Instant::now();
483 for id in &self.task_order {
484 if let Some(task) = self.tasks.get(id) {
485 if !task.visible {
486 continue;
487 }
488 let elapsed = now.duration_since(task.start_time);
489 let mut line = String::new();
490 for (i, col) in columns.iter().enumerate() {
491 if i > 0 {
492 line.push(' ');
493 }
494 line.push_str(&col.render(task, 20, elapsed));
495 }
496 out.push_str(&line);
497 out.push('\n');
498 }
499 }
500 out
501 }
502
503 fn render_default(&self, width: usize) -> String {
505 let mut out = String::new();
506 for id in &self.task_order {
507 if let Some(task) = self.tasks.get(id) {
508 if !task.visible {
509 continue;
510 }
511 let bar_width = width.saturating_sub(30).max(10);
512 let bar = self.render_task_bar(task, bar_width);
513 let pct = (task.progress() * 100.0) as usize;
514 let elapsed = format_duration(&task.elapsed());
515 let remaining = task
516 .time_remaining()
517 .map(|d| format_duration(&d))
518 .unwrap_or_else(|| "?".to_string());
519
520 out.push_str(&format!(
521 "{desc:<20} {pct:>3}% {bar} {elapsed}<{remaining}\n",
522 desc = task.description.chars().take(20).collect::<String>(),
523 ));
524 }
525 }
526 out
527 }
528
529 fn render_task_bar(&self, task: &Task, width: usize) -> String {
530 let w = width.saturating_sub(2);
531 if w < 3 {
532 return "[]".to_string();
533 }
534 let pct = task.progress();
535 let filled = (w as f64 * pct) as usize;
536 let empty = w - filled;
537 format!(
538 "[{}░{}]",
539 "█".repeat(filled),
540 " ".repeat(empty.saturating_sub(1))
541 )
542 }
543
544 pub fn track<I: IntoIterator>(
548 &mut self,
549 sequence: I,
550 description: &str,
551 total: Option<f64>,
552 ) -> TrackIterator<I::IntoIter> {
553 let iter = sequence.into_iter();
554 let (lower, upper) = iter.size_hint();
555 let total = total.unwrap_or(upper.unwrap_or(lower) as f64);
556 let task_id = self.add_task(description, Some(total));
557
558 TrackIterator {
559 inner: iter,
560 progress_id: task_id,
561 count: 0,
562 total,
563 }
564 }
565
566 pub fn advance_bytes(&mut self, task_id: usize, bytes: u64) {
568 self.advance(task_id, bytes as f64);
569 }
570
571 pub fn open(
575 &mut self,
576 path: impl AsRef<std::path::Path>,
577 description: impl Into<String>,
578 ) -> std::io::Result<ProgressFile> {
579 let path = path.as_ref();
580 let metadata = std::fs::metadata(path)?;
581 let total = metadata.len();
582 let file = std::fs::File::open(path)?;
583 let desc = description.into();
584 Ok(self.wrap_file(file, &desc, Some(total)))
585 }
586
587 pub fn wrap_file(
589 &mut self,
590 file: std::fs::File,
591 description: &str,
592 total: Option<u64>,
593 ) -> ProgressFile {
594 let total_val = total.unwrap_or(0) as f64;
595 let task_id = self.add_task(description, Some(total_val));
596 ProgressFile::new(file, task_id, total.unwrap_or(0))
597 }
598}
599
600impl Default for Progress {
601 fn default() -> Self {
602 Self::new()
603 }
604}
605
606pub fn track<T: IntoIterator>(
646 sequence: T,
647 _description: &str,
648 total: Option<f64>,
649) -> TrackIterator<T::IntoIter> {
650 let iter = sequence.into_iter();
651 let (lower, upper) = iter.size_hint();
652 let total_val = total.unwrap_or(upper.unwrap_or(lower) as f64);
653 TrackIterator {
654 inner: iter,
655 progress_id: 0,
656 count: 0,
657 total: total_val,
658 }
659}
660
661pub fn wrap_file(file: std::fs::File, _description: &str, total: Option<u64>) -> ProgressFile {
669 ProgressFile::new(file, 0, total.unwrap_or(0))
670}
671
672pub struct TrackIterator<I: Iterator> {
679 inner: I,
680 pub progress_id: usize,
682 count: usize,
683 total: f64,
684}
685
686impl<I: Iterator> Iterator for TrackIterator<I> {
687 type Item = I::Item;
688
689 fn next(&mut self) -> Option<Self::Item> {
690 let item = self.inner.next();
691 if item.is_some() {
692 self.count += 1;
693 }
694 item
695 }
696
697 fn size_hint(&self) -> (usize, Option<usize>) {
698 self.inner.size_hint()
699 }
700}
701
702impl<I: Iterator> TrackIterator<I> {
703 pub fn count(&self) -> usize {
705 self.count
706 }
707
708 pub fn total(&self) -> f64 {
710 self.total
711 }
712}
713
714#[derive(Debug)]
720pub struct ProgressFile {
721 inner: std::fs::File,
722 task_id: usize,
723 total: u64,
724 bytes_read: u64,
725}
726
727impl ProgressFile {
728 pub fn new(file: std::fs::File, task_id: usize, total: u64) -> Self {
730 Self {
731 inner: file,
732 task_id,
733 total,
734 bytes_read: 0,
735 }
736 }
737
738 pub fn bytes_read(&self) -> u64 {
740 self.bytes_read
741 }
742
743 pub fn total(&self) -> u64 {
745 self.total
746 }
747
748 pub fn task_id(&self) -> usize {
750 self.task_id
751 }
752
753 pub fn sync(&self, progress: &mut Progress) {
755 if let Some(task) = progress.tasks.get_mut(&self.task_id) {
756 task.completed = self.bytes_read as f64;
757 }
758 }
759
760 pub fn inner(&self) -> &std::fs::File {
762 &self.inner
763 }
764
765 pub fn inner_mut(&mut self) -> &mut std::fs::File {
767 &mut self.inner
768 }
769
770 pub fn into_inner(self) -> std::fs::File {
772 self.inner
773 }
774}
775
776impl std::io::Read for ProgressFile {
777 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
778 let n = self.inner.read(buf)?;
779 self.bytes_read += n as u64;
780 Ok(n)
781 }
782}
783
784fn format_duration(d: &Duration) -> String {
787 let secs = d.as_secs();
788 if secs < 60 {
789 format!("0:{secs:02}")
790 } else if secs < 3600 {
791 format!("{}:{:02}", secs / 60, secs % 60)
792 } else {
793 format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
794 }
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800
801 #[test]
802 fn test_progress_bar_render() {
803 let bar = ProgressBar::new().total(100.0).completed(50.0);
804 let r = bar.render(20);
805 assert!(r.contains('█'));
806 }
807
808 #[test]
809 fn test_progress_add_task() {
810 let mut p = Progress::new();
811 let id = p.add_task("Download", Some(100.0));
812 assert_eq!(id, 1);
813 p.advance(1, 50.0);
814 assert_eq!(p.tasks.get(&1).unwrap().completed, 50.0);
815 }
816
817 #[test]
818 fn test_advance_bytes() {
819 let mut p = Progress::new();
820 let id = p.add_task("Download", Some(1000.0));
821 p.advance_bytes(id, 256);
822 assert_eq!(p.tasks.get(&id).unwrap().completed, 256.0);
823 }
824
825 #[test]
826 fn test_progress_file_wrap_and_read() {
827 use std::io::Read;
828 let data = b"hello world";
829 let dir = std::env::temp_dir();
830 let path = dir.join("rusty_rich_test_progress.txt");
831
832 std::fs::write(&path, data).unwrap();
834
835 let mut p = Progress::new();
836 let mut pf = p.open(&path, "test file").unwrap();
837 assert_eq!(pf.total(), 11);
838 assert_eq!(pf.bytes_read(), 0);
839
840 let mut buf = [0u8; 5];
842 let n = pf.read(&mut buf).unwrap();
843 assert_eq!(n, 5);
844 assert_eq!(pf.bytes_read(), 5);
845
846 pf.sync(&mut p);
848 assert_eq!(p.tasks.get(&pf.task_id()).unwrap().completed, 5.0);
849
850 let mut buf = Vec::new();
852 pf.read_to_end(&mut buf).unwrap();
853 assert_eq!(pf.bytes_read(), 11);
854
855 pf.sync(&mut p);
857 assert_eq!(p.tasks.get(&pf.task_id()).unwrap().completed, 11.0);
858
859 drop(pf);
860 std::fs::remove_file(&path).unwrap();
861 }
862
863 #[test]
864 fn test_progress_file_wrap_existing() {
865 let data = b"test data for wrap";
866 let dir = std::env::temp_dir();
867 let path = dir.join("rusty_rich_test_wrap.txt");
868 std::fs::write(&path, data).unwrap();
869
870 let file = std::fs::File::open(&path).unwrap();
871 let mut p = Progress::new();
872 let pf = p.wrap_file(file, "wrapped", Some(data.len() as u64));
873 assert_eq!(pf.total(), data.len() as u64);
874 assert_eq!(pf.task_id(), 1);
875
876 drop(pf);
877 std::fs::remove_file(&path).unwrap();
878 }
879
880 #[test]
883 fn test_start_task() {
884 let mut p = Progress::new();
885 let id = p.add_task("test", Some(100.0));
886 p.start_task(id);
888 assert!(!p.tasks.get(&id).unwrap().elapsed().is_zero());
889 }
890
891 #[test]
892 fn test_stop_task() {
893 let mut p = Progress::new();
894 let id = p.add_task("test", Some(100.0));
895 p.stop_task(id);
896 assert!(p.tasks.get(&id).unwrap().stop_time.is_some());
897 }
898
899 #[test]
900 fn test_reset_task() {
901 let mut p = Progress::new();
902 let id = p.add_task("test", Some(100.0));
903 p.advance(id, 50.0);
904 assert_eq!(p.tasks.get(&id).unwrap().completed, 50.0);
905 p.reset(id, Some(200.0));
906 assert_eq!(p.tasks.get(&id).unwrap().completed, 0.0);
907 assert_eq!(p.tasks.get(&id).unwrap().total, Some(200.0));
908 }
909
910 #[test]
911 fn test_finished() {
912 let mut p = Progress::new();
913 p.add_task("a", Some(100.0));
914 p.add_task("b", Some(100.0));
915 assert!(!p.finished());
916 p.update(1, 100.0);
917 p.update(2, 100.0);
918 assert!(p.finished());
919 }
920
921 #[test]
922 fn test_get_default_columns() {
923 let p = Progress::new();
924 let cols = p.get_default_columns();
925 assert_eq!(cols.len(), 5);
926 }
927
928 #[test]
929 fn test_refresh() {
930 let mut p = Progress::new();
931 p.add_task("test", Some(100.0));
932 p.refresh();
934 }
935
936 #[test]
937 fn test_track_method() {
938 let mut p = Progress::new();
939 let items = vec![1, 2, 3];
940 let tracker = p.track(items, "counting", Some(3.0));
941 assert_eq!(tracker.progress_id, 1);
942 assert_eq!(p.tasks.len(), 1);
943 }
944
945 #[test]
946 fn test_standalone_track() {
947 let items = vec![1, 2, 3];
948 let tracker = track(items, "counting", Some(3.0));
949 assert_eq!(tracker.progress_id, 0);
950 }
951
952 #[test]
953 fn test_standalone_wrap_file() {
954 let data = b"hello";
955 let dir = std::env::temp_dir();
956 let path = dir.join("rusty_rich_test_standalone_wrap.txt");
957 std::fs::write(&path, data).unwrap();
958 let file = std::fs::File::open(&path).unwrap();
959 let pf = wrap_file(file, "standalone", Some(data.len() as u64));
960 assert_eq!(pf.total(), 5);
961 std::fs::remove_file(&path).unwrap();
962 }
963
964 #[test]
965 fn test_renderable_column() {
966 let col = RenderableColumn::new(|task: &Task| DynRenderable::new(task.description.clone()));
967 let task = Task::new(1, "hello", Some(100.0));
968 let result = col.render(&task, 20, Duration::from_secs(0));
969 assert!(result.contains("hello"));
970 }
971
972 #[test]
973 fn test_make_tasks_table() {
974 let mut p = Progress::new();
975 p.add_task("task1", Some(100.0));
976 p.add_task("task2", Some(50.0));
977 let cols = p.get_default_columns();
978 let table = p.make_tasks_table(&cols);
979 assert_eq!(table.row_count(), 2);
980 }
981
982 #[test]
983 fn test_get_renderable() {
984 let mut p = Progress::new();
985 let id = p.add_task("test", Some(100.0));
986 assert!(p.get_renderable(id).is_none());
988 }
989
990 #[test]
991 fn test_get_renderables() {
992 let mut p = Progress::new();
993 p.add_task("a", Some(100.0));
994 p.add_task("b", Some(50.0));
995 let renderables = p.get_renderables();
996 assert!(renderables.is_empty());
997 }
998
999 #[test]
1000 fn test_auto_refresh_default() {
1001 let p = Progress::new();
1002 assert!(p.auto_refresh);
1003 }
1004
1005 #[test]
1006 fn test_refresh_per_second_default() {
1007 let p = Progress::new();
1008 assert!((p.refresh_per_second - 10.0).abs() < f64::EPSILON);
1009 }
1010}