// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. use std::fmt::Write; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use std::time::Duration; use deno_terminal::colors; use crate::util::display::human_download_size; use super::ProgressMessagePrompt; #[derive(Clone)] pub struct ProgressDataDisplayEntry { pub prompt: ProgressMessagePrompt, pub message: String, pub position: u64, pub total_size: u64, } #[derive(Clone)] pub struct ProgressData { pub terminal_width: u32, pub display_entries: Vec, pub pending_entries: usize, pub percent_done: f64, pub total_entries: usize, pub duration: Duration, } pub trait ProgressBarRenderer: Send + Sync + std::fmt::Debug { fn render(&self, data: ProgressData) -> String; } /// Indicatif style progress bar. #[derive(Debug)] pub struct BarProgressBarRenderer { pub display_human_download_size: bool, } impl ProgressBarRenderer for BarProgressBarRenderer { fn render(&self, data: ProgressData) -> String { // In `ProgressBarRenderer` we only care about first entry. let Some(display_entry) = &data.display_entries.first() else { return String::new(); }; let (bytes_text, bytes_text_max_width) = { let total_size = display_entry.total_size; let pos = display_entry.position; if total_size == 0 { (String::new(), 0) } else { let (pos_str, total_size_str) = if self.display_human_download_size { ( human_download_size(pos, total_size), human_download_size(total_size, total_size), ) } else { (pos.to_string(), total_size.to_string()) }; ( format!(" {}/{}", pos_str, total_size_str,), 2 + total_size_str.len() * 2, ) } }; let (total_text, total_text_max_width) = if data.total_entries <= 1 { (String::new(), 0) } else { let total_entries_str = data.total_entries.to_string(); ( format!( " ({}/{})", data.total_entries - data.pending_entries, data.total_entries ), 4 + total_entries_str.len() * 2, ) }; let elapsed_text = get_elapsed_text(data.duration); let mut text = String::new(); if !display_entry.message.is_empty() { writeln!( &mut text, "{} {}{}", colors::green("Download"), display_entry.message, bytes_text, ) .unwrap(); } text.push_str(&elapsed_text); let max_width = (data.terminal_width as i32 - 5).clamp(10, 75) as usize; let same_line_text_width = elapsed_text.len() + total_text_max_width + bytes_text_max_width + 3; // space, open and close brace let total_bars = if same_line_text_width > max_width { 1 } else { max_width - same_line_text_width }; let completed_bars = (total_bars as f64 * data.percent_done).floor() as usize; text.push_str(" ["); if completed_bars != total_bars { if completed_bars > 0 { text.push_str(&format!( "{}", colors::cyan(format!("{}{}", "#".repeat(completed_bars - 1), ">")) )) } text.push_str(&format!( "{}", colors::intense_blue("-".repeat(total_bars - completed_bars)) )) } else { text.push_str(&format!("{}", colors::cyan("#".repeat(completed_bars)))) } text.push(']'); // suffix if display_entry.message.is_empty() { text.push_str(&colors::gray(bytes_text).to_string()); } text.push_str(&colors::gray(total_text).to_string()); text } } #[derive(Debug)] pub struct TextOnlyProgressBarRenderer { last_tick: AtomicUsize, start_time: std::time::Instant, } impl Default for TextOnlyProgressBarRenderer { fn default() -> Self { Self { last_tick: Default::default(), start_time: std::time::Instant::now(), } } } const SPINNER_CHARS: [&str; 8] = ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"]; impl ProgressBarRenderer for TextOnlyProgressBarRenderer { fn render(&self, data: ProgressData) -> String { let last_tick = { let last_tick = self.last_tick.load(Ordering::Relaxed); let last_tick = (last_tick + 1) % 8; self.last_tick.store(last_tick, Ordering::Relaxed); last_tick }; let current_time = std::time::Instant::now(); let mut display_str = format!( "{} {} ", data.display_entries[0].prompt.as_text(), SPINNER_CHARS[last_tick] ); let elapsed_time = current_time - self.start_time; let fmt_elapsed_time = get_elapsed_text(elapsed_time); let total_text = if data.total_entries <= 1 { String::new() } else { format!( " {}/{}", data.total_entries - data.pending_entries, data.total_entries ) }; display_str.push_str(&format!("{}{}\n", fmt_elapsed_time, total_text)); for i in 0..4 { let Some(display_entry) = data.display_entries.get(i) else { display_str.push('\n'); continue; }; let bytes_text = { let total_size = display_entry.total_size; let pos = display_entry.position; if total_size == 0 { String::new() } else { format!( " {}/{}", human_download_size(pos, total_size), human_download_size(total_size, total_size) ) } }; // TODO(@marvinhagemeister): We're trying to reconstruct the original // specifier from the resolved one, but we lack the information about // private registries URLs and other things here. let message = display_entry .message .replace("https://registry.npmjs.org/", "npm:") .replace("https://jsr.io/", "jsr:") .replace("%2f", "/") .replace("%2F", "/"); display_str.push_str( &colors::gray(format!(" - {}{}\n", message, bytes_text)).to_string(), ); } display_str } } fn get_elapsed_text(elapsed: Duration) -> String { let elapsed_secs = elapsed.as_secs(); let seconds = elapsed_secs % 60; let minutes = elapsed_secs / 60; format!("[{minutes:0>2}:{seconds:0>2}]") } #[cfg(test)] mod test { use super::*; use pretty_assertions::assert_eq; use std::time::Duration; use test_util::assert_contains; #[test] fn should_get_elapsed_text() { assert_eq!(get_elapsed_text(Duration::from_secs(1)), "[00:01]"); assert_eq!(get_elapsed_text(Duration::from_secs(20)), "[00:20]"); assert_eq!(get_elapsed_text(Duration::from_secs(59)), "[00:59]"); assert_eq!(get_elapsed_text(Duration::from_secs(60)), "[01:00]"); assert_eq!( get_elapsed_text(Duration::from_secs(60 * 5 + 23)), "[05:23]" ); assert_eq!( get_elapsed_text(Duration::from_secs(60 * 59 + 59)), "[59:59]" ); assert_eq!(get_elapsed_text(Duration::from_secs(60 * 60)), "[60:00]"); assert_eq!( get_elapsed_text(Duration::from_secs(60 * 60 * 3 + 20 * 60 + 2)), "[200:02]" ); assert_eq!( get_elapsed_text(Duration::from_secs(60 * 60 * 99)), "[5940:00]" ); } const BYTES_TO_KIB: u64 = 2u64.pow(10); #[test] fn should_render_bar_progress() { let renderer = BarProgressBarRenderer { display_human_download_size: true, }; let mut data = ProgressData { display_entries: vec![ProgressDataDisplayEntry { prompt: ProgressMessagePrompt::Download, message: "data".to_string(), position: 0, total_size: 10 * BYTES_TO_KIB, }], duration: Duration::from_secs(1), pending_entries: 1, total_entries: 1, percent_done: 0f64, terminal_width: 50, }; let text = renderer.render(data.clone()); let text = test_util::strip_ansi_codes(&text); assert_eq!( text, concat!( "Download data 0.00KiB/10.00KiB\n", "[00:01] [-----------------]", ), ); data.percent_done = 0.5f64; data.display_entries[0].position = 5 * BYTES_TO_KIB; data.display_entries[0].message = "".to_string(); data.total_entries = 3; let text = renderer.render(data.clone()); let text = test_util::strip_ansi_codes(&text); assert_eq!(text, "[00:01] [####>------] 5.00KiB/10.00KiB (2/3)",); // just ensure this doesn't panic data.terminal_width = 0; let text = renderer.render(data.clone()); let text = test_util::strip_ansi_codes(&text); assert_eq!(text, "[00:01] [-] 5.00KiB/10.00KiB (2/3)",); data.terminal_width = 50; data.pending_entries = 0; data.display_entries[0].position = 10 * BYTES_TO_KIB; data.percent_done = 1.0f64; let text = renderer.render(data.clone()); let text = test_util::strip_ansi_codes(&text); assert_eq!(text, "[00:01] [###########] 10.00KiB/10.00KiB (3/3)",); data.display_entries[0].position = 0; data.display_entries[0].total_size = 0; data.pending_entries = 0; data.total_entries = 1; let text = renderer.render(data); let text = test_util::strip_ansi_codes(&text); assert_eq!(text, "[00:01] [###################################]",); } #[test] fn should_render_text_only_progress() { let renderer = TextOnlyProgressBarRenderer::default(); let mut data = ProgressData { display_entries: vec![ProgressDataDisplayEntry { prompt: ProgressMessagePrompt::Blocking, message: "data".to_string(), position: 0, total_size: 10 * BYTES_TO_KIB, }], duration: Duration::from_secs(1), pending_entries: 1, total_entries: 3, percent_done: 0f64, terminal_width: 50, }; let text = renderer.render(data.clone()); let text = test_util::strip_ansi_codes(&text); assert_contains!(text, "Blocking ⣯"); assert_contains!(text, "2/3\n - data 0.00KiB/10.00KiB\n\n\n\n"); data.pending_entries = 0; data.total_entries = 1; data.display_entries[0].position = 0; data.display_entries[0].total_size = 0; let text = renderer.render(data); let text = test_util::strip_ansi_codes(&text); assert_contains!(text, "Blocking ⣟"); assert_contains!(text, "\n - data\n\n\n\n"); } }