diff options
author | Bartek IwaĆczuk <biwanczuk@gmail.com> | 2022-12-13 02:52:10 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-12-12 20:52:10 -0500 |
commit | 8c026dab92b20fea44bc66f84db48b885c7264d1 (patch) | |
tree | 884704fb4721c9e227859451e58f524ace0a2261 /cli/util/progress_bar | |
parent | 4a17c930882c5765e5fdedb50b6493469f61e32d (diff) |
feat: improve download progress bar (#16984)
Co-authored-by: David Sherret <dsherret@gmail.com>
Diffstat (limited to 'cli/util/progress_bar')
-rw-r--r-- | cli/util/progress_bar/draw_thread.rs | 218 | ||||
-rw-r--r-- | cli/util/progress_bar/mod.rs | 123 | ||||
-rw-r--r-- | cli/util/progress_bar/renderer.rs | 278 |
3 files changed, 619 insertions, 0 deletions
diff --git a/cli/util/progress_bar/draw_thread.rs b/cli/util/progress_bar/draw_thread.rs new file mode 100644 index 000000000..89e8ab53f --- /dev/null +++ b/cli/util/progress_bar/draw_thread.rs @@ -0,0 +1,218 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use console_static_text::ConsoleStaticText; +use deno_core::parking_lot::Mutex; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; +use std::time::SystemTime; + +use crate::util::console::console_size; + +use super::renderer::ProgressBarRenderer; +use super::renderer::ProgressData; +use super::renderer::ProgressDataDisplayEntry; + +#[derive(Clone, Debug)] +pub struct ProgressBarEntry { + id: usize, + pub message: String, + pos: Arc<AtomicU64>, + total_size: Arc<AtomicU64>, + draw_thread: DrawThread, +} + +impl ProgressBarEntry { + pub fn position(&self) -> u64 { + self.pos.load(Ordering::Relaxed) + } + + pub fn set_position(&self, new_pos: u64) { + self.pos.store(new_pos, Ordering::Relaxed); + } + + pub fn total_size(&self) -> u64 { + self.total_size.load(Ordering::Relaxed) + } + + pub fn set_total_size(&self, new_size: u64) { + self.total_size.store(new_size, Ordering::Relaxed); + } + + pub fn finish(&self) { + self.draw_thread.finish_entry(self.id); + } + + pub fn percent(&self) -> f64 { + let pos = self.pos.load(Ordering::Relaxed) as f64; + let total_size = self.total_size.load(Ordering::Relaxed) as f64; + if total_size == 0f64 { + 0f64 + } else { + pos / total_size + } + } +} + +#[derive(Debug)] +struct InternalState { + start_time: SystemTime, + // this ensures only one draw thread is running + drawer_id: usize, + keep_alive_count: usize, + has_draw_thread: bool, + total_entries: usize, + entries: Vec<ProgressBarEntry>, + static_text: ConsoleStaticText, + renderer: Box<dyn ProgressBarRenderer>, +} + +#[derive(Clone, Debug)] +pub struct DrawThread { + state: Arc<Mutex<InternalState>>, +} + +impl DrawThread { + pub fn new(renderer: Box<dyn ProgressBarRenderer>) -> Self { + Self { + state: Arc::new(Mutex::new(InternalState { + start_time: SystemTime::now(), + drawer_id: 0, + keep_alive_count: 0, + has_draw_thread: false, + total_entries: 0, + entries: Vec::new(), + static_text: ConsoleStaticText::new(|| { + let size = console_size().unwrap(); + console_static_text::ConsoleSize { + cols: Some(size.cols as u16), + rows: Some(size.rows as u16), + } + }), + renderer, + })), + } + } + + pub fn add_entry(&self, message: String) -> ProgressBarEntry { + let mut internal_state = self.state.lock(); + let id = internal_state.total_entries; + let entry = ProgressBarEntry { + id, + draw_thread: self.clone(), + message, + pos: Default::default(), + total_size: Default::default(), + }; + internal_state.entries.push(entry.clone()); + internal_state.total_entries += 1; + internal_state.keep_alive_count += 1; + + if !internal_state.has_draw_thread { + self.start_draw_thread(&mut internal_state); + } + + entry + } + + fn finish_entry(&self, entry_id: usize) { + let mut internal_state = self.state.lock(); + + if let Ok(index) = internal_state + .entries + .binary_search_by(|e| e.id.cmp(&entry_id)) + { + internal_state.entries.remove(index); + self.decrement_keep_alive(&mut internal_state); + } + } + + pub fn increment_clear(&self) { + let mut internal_state = self.state.lock(); + internal_state.keep_alive_count += 1; + } + + pub fn decrement_clear(&self) { + let mut internal_state = self.state.lock(); + self.decrement_keep_alive(&mut internal_state); + } + + fn decrement_keep_alive(&self, internal_state: &mut InternalState) { + internal_state.keep_alive_count -= 1; + + if internal_state.keep_alive_count == 0 { + internal_state.static_text.eprint_clear(); + // bump the drawer id to exit the draw thread + internal_state.drawer_id += 1; + internal_state.has_draw_thread = false; + } + } + + fn start_draw_thread(&self, internal_state: &mut InternalState) { + internal_state.drawer_id += 1; + internal_state.start_time = SystemTime::now(); + internal_state.has_draw_thread = true; + let drawer_id = internal_state.drawer_id; + let internal_state = self.state.clone(); + tokio::task::spawn_blocking(move || { + let mut previous_size = console_size().unwrap(); + loop { + let mut delay_ms = 120; + { + let mut internal_state = internal_state.lock(); + // exit if not the current draw thread + if internal_state.drawer_id != drawer_id { + break; + } + + let size = console_size().unwrap(); + if size != previous_size { + // means the user is actively resizing the console... + // wait a little bit until they stop resizing + previous_size = size; + delay_ms = 200; + } else if !internal_state.entries.is_empty() { + let preferred_entry = internal_state + .entries + .iter() + .find(|e| e.percent() > 0f64) + .or_else(|| internal_state.entries.iter().last()) + .unwrap(); + let text = internal_state.renderer.render(ProgressData { + duration: internal_state.start_time.elapsed().unwrap(), + terminal_width: size.cols, + pending_entries: internal_state.entries.len(), + total_entries: internal_state.total_entries, + display_entry: ProgressDataDisplayEntry { + message: preferred_entry.message.clone(), + position: preferred_entry.position(), + total_size: preferred_entry.total_size(), + }, + percent_done: { + let mut total_percent_sum = 0f64; + for entry in &internal_state.entries { + total_percent_sum += entry.percent(); + } + total_percent_sum += (internal_state.total_entries + - internal_state.entries.len()) + as f64; + total_percent_sum / (internal_state.total_entries as f64) + }, + }); + + internal_state.static_text.eprint_with_size( + &text, + console_static_text::ConsoleSize { + cols: Some(size.cols as u16), + rows: Some(size.rows as u16), + }, + ); + } + } + + std::thread::sleep(Duration::from_millis(delay_ms)); + } + }); + } +} diff --git a/cli/util/progress_bar/mod.rs b/cli/util/progress_bar/mod.rs new file mode 100644 index 000000000..122db7a59 --- /dev/null +++ b/cli/util/progress_bar/mod.rs @@ -0,0 +1,123 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use crate::colors; + +use self::draw_thread::DrawThread; +use self::draw_thread::ProgressBarEntry; + +use super::console::console_size; + +mod draw_thread; +mod renderer; + +// Inspired by Indicatif, but this custom implementation allows +// for more control over what's going on under the hood. + +pub struct UpdateGuard { + maybe_entry: Option<ProgressBarEntry>, +} + +impl Drop for UpdateGuard { + fn drop(&mut self) { + if let Some(entry) = &self.maybe_entry { + entry.finish(); + } + } +} + +impl UpdateGuard { + pub fn set_position(&self, value: u64) { + if let Some(entry) = &self.maybe_entry { + entry.set_position(value); + } + } + + pub fn set_total_size(&self, value: u64) { + if let Some(entry) = &self.maybe_entry { + entry.set_total_size(value); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProgressBarStyle { + DownloadBars, + TextOnly, +} + +#[derive(Clone, Debug)] +pub struct ProgressBar { + draw_thread: Option<DrawThread>, +} + +impl ProgressBar { + /// Checks if progress bars are supported + pub fn are_supported() -> bool { + atty::is(atty::Stream::Stderr) + && log::log_enabled!(log::Level::Info) + && console_size() + .map(|s| s.cols > 0 && s.rows > 0) + .unwrap_or(false) + } + + pub fn new(style: ProgressBarStyle) -> Self { + Self { + draw_thread: match Self::are_supported() { + true => Some(DrawThread::new(match style { + ProgressBarStyle::DownloadBars => { + Box::new(renderer::BarProgressBarRenderer) + } + ProgressBarStyle::TextOnly => { + Box::new(renderer::TextOnlyProgressBarRenderer) + } + })), + false => None, + }, + } + } + + pub fn is_enabled(&self) -> bool { + self.draw_thread.is_some() + } + + pub fn update(&self, msg: &str) -> UpdateGuard { + match &self.draw_thread { + Some(draw_thread) => { + let entry = draw_thread.add_entry(msg.to_string()); + UpdateGuard { + maybe_entry: Some(entry), + } + } + None => { + // if we're not running in TTY, fallback to using logger crate + if !msg.is_empty() { + log::log!(log::Level::Info, "{} {}", colors::green("Download"), msg); + } + UpdateGuard { maybe_entry: None } + } + } + } + + pub fn clear_guard(&self) -> ClearGuard { + if let Some(draw_thread) = &self.draw_thread { + draw_thread.increment_clear(); + } + ClearGuard { pb: self.clone() } + } + + fn decrement_clear(&self) { + if let Some(draw_thread) = &self.draw_thread { + draw_thread.decrement_clear(); + } + } +} + +pub struct ClearGuard { + pb: ProgressBar, +} + +impl Drop for ClearGuard { + fn drop(&mut self) { + self.pb.decrement_clear(); + } +} diff --git a/cli/util/progress_bar/renderer.rs b/cli/util/progress_bar/renderer.rs new file mode 100644 index 000000000..cb249ce36 --- /dev/null +++ b/cli/util/progress_bar/renderer.rs @@ -0,0 +1,278 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use std::time::Duration; + +use deno_runtime::colors; + +use crate::util::display::human_download_size; + +#[derive(Clone)] +pub struct ProgressDataDisplayEntry { + pub message: String, + pub position: u64, + pub total_size: u64, +} + +#[derive(Clone)] +pub struct ProgressData { + pub terminal_width: u32, + pub display_entry: ProgressDataDisplayEntry, + pub pending_entries: usize, + pub percent_done: f64, + pub total_entries: usize, + pub duration: Duration, +} + +pub trait ProgressBarRenderer: Send + std::fmt::Debug { + fn render(&self, data: ProgressData) -> String; +} + +/// Indicatif style progress bar. +#[derive(Debug)] +pub struct BarProgressBarRenderer; + +impl ProgressBarRenderer for BarProgressBarRenderer { + fn render(&self, data: ProgressData) -> String { + let (bytes_text, bytes_text_max_width) = { + let total_size = data.display_entry.total_size; + let pos = data.display_entry.position; + if total_size == 0 { + (String::new(), 0) + } else { + let total_size_str = human_download_size(total_size, total_size); + ( + format!( + " {}/{}", + human_download_size(pos, total_size), + 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 !data.display_entry.message.is_empty() { + text.push_str(&format!( + "{} {}{}\n", + colors::green("Download"), + data.display_entry.message, + bytes_text, + )); + } + text.push_str(&elapsed_text); + let max_width = + std::cmp::max(10, std::cmp::min(75, data.terminal_width as i32 - 5)) + 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 data.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; + +impl ProgressBarRenderer for TextOnlyProgressBarRenderer { + fn render(&self, data: ProgressData) -> String { + let bytes_text = { + let total_size = data.display_entry.total_size; + let pos = data.display_entry.position; + if total_size == 0 { + String::new() + } else { + format!( + " {}/{}", + human_download_size(pos, total_size), + human_download_size(total_size, total_size) + ) + } + }; + let total_text = if data.total_entries <= 1 { + String::new() + } else { + format!( + " ({}/{})", + data.total_entries - data.pending_entries, + data.total_entries + ) + }; + + format!( + "{} {}{}{}", + colors::green("Download"), + data.display_entry.message, + colors::gray(bytes_text), + colors::gray(total_text), + ) + } +} + +fn get_elapsed_text(elapsed: Duration) -> String { + let elapsed_secs = elapsed.as_secs(); + let seconds = elapsed_secs % 60; + let minutes = elapsed_secs / 60; + format!("[{:0>2}:{:0>2}]", minutes, seconds) +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use std::time::Duration; + + #[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; + let mut data = ProgressData { + display_entry: ProgressDataDisplayEntry { + 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_entry.position = 5 * BYTES_TO_KIB; + data.display_entry.message = String::new(); + 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_entry.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_entry.position = 0; + data.display_entry.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; + let mut data = ProgressData { + display_entry: ProgressDataDisplayEntry { + 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_eq!(text, "Download data 0.00KiB/10.00KiB (2/3)"); + + data.pending_entries = 0; + data.total_entries = 1; + data.display_entry.position = 0; + data.display_entry.total_size = 0; + let text = renderer.render(data); + let text = test_util::strip_ansi_codes(&text); + assert_eq!(text, "Download data"); + } +} |