mirror of
https://github.com/facebook/sapling.git
synced 2024-10-10 08:47:12 +03:00
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:
parent
309bcb5424
commit
a310122ce2
9
eden/scm/lib/progress/render/Cargo.toml
Normal file
9
eden/scm/lib/progress/render/Cargo.toml
Normal 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"
|
98
eden/scm/lib/progress/render/src/config.rs
Normal file
98
eden/scm/lib/progress/render/src/config.rs
Normal 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()
|
||||
}
|
||||
}
|
16
eden/scm/lib/progress/render/src/lib.rs
Normal file
16
eden/scm/lib/progress/render/src/lib.rs
Normal 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;
|
234
eden/scm/lib/progress/render/src/simple.rs
Normal file
234
eden/scm/lib/progress/render/src/simple.rs
Normal 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)
|
||||
}
|
||||
}
|
72
eden/scm/lib/progress/render/src/tests.rs
Normal file
72
eden/scm/lib/progress/render/src/tests.rs
Normal 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(®, &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
|
||||
}
|
Loading…
Reference in New Issue
Block a user