summaryrefslogtreecommitdiff
path: root/cli/util/progress_bar
diff options
context:
space:
mode:
authorBartek IwaƄczuk <biwanczuk@gmail.com>2022-12-13 02:52:10 +0100
committerGitHub <noreply@github.com>2022-12-12 20:52:10 -0500
commit8c026dab92b20fea44bc66f84db48b885c7264d1 (patch)
tree884704fb4721c9e227859451e58f524ace0a2261 /cli/util/progress_bar
parent4a17c930882c5765e5fdedb50b6493469f61e32d (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.rs218
-rw-r--r--cli/util/progress_bar/mod.rs123
-rw-r--r--cli/util/progress_bar/renderer.rs278
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");
+ }
+}