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
This commit is contained in:
Jun Wu 2021-03-11 17:16:35 -08:00 committed by Facebook GitHub Bot
parent 309bcb5424
commit a310122ce2
5 changed files with 429 additions and 0 deletions

View File

@ -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"

View File

@ -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()
}
}

View File

@ -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;

View File

@ -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<String>, series_list: &[Arc<IoTimeSeries>]) {
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<String>,
bars: &[Arc<ProgressBar>],
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<String>, list: &[Arc<CacheStats>]) {
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)
}
}

View File

@ -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(&reg, &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
}