From a310122ce268246f5dffd18cdb5dc65dd80bc3da Mon Sep 17 00:00:00 2001 From: Jun Wu Date: Thu, 11 Mar 2021 17:16:35 -0800 Subject: [PATCH] progress: add a sub-crate for rendering progress Summary: Add a simple way to render progress into a multi-line string. Reviewed By: andll Differential Revision: D26853751 fbshipit-source-id: 4f1de55e7bb03f607d683eff16e035aa5d1476c1 --- eden/scm/lib/progress/render/Cargo.toml | 9 + eden/scm/lib/progress/render/src/config.rs | 98 +++++++++ eden/scm/lib/progress/render/src/lib.rs | 16 ++ eden/scm/lib/progress/render/src/simple.rs | 234 +++++++++++++++++++++ eden/scm/lib/progress/render/src/tests.rs | 72 +++++++ 5 files changed, 429 insertions(+) create mode 100644 eden/scm/lib/progress/render/Cargo.toml create mode 100644 eden/scm/lib/progress/render/src/config.rs create mode 100644 eden/scm/lib/progress/render/src/lib.rs create mode 100644 eden/scm/lib/progress/render/src/simple.rs create mode 100644 eden/scm/lib/progress/render/src/tests.rs diff --git a/eden/scm/lib/progress/render/Cargo.toml b/eden/scm/lib/progress/render/Cargo.toml new file mode 100644 index 0000000000..52e9a5514f --- /dev/null +++ b/eden/scm/lib/progress/render/Cargo.toml @@ -0,0 +1,9 @@ +# @generated by autocargo from //eden/scm/lib/progress/render:progress-render +[package] +name = "progress-render" +version = "0.1.0" +edition = "2018" + +[dependencies] +progress-model = { path = "../model" } +unicode-width = "0.1" diff --git a/eden/scm/lib/progress/render/src/config.rs b/eden/scm/lib/progress/render/src/config.rs new file mode 100644 index 0000000000..4eb848717e --- /dev/null +++ b/eden/scm/lib/progress/render/src/config.rs @@ -0,0 +1,98 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This software may be used and distributed according to the terms of the + * GNU General Public License version 2. + */ + +//! Progress rendering configuration. + +use std::borrow::Cow; +use std::time::Duration; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +pub struct RenderingConfig { + /// Delay before showing a newly created bar. + pub delay: Duration, + + /// Maximum number of bars to show. + pub max_bar_count: usize, + + /// Terminal width. + pub term_width: usize, + + /// Use CJK width (some characters are treated as 2-char width, instead of 1-char width). + /// Practically, some CJK fonts would work better with this set to true. + pub cjk_width: bool, +} + +impl Default for RenderingConfig { + fn default() -> Self { + Self { + delay: Duration::from_secs(3), + max_bar_count: 8, + term_width: 80, + cjk_width: false, + } + } +} + +#[cfg(test)] +impl RenderingConfig { + pub fn for_testing() -> Self { + Self { + delay: Duration::from_secs(0), + max_bar_count: 5, + term_width: 60, + cjk_width: false, + } + } +} + +impl RenderingConfig { + /// Truncate a single line. + pub(crate) fn truncate_line<'a>(&self, line: &'a str) -> Cow<'a, str> { + self.truncate_by_width(line, self.term_width, "…") + } + + /// Truncate `text` to fit in the given width. + /// `suffix` is appended if `text` is truncated. + pub(crate) fn truncate_by_width<'a>( + &self, + text: &'a str, + width: usize, + suffix: &str, + ) -> Cow<'a, str> { + if self.width_str(text) >= width { + let mut current_width = 0; + let suffix_width = self.width_str(suffix); + for (i, ch) in text.char_indices() { + let next_width = current_width + self.width_char(ch); + if next_width + suffix_width >= width { + // Cannot take this char. + return format!("{}{}", &text[..i], suffix).into(); + } + current_width = next_width; + } + } + return Cow::Borrowed(text); + } + + fn width_str(&self, text: &str) -> usize { + if self.cjk_width { + text.width_cjk() + } else { + text.width() + } + } + + fn width_char(&self, ch: char) -> usize { + if self.cjk_width { + ch.width_cjk() + } else { + ch.width() + } + .unwrap_or_default() + } +} diff --git a/eden/scm/lib/progress/render/src/lib.rs b/eden/scm/lib/progress/render/src/lib.rs new file mode 100644 index 0000000000..39f8d1fac3 --- /dev/null +++ b/eden/scm/lib/progress/render/src/lib.rs @@ -0,0 +1,16 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This software may be used and distributed according to the terms of the + * GNU General Public License version 2. + */ + +//! Progress rendering. + +mod config; +pub mod simple; + +pub use config::RenderingConfig; + +#[cfg(test)] +mod tests; diff --git a/eden/scm/lib/progress/render/src/simple.rs b/eden/scm/lib/progress/render/src/simple.rs new file mode 100644 index 0000000000..89a7352fcc --- /dev/null +++ b/eden/scm/lib/progress/render/src/simple.rs @@ -0,0 +1,234 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This software may be used and distributed according to the terms of the + * GNU General Public License version 2. + */ + +//! Simple renderer. Does not use complex ANSI escape codes (ex. colors). + +use crate::RenderingConfig; +use progress_model::CacheStats; +use progress_model::IoTimeSeries; +use progress_model::ProgressBar; +use progress_model::Registry; +use std::borrow::Cow; +use std::sync::Arc; + +/// Render progress into a multi-line string. +pub fn render(registry: &Registry, config: &RenderingConfig) -> String { + let mut lines = Vec::new(); + + let cache_list = registry.list_cache_stats(); + let series_list = registry.list_io_time_series(); + let bar_list = registry.list_progress_bar(); + + render_cache_stats(&mut lines, &cache_list); + render_time_series(&mut lines, &series_list); + render_progress_bars(&mut lines, &bar_list, config); + + for line in lines.iter_mut() { + *line = config.truncate_line(&line).to_string(); + } + + lines.join("\n") +} + +fn render_time_series(lines: &mut Vec, series_list: &[Arc]) { + for model in series_list { + let mut phrases = Vec::with_capacity(4); + if model.is_stale() { + continue; + } + + // Net [▁▂▄█▇▅▃▆] 3 MB/s + phrases.push(format!("{:>12}", model.topic())); + + let ascii = ascii_time_series(&model); + phrases.push(format!("[{}]", ascii)); + + let (rx, tx) = model.bytes_per_second(); + let speed = human_rx_tx_per_second(rx, tx); + if !speed.is_empty() { + phrases.push(speed); + } + + let count = model.count(); + if count > 1 { + let unit = model.count_unit(); + phrases.push(format!("{} {}", count, unit)); + } + let line = phrases.join(" "); + lines.push(line); + } +} + +fn render_progress_bars( + lines: &mut Vec, + bars: &[Arc], + config: &RenderingConfig, +) { + let mut hidden = 0; + let mut shown = 0; + for bar in bars.iter() { + if config.delay.as_millis() > 0 && bar.elapsed() < config.delay { + continue; + } + + if shown >= config.max_bar_count { + hidden += 1; + continue; + } + + shown += 1; + + // topic [====> ] 12 / 56 files message + let topic = capitalize(bar.topic().split_whitespace().next().unwrap_or("")); + let mut phrases = vec![format!("{:>12}", topic)]; + // [===> ] + + let (pos, total) = bar.position_total(); + let width = 15usize; + if total > 0 && pos <= total { + let (len, end) = if pos == total { + (width, "") + } else { + ((pos * (width as u64) / total) as usize, ">") + }; + phrases.push(format!( + "[{}{}{}]", + str::repeat("=", len), + end, + str::repeat(" ", width - len - end.len()) + )); + } else { + // Spinner + let pos = if cfg!(test) { + 5 + } else { + bar.elapsed().as_millis() / 200 + }; + let spaceship = "<=>"; + let left_max = width - spaceship.len(); + // 0, 1, 2, ..., width - 4, width - 3, width - 4, ..., 0 + let mut left_pad = (pos as usize) % (left_max * 2); + if left_pad >= left_max { + left_pad = 2 * left_max - left_pad; + } + phrases.push(format!( + "[{}{}{}]", + str::repeat(" ", left_pad), + spaceship, + str::repeat(" ", left_max - left_pad) + )); + } + + // 12 / 56 files + let unit = bar.unit(); + let phrase = match unit { + "%" => { + let total = total.max(1); + format!("{}%", pos.min(total) * 100 / total) + } + "bytes" | "B" => { + if total == 0 { + human_bytes(pos as _) + } else { + format!("{}/{}", human_bytes(pos as _), human_bytes(total as _)) + } + } + _ => { + if total == 0 { + if pos == 0 { + String::new() + } else { + format!("{} {}", pos, unit) + } + } else { + format!("{}/{} {}", pos, total, unit) + } + } + }; + phrases.push(phrase); + + // message + if let Some(message) = bar.message() { + phrases.push(message.to_string()); + } + lines.push(phrases.join(" ")); + } + + if hidden > 0 { + lines.push(format!("{:>12} and {} more", "", hidden)); + } +} + +fn render_cache_stats(lines: &mut Vec, list: &[Arc]) { + for model in list { + // topic [====> ] 12 / 56 files message + let topic = model.topic(); + let miss = model.miss(); + let hit = model.hit(); + let total = miss + hit; + if total > 0 { + let mut line = format!("{:>12} {}", topic, total); + if miss > 0 { + let miss_rate = (miss * 100) / (total.max(1)); + line += &format!(" ({}% miss)", miss_rate); + } + lines.push(line); + } + } +} + +fn human_rx_tx_per_second(rx: u64, tx: u64) -> String { + let mut result = Vec::new(); + for (speed, symbol) in [(rx, '⬇'), (tx, '⬆')].iter() { + if *speed > 0 { + result.push(format!("{} {}", symbol, human_bytes_per_second(*speed))); + } + } + result.join(" ") +} + +fn human_bytes(bytes: u64) -> String { + if bytes < 5000 { + format!("{}B", bytes) + } else if bytes < 5_000_000 { + format!("{}KB", bytes / 1000) + } else if bytes < 5_000_000_000 { + format!("{}MB", bytes / 1000000) + } else { + format!("{}GB", bytes / 1000000000) + } +} + +fn human_bytes_per_second(bytes_per_second: u64) -> String { + format!("{}/s", human_bytes(bytes_per_second)) +} + +fn ascii_time_series(time_series: &IoTimeSeries) -> String { + const GAUGE_CHARS: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let v = time_series.scaled_speeds((GAUGE_CHARS.len() - 1) as u8); + v.into_iter().map(|i| GAUGE_CHARS[i as usize]).collect() +} + +fn capitalize<'a>(s: &'a str) -> Cow<'a, str> { + if s.chars().next().unwrap_or('A').is_ascii_uppercase() { + Cow::Borrowed(s) + } else { + let mut first = true; + let s: String = s + .chars() + .map(|c| { + if first { + first = false; + c.to_ascii_uppercase() + } else { + c + } + }) + .collect(); + Cow::Owned(s) + } +} diff --git a/eden/scm/lib/progress/render/src/tests.rs b/eden/scm/lib/progress/render/src/tests.rs new file mode 100644 index 0000000000..062625d881 --- /dev/null +++ b/eden/scm/lib/progress/render/src/tests.rs @@ -0,0 +1,72 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This software may be used and distributed according to the terms of the + * GNU General Public License version 2. + */ + +use crate::RenderingConfig; +use progress_model::CacheStats; +use progress_model::IoTimeSeries; +use progress_model::ProgressBar; +use progress_model::Registry; + +#[test] +fn test_simple_render() { + let reg = example(); + let config = RenderingConfig::for_testing(); + assert_eq!( + format!("\n{}", crate::simple::render(®, &config)), + r#" + Files 110 (9% miss) + Trees 110 (9% miss) + Net [ ▁▂▂▃▃▄▅▅▆▆▇█] ⬇ 67KB/s 154 requests + Disk [ ▁▂▂▃▃▄▅▅▆▆▇█] ⬆ 4050B/s + Files [=======> ] 5KB/10KB + Trees [ <=> ] 5KB + Commits [=======> ] 5KB/10KB + Files [=======> ] 5KB/10KB ./foo/Files/文… + Trees [ <=> ] 5KB ./foo/Trees/文件名 + and 4 more"# + ); +} + +/// Example registry with some progress bars. +fn example() -> Registry { + let reg = Registry::default(); + + // Time series. + for &(topic, unit) in &[("Net", "requests"), ("Disk", "files")] { + let series = IoTimeSeries::new(topic, unit); + if topic == "Net" { + series.populate_test_samples(1, 0, 11); + } else { + series.populate_test_samples(0, 1, 0); + } + reg.register_io_time_series(&series); + } + + // Cache stats + for &topic in &["Files", "Trees"] { + let stats = CacheStats::new(topic); + stats.increase_hit(100); + stats.increase_miss(10); + reg.register_cache_stats(&stats); + } + + // Progress bars + for i in 0..3 { + for &topic in &["Files", "Trees", "Commits"] { + let total = if topic == "Trees" { 0 } else { 10000 }; + let bar = ProgressBar::new(topic, total, "bytes"); + bar.increase_position(5000); + reg.register_progress_bar(&bar); + if i == 1 { + let message = format!("./foo/{}/文件名", topic); + bar.set_message(message); + } + } + } + + reg +}