1use std::cell::RefCell;
18use std::collections::{HashMap, VecDeque};
19use std::sync::Arc;
20
21use crate::cells::cell_len;
22use crate::console::{Console, ConsoleOptions};
23use crate::filesize;
24use crate::progress_bar::ProgressBar;
25use crate::protocol::Renderable;
26use crate::segment::Segment;
27use crate::spinner::Spinner;
28use crate::style::{Style, StyleType};
29use crate::text::Text;
30
31const BAR_MAX_WIDTH: usize = 40;
34
35const MAX_SAMPLES: usize = 1000;
37
38pub type GetTime = Arc<dyn Fn() -> f64 + Send + Sync>;
40
41fn monotonic() -> f64 {
43 static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
44 START
45 .get_or_init(std::time::Instant::now)
46 .elapsed()
47 .as_secs_f64()
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
52pub struct TaskId(pub usize);
53
54pub struct TimeRemainingColumn {
57 compact: bool,
58 elapsed_when_finished: bool,
59 cache: RefCell<HashMap<TaskId, (f64, Text)>>,
60}
61
62impl TimeRemainingColumn {
63 pub fn new(compact: bool, elapsed_when_finished: bool) -> Self {
66 TimeRemainingColumn {
67 compact,
68 elapsed_when_finished,
69 cache: RefCell::new(HashMap::new()),
70 }
71 }
72}
73
74pub struct SpinnerColumn {
77 spinner: Spinner,
78 style: StyleType,
79 finished_text: String,
80}
81
82impl SpinnerColumn {
83 pub fn new(name: &str, finished_text: impl Into<String>) -> Self {
86 SpinnerColumn {
87 spinner: Spinner::new(name),
88 style: StyleType::Name("progress.spinner".to_string()),
89 finished_text: finished_text.into(),
90 }
91 }
92
93 pub fn speed(mut self, speed: f64) -> Self {
95 self.spinner = self.spinner.speed(speed);
96 self
97 }
98
99 pub fn style(mut self, style: impl Into<StyleType>) -> Self {
101 self.style = style.into();
102 self
103 }
104}
105
106pub enum ProgressColumn {
108 Description,
111 Text(String, Style),
113 Bar,
115 Percentage,
117 TaskProgress { show_speed: bool },
120 MofN,
122 Download,
125 BinaryDownload,
127 TimeElapsed,
129 TimeRemaining(TimeRemainingColumn),
131 TransferSpeed,
133 FileSize,
135 TotalFileSize,
137 Spinner(SpinnerColumn),
139}
140
141impl ProgressColumn {
142 pub fn time_remaining() -> Self {
144 ProgressColumn::TimeRemaining(TimeRemainingColumn::new(false, false))
145 }
146
147 pub fn spinner() -> Self {
149 ProgressColumn::Spinner(SpinnerColumn::new("dots", " "))
150 }
151
152 fn is_bar(&self) -> bool {
153 matches!(self, ProgressColumn::Bar)
154 }
155
156 fn cell(&self, task: &Task) -> Text {
158 let named = |plain: String, style: &str| Text::styled(plain, style);
159 match self {
160 ProgressColumn::Description => {
161 let markup = format!("[progress.description]{}", task.description);
162 Text::from_markup(&markup).unwrap_or_else(|_| Text::new(task.description.clone()))
163 }
164 ProgressColumn::Text(text, style) => Text::styled(text.clone(), style.clone()),
165 ProgressColumn::Bar => unreachable!("bar column has no text cell"),
166 ProgressColumn::Percentage => task.percentage_cell(),
167 ProgressColumn::TaskProgress { show_speed } => {
168 if task.total.is_none() && *show_speed {
169 render_speed(
170 task.finished_speed
171 .filter(|s| *s != 0.0)
172 .or_else(|| task.speed()),
173 )
174 } else {
175 task.percentage_cell()
176 }
177 }
178 ProgressColumn::MofN => named(task.mofn_text(), "progress.download"),
179 ProgressColumn::Download => named(task.download_text(false), "progress.download"),
180 ProgressColumn::BinaryDownload => named(task.download_text(true), "progress.download"),
181 ProgressColumn::TimeElapsed => {
182 let elapsed = if task.finished() {
183 task.finished_time
184 } else {
185 task.elapsed()
186 };
187 let text = match elapsed {
188 None => "-:--:--".to_string(),
189 Some(elapsed) => timedelta(elapsed.max(0.0) as i64),
190 };
191 named(text, "progress.elapsed")
192 }
193 ProgressColumn::TimeRemaining(column) => column.render(task),
194 ProgressColumn::TransferSpeed => {
195 let speed = task
196 .finished_speed
197 .filter(|s| *s != 0.0)
198 .or_else(|| task.speed());
199 let text = match speed {
200 None => "?".to_string(),
201 Some(speed) => format!("{}/s", filesize::decimal(speed as u64)),
202 };
203 named(text, "progress.data.speed")
204 }
205 ProgressColumn::FileSize => named(
206 filesize::decimal(task.completed as u64),
207 "progress.filesize",
208 ),
209 ProgressColumn::TotalFileSize => named(
210 task.total
211 .map_or_else(String::new, |total| filesize::decimal(total as u64)),
212 "progress.filesize.total",
213 ),
214 ProgressColumn::Spinner(column) => {
215 if task.finished() {
216 Text::from_markup(&column.finished_text)
217 .unwrap_or_else(|_| Text::new(column.finished_text.clone()))
218 } else {
219 let mut frame = column.spinner.render(task.now());
222 frame.set_base_style(column.style.clone());
223 frame
224 }
225 }
226 }
227 }
228}
229
230impl TimeRemainingColumn {
231 fn render(&self, task: &Task) -> Text {
232 let now = task.now();
235 if task.completed == 0.0 {
236 if let Some((timestamp, text)) = self.cache.borrow().get(&task.id) {
237 if timestamp + 0.5 > now {
238 return text.clone();
239 }
240 }
241 }
242 let (task_time, style) = if self.elapsed_when_finished && task.finished() {
243 (task.finished_time, "progress.elapsed")
244 } else {
245 (task.time_remaining(), "progress.remaining")
246 };
247 let text = if task.total.is_none() {
248 Text::styled("", style)
249 } else {
250 match task_time {
251 None => Text::styled(if self.compact { "--:--" } else { "-:--:--" }, style),
252 Some(task_time) => {
253 let whole = task_time as i64;
254 let (minutes, seconds) = (whole.div_euclid(60), whole.rem_euclid(60));
255 let (hours, minutes) = (minutes.div_euclid(60), minutes.rem_euclid(60));
256 let formatted = if self.compact && hours == 0 {
257 format!("{minutes:02}:{seconds:02}")
258 } else {
259 format!("{hours}:{minutes:02}:{seconds:02}")
260 };
261 Text::styled(formatted, style)
262 }
263 }
264 };
265 self.cache.borrow_mut().insert(task.id, (now, text.clone()));
266 text
267 }
268}
269
270fn render_speed(speed: Option<f64>) -> Text {
273 let Some(speed) = speed else {
274 return Text::styled("", "progress.percentage");
275 };
276 let (unit, suffix) =
277 filesize::pick_unit_and_suffix(speed as u64, &["", "×10³", "×10⁶", "×10⁹", "×10¹²"], 1000);
278 let data_speed = speed / unit as f64;
279 Text::styled(
280 format!("{data_speed:.1}{suffix} it/s"),
281 "progress.percentage",
282 )
283}
284
285fn timedelta(total_seconds: i64) -> String {
288 let days = total_seconds / 86_400;
289 let rest = total_seconds % 86_400;
290 let clock = format!("{}:{:02}:{:02}", rest / 3600, rest % 3600 / 60, rest % 60);
291 match days {
292 0 => clock,
293 1 => format!("1 day, {clock}"),
294 days => format!("{days} days, {clock}"),
295 }
296}
297
298fn grouped(value: f64, precision: usize) -> String {
300 let formatted = format!("{value:.precision$}");
301 let (sign, digits) = match formatted.strip_prefix('-') {
302 Some(rest) => ("-", rest),
303 None => ("", formatted.as_str()),
304 };
305 let (integer, fraction) = match digits.split_once('.') {
306 Some((integer, fraction)) => (integer, Some(fraction)),
307 None => (digits, None),
308 };
309 let mut grouped = String::new();
310 for (index, digit) in integer.chars().enumerate() {
311 if index > 0 && (integer.len() - index) % 3 == 0 {
312 grouped.push(',');
313 }
314 grouped.push(digit);
315 }
316 match fraction {
317 Some(fraction) => format!("{sign}{grouped}.{fraction}"),
318 None => format!("{sign}{grouped}"),
319 }
320}
321
322pub struct Task {
325 id: TaskId,
326 description: String,
327 total: Option<f64>,
328 completed: f64,
329 visible: bool,
330 start_time: Option<f64>,
331 stop_time: Option<f64>,
332 finished_time: Option<f64>,
333 finished_speed: Option<f64>,
334 samples: VecDeque<(f64, f64)>,
336 get_time: GetTime,
337}
338
339impl Task {
340 fn now(&self) -> f64 {
341 (self.get_time)()
342 }
343
344 pub fn id(&self) -> TaskId {
346 self.id
347 }
348
349 pub fn description(&self) -> &str {
351 &self.description
352 }
353
354 pub fn total(&self) -> Option<f64> {
356 self.total
357 }
358
359 pub fn completed(&self) -> f64 {
361 self.completed
362 }
363
364 pub fn visible(&self) -> bool {
366 self.visible
367 }
368
369 pub fn started(&self) -> bool {
371 self.start_time.is_some()
372 }
373
374 pub fn remaining(&self) -> Option<f64> {
376 self.total.map(|total| total - self.completed)
377 }
378
379 pub fn elapsed(&self) -> Option<f64> {
381 let start = self.start_time?;
382 Some(self.stop_time.unwrap_or_else(|| self.now()) - start)
383 }
384
385 pub fn finished(&self) -> bool {
387 self.finished_time.is_some()
388 }
389
390 pub fn finished_time(&self) -> Option<f64> {
392 self.finished_time
393 }
394
395 pub fn percentage(&self) -> f64 {
397 match self.total {
398 Some(total) if total != 0.0 => (self.completed / total * 100.0).clamp(0.0, 100.0),
399 _ => 0.0,
400 }
401 }
402
403 pub fn speed(&self) -> Option<f64> {
405 self.start_time?;
406 let (first, _) = *self.samples.front()?;
407 let (last, _) = *self.samples.back()?;
408 let total_time = last - first;
409 if total_time == 0.0 {
410 return None;
411 }
412 let total_completed: f64 = self.samples.iter().skip(1).map(|(_, done)| done).sum();
413 Some(total_completed / total_time)
414 }
415
416 pub fn time_remaining(&self) -> Option<f64> {
418 if self.finished() {
419 return Some(0.0);
420 }
421 let speed = self.speed().filter(|speed| *speed != 0.0)?;
422 let remaining = self.remaining()?;
423 Some((remaining / speed).ceil())
424 }
425
426 fn clear_progress(&mut self) {
428 self.samples.clear();
429 self.finished_time = None;
430 self.finished_speed = None;
431 }
432
433 fn percentage_cell(&self) -> Text {
436 if self.total.is_none() {
437 return Text::new("");
438 }
439 let mut text = Text::new(format!("{:>3.0}%", self.percentage()));
440 let len = text.plain().len();
441 text.stylize("progress.percentage", 0, len);
442 text
443 }
444
445 fn mofn_text(&self) -> String {
448 let completed = self.completed as i64;
449 let total = self
450 .total
451 .map_or_else(|| "?".to_string(), |total| (total as i64).to_string());
452 let total_width = total.chars().count();
453 format!("{completed:>total_width$}/{total}")
454 }
455
456 fn download_text(&self, binary: bool) -> String {
459 const DECIMAL: &[&str] = &["bytes", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
460 const BINARY: &[&str] = &[
461 "bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB",
462 ];
463 let completed = self.completed as u64;
464 let base_size = self.total.map_or(completed, |total| total as u64);
465 let (unit, suffix) = if binary {
466 filesize::pick_unit_and_suffix(base_size, BINARY, 1024)
467 } else {
468 filesize::pick_unit_and_suffix(base_size, DECIMAL, 1000)
469 };
470 let precision = if unit == 1 { 0 } else { 1 };
471 let completed_str = grouped(completed as f64 / unit as f64, precision);
472 let total_str = self.total.map_or_else(
473 || "?".to_string(),
474 |total| grouped((total as u64) as f64 / unit as f64, precision),
475 );
476 format!("{completed_str}/{total_str} {suffix}")
477 }
478}
479
480#[derive(Debug, Clone, Default)]
483pub struct TaskUpdate {
484 pub total: Option<f64>,
485 pub completed: Option<f64>,
486 pub advance: Option<f64>,
487 pub description: Option<String>,
488 pub visible: Option<bool>,
489}
490
491impl TaskUpdate {
492 pub fn total(mut self, total: f64) -> Self {
493 self.total = Some(total);
494 self
495 }
496
497 pub fn completed(mut self, completed: f64) -> Self {
498 self.completed = Some(completed);
499 self
500 }
501
502 pub fn advance(mut self, advance: f64) -> Self {
503 self.advance = Some(advance);
504 self
505 }
506
507 pub fn description(mut self, description: impl Into<String>) -> Self {
508 self.description = Some(description.into());
509 self
510 }
511
512 pub fn visible(mut self, visible: bool) -> Self {
513 self.visible = Some(visible);
514 self
515 }
516}
517
518pub struct Progress {
520 tasks: Vec<Task>,
521 next_id: usize,
522 columns: Vec<ProgressColumn>,
523 get_time: GetTime,
524 speed_estimate_period: f64,
525}
526
527impl Default for Progress {
528 fn default() -> Self {
529 Progress {
530 tasks: Vec::new(),
531 next_id: 0,
532 columns: Progress::default_columns(),
533 get_time: Arc::new(monotonic),
534 speed_estimate_period: 30.0,
535 }
536 }
537}
538
539impl Progress {
540 pub fn new() -> Self {
541 Progress::default()
542 }
543
544 pub fn default_columns() -> Vec<ProgressColumn> {
547 vec![
548 ProgressColumn::Description,
549 ProgressColumn::Bar,
550 ProgressColumn::Percentage,
551 ProgressColumn::time_remaining(),
552 ]
553 }
554
555 pub fn columns(mut self, columns: Vec<ProgressColumn>) -> Self {
557 self.columns = columns;
558 self
559 }
560
561 pub fn clock(mut self, clock: impl Fn() -> f64 + Send + Sync + 'static) -> Self {
564 self.get_time = Arc::new(clock);
565 for task in &mut self.tasks {
566 task.get_time = self.get_time.clone();
567 }
568 self
569 }
570
571 pub fn speed_estimate_period(mut self, seconds: f64) -> Self {
573 self.speed_estimate_period = seconds;
574 self
575 }
576
577 fn now(&self) -> f64 {
578 (self.get_time)()
579 }
580
581 fn task_mut(&mut self, id: TaskId) -> Option<&mut Task> {
582 self.tasks.iter_mut().find(|task| task.id == id)
583 }
584
585 pub fn add_task(
588 &mut self,
589 description: impl Into<String>,
590 total: impl Into<Option<f64>>,
591 completed: f64,
592 ) -> TaskId {
593 let id = self.push_task(description.into(), total.into(), completed);
594 self.start_task(id);
595 id
596 }
597
598 pub fn add_unstarted_task(
601 &mut self,
602 description: impl Into<String>,
603 total: impl Into<Option<f64>>,
604 completed: f64,
605 ) -> TaskId {
606 self.push_task(description.into(), total.into(), completed)
607 }
608
609 fn push_task(&mut self, description: String, total: Option<f64>, completed: f64) -> TaskId {
610 let id = TaskId(self.next_id);
611 self.next_id += 1;
612 self.tasks.push(Task {
613 id,
614 description,
615 total,
616 completed,
617 visible: true,
618 start_time: None,
619 stop_time: None,
620 finished_time: None,
621 finished_speed: None,
622 samples: VecDeque::new(),
623 get_time: self.get_time.clone(),
624 });
625 id
626 }
627
628 pub fn task(&self, id: TaskId) -> Option<&Task> {
630 self.tasks.iter().find(|task| task.id == id)
631 }
632
633 pub fn tasks(&self) -> &[Task] {
635 &self.tasks
636 }
637
638 pub fn finished(&self) -> bool {
640 self.tasks.iter().all(Task::finished)
641 }
642
643 pub fn start_task(&mut self, id: TaskId) {
645 let now = self.now();
646 if let Some(task) = self.task_mut(id) {
647 task.start_time.get_or_insert(now);
648 }
649 }
650
651 pub fn stop_task(&mut self, id: TaskId) {
653 let now = self.now();
654 if let Some(task) = self.task_mut(id) {
655 task.start_time.get_or_insert(now);
656 task.stop_time = Some(now);
657 }
658 }
659
660 pub fn update(&mut self, id: TaskId, update: TaskUpdate) {
664 let now = self.now();
665 let period = self.speed_estimate_period;
666 let Some(task) = self.task_mut(id) else {
667 return;
668 };
669 let completed_start = task.completed;
670 if let Some(total) = update.total {
671 if Some(total) != task.total {
672 task.total = Some(total);
673 task.clear_progress();
674 }
675 }
676 if let Some(advance) = update.advance {
677 task.completed += advance;
678 }
679 if let Some(completed) = update.completed {
680 task.completed = completed;
681 }
682 if let Some(description) = update.description {
683 task.description = description;
684 }
685 if let Some(visible) = update.visible {
686 task.visible = visible;
687 }
688 let update_completed = task.completed - completed_start;
689 let old_sample_time = now - period;
690 while task
691 .samples
692 .front()
693 .is_some_and(|(time, _)| *time < old_sample_time)
694 {
695 task.samples.pop_front();
696 }
697 if update_completed > 0.0 {
698 task.samples.push_back((now, update_completed));
699 if task.samples.len() > MAX_SAMPLES {
700 task.samples.pop_front();
701 }
702 }
703 if task.total.is_some_and(|total| task.completed >= total) && task.finished_time.is_none() {
704 task.finished_time = task.elapsed();
705 }
706 }
707
708 pub fn advance(&mut self, id: TaskId, amount: f64) {
711 let now = self.now();
712 let period = self.speed_estimate_period;
713 let Some(task) = self.task_mut(id) else {
714 return;
715 };
716 let completed_start = task.completed;
717 task.completed += amount;
718 let update_completed = task.completed - completed_start;
719 let old_sample_time = now - period;
720 while task
721 .samples
722 .front()
723 .is_some_and(|(time, _)| *time < old_sample_time)
724 {
725 task.samples.pop_front();
726 }
727 while task.samples.len() > MAX_SAMPLES {
728 task.samples.pop_front();
729 }
730 task.samples.push_back((now, update_completed));
731 if task.samples.len() > MAX_SAMPLES {
732 task.samples.pop_front();
733 }
734 if task.total.is_some_and(|total| task.completed >= total) && task.finished_time.is_none() {
735 task.finished_time = task.elapsed();
736 task.finished_speed = task.speed();
737 }
738 }
739
740 pub fn reset(&mut self, id: TaskId, start: bool, total: Option<f64>, completed: f64) {
744 let now = self.now();
745 let Some(task) = self.task_mut(id) else {
746 return;
747 };
748 task.clear_progress();
749 task.start_time = start.then_some(now);
750 if let Some(total) = total {
751 task.total = Some(total);
752 }
753 task.completed = completed;
754 task.finished_time = None;
755 }
756
757 pub fn remove_task(&mut self, id: TaskId) {
759 self.tasks.retain(|task| task.id != id);
760 }
761}
762
763impl Renderable for Progress {
764 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
765 let width = options.max_width;
766 let ncols = self.columns.len();
767 let tasks: Vec<&Task> = self.tasks.iter().filter(|task| task.visible).collect();
768
769 let cells: Vec<Vec<Option<Text>>> = tasks
772 .iter()
773 .map(|task| {
774 self.columns
775 .iter()
776 .map(|column| (!column.is_bar()).then(|| column.cell(task)))
777 .collect()
778 })
779 .collect();
780
781 let mut col_widths = vec![0usize; ncols];
783 for row in &cells {
784 for (index, cell) in row.iter().enumerate() {
785 if let Some(cell) = cell {
786 col_widths[index] = col_widths[index].max(cell_len(cell.plain()));
787 }
788 }
789 }
790
791 let gaps = ncols.saturating_sub(1);
795 let fixed_sum: usize = col_widths.iter().sum();
796 let bar_count = self.columns.iter().filter(|c| c.is_bar()).count();
797 let bar_width = width
798 .saturating_sub(fixed_sum + gaps)
799 .checked_div(bar_count)
800 .map_or(0, |per_bar| BAR_MAX_WIDTH.min(per_bar));
801 for (index, column) in self.columns.iter().enumerate() {
802 if column.is_bar() {
803 col_widths[index] = bar_width;
804 }
805 }
806
807 let theme = console.theme();
808 let mut lines: Vec<Vec<Segment>> = Vec::with_capacity(tasks.len());
809 for (task, row_cells) in tasks.iter().zip(cells) {
810 let mut row: Vec<Segment> = Vec::new();
811 for (index, (column, cell)) in self.columns.iter().zip(row_cells).enumerate() {
812 if index > 0 {
813 row.push(Segment::new(" ", None));
816 }
817 if column.is_bar() {
818 let bar = ProgressBar::new(
819 task.total.unwrap_or(0.0).max(0.0),
820 task.completed.max(0.0),
821 )
822 .width(bar_width);
823 row.extend(bar.rich_render(console, &options.update_width(bar_width)));
824 } else if let Some(mut cell) = cell {
825 cell.truncate(col_widths[index], None, true);
828 row.extend(cell.render(theme, &Style::new()));
829 }
830 }
831 lines.push(row);
832 }
833
834 let mut segments = Vec::new();
835 let last = lines.len().saturating_sub(1);
836 for (index, line) in lines.into_iter().enumerate() {
837 segments.extend(line);
838 if index != last {
839 segments.push(Segment::line());
840 }
841 }
842 segments
843 }
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849 use crate::color::ColorSystem;
850
851 fn render(progress: &Progress) -> String {
852 Console::builder()
853 .force_terminal(true)
854 .color_system(Some(ColorSystem::Truecolor))
855 .width(50)
856 .no_color(false)
857 .build()
858 .render_to_string(progress)
859 }
860
861 #[test]
862 fn three_tasks_match_upstream() {
863 let mut progress = Progress::new().columns(vec![
865 ProgressColumn::Description,
866 ProgressColumn::Bar,
867 ProgressColumn::Percentage,
868 ]);
869 progress.add_task("Downloading", 100.0, 50.0);
870 progress.add_task("Processing", 100.0, 100.0);
871 progress.add_task("Waiting", 100.0, 0.0);
872 let expected = concat!(
873 "Downloading \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━\x1b[0m",
874 "\x1b[38;2;249;38;114m╸\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 50%\x1b[0m\n",
875 "Processing \x1b[38;2;114;156;31m",
876 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m100%\x1b[0m\n",
877 "Waiting \x1b[38;5;237m",
878 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 0%\x1b[0m",
879 );
880 assert_eq!(render(&progress), expected);
881 }
882
883 #[test]
884 fn download_text_matches_upstream() {
885 let dl = |completed: f64, total: f64| {
887 let mut progress = Progress::new();
888 let id = progress.add_task("", total, completed);
889 progress.task(id).unwrap().download_text(false)
890 };
891 assert_eq!(dl(500.0, 1000.0), "0.5/1.0 kB");
892 assert_eq!(dl(500.0, 999.0), "500/999 bytes");
893 assert_eq!(dl(1_500_000.0, 3_000_000.0), "1.5/3.0 MB");
894 assert_eq!(dl(0.0, 1024.0), "0.0/1.0 kB");
895 assert_eq!(dl(2_500_000_000.0, 10_000_000_000.0), "2.5/10.0 GB");
896 assert_eq!(dl(250.0, 250.0), "250/250 bytes");
897 }
898
899 #[test]
900 fn download_column_in_grid_matches_upstream() {
901 let mut progress = Progress::new().columns(vec![
903 ProgressColumn::Description,
904 ProgressColumn::Bar,
905 ProgressColumn::Download,
906 ]);
907 progress.add_task("File", 1000.0, 500.0);
908 let expected = concat!(
909 "File \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m",
910 "\x1b[38;5;237m━━━━━━━━━━━━━━━━\x1b[0m \x1b[32m0.5/1.0 kB\x1b[0m",
911 );
912 assert_eq!(render(&progress), expected);
913 }
914
915 #[test]
916 fn custom_columns_with_mofn_match_upstream() {
917 let mut progress = Progress::new().columns(vec![
920 ProgressColumn::Description,
921 ProgressColumn::Bar,
922 ProgressColumn::MofN,
923 ]);
924 progress.add_task("A", 5.0, 3.0);
925 progress.add_task("B", 100.0, 50.0);
926 let console = Console::builder()
927 .force_terminal(true)
928 .color_system(Some(ColorSystem::Truecolor))
929 .width(40)
930 .no_color(false)
931 .build();
932 let expected = concat!(
933 "A \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m",
934 "\x1b[38;5;237m━━━━━━━━━━━\x1b[0m \x1b[32m3/5 \x1b[0m\n",
935 "B \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m",
936 "\x1b[38;5;237m━━━━━━━━━━━━━━\x1b[0m \x1b[32m 50/100\x1b[0m",
937 );
938 assert_eq!(console.render_to_string(&progress), expected);
939 }
940}