feat: add taskbar progress reporting

This commit is contained in:
Alexander Sergeev 2024-05-12 00:58:56 +03:00
parent 4a8fd9b5df
commit a0624eaf53
5 changed files with 225 additions and 17 deletions

View File

@ -854,7 +854,7 @@ impl<'gctx> DrainState<'gctx> {
} }
fn handle_error( fn handle_error(
&self, &mut self,
shell: &mut Shell, shell: &mut Shell,
err_state: &mut ErrorsDuringDrain, err_state: &mut ErrorsDuringDrain,
new_err: impl Into<ErrorToHandle>, new_err: impl Into<ErrorToHandle>,
@ -863,6 +863,7 @@ impl<'gctx> DrainState<'gctx> {
if new_err.print_always || err_state.count == 0 { if new_err.print_always || err_state.count == 0 {
crate::display_error(&new_err.error, shell); crate::display_error(&new_err.error, shell);
if err_state.count == 0 && !self.active.is_empty() { if err_state.count == 0 && !self.active.is_empty() {
self.progress.indicate_error();
let _ = shell.warn("build failed, waiting for other jobs to finish..."); let _ = shell.warn("build failed, waiting for other jobs to finish...");
} }
err_state.count += 1; err_state.count += 1;

View File

@ -56,6 +56,7 @@ impl Shell {
stderr_tty: std::io::stderr().is_terminal(), stderr_tty: std::io::stderr().is_terminal(),
stdout_unicode: supports_unicode(&std::io::stdout()), stdout_unicode: supports_unicode(&std::io::stdout()),
stderr_unicode: supports_unicode(&std::io::stderr()), stderr_unicode: supports_unicode(&std::io::stderr()),
stderr_term_integration: supports_term_integration(&std::io::stderr()),
}, },
verbosity: Verbosity::Verbose, verbosity: Verbosity::Verbose,
needs_clear: false, needs_clear: false,
@ -122,6 +123,18 @@ impl Shell {
} }
} }
pub fn is_err_term_integration_available(&self) -> bool {
if let ShellOut::Stream {
stderr_term_integration,
..
} = self.output
{
stderr_term_integration
} else {
false
}
}
/// Gets a reference to the underlying stdout writer. /// Gets a reference to the underlying stdout writer.
pub fn out(&mut self) -> &mut dyn Write { pub fn out(&mut self) -> &mut dyn Write {
if self.needs_clear { if self.needs_clear {
@ -426,6 +439,7 @@ enum ShellOut {
hyperlinks: bool, hyperlinks: bool,
stdout_unicode: bool, stdout_unicode: bool,
stderr_unicode: bool, stderr_unicode: bool,
stderr_term_integration: bool,
}, },
} }
@ -575,6 +589,16 @@ fn supports_hyperlinks() -> bool {
supports_hyperlinks::supports_hyperlinks() supports_hyperlinks::supports_hyperlinks()
} }
/// Determines whether the terminal supports ANSI OSC 9;4.
#[allow(clippy::disallowed_methods)] // Read environment variables to detect terminal
fn supports_term_integration(stream: &dyn IsTerminal) -> bool {
let windows_terminal = std::env::var("WT_SESSION").is_ok();
let conemu = std::env::var("ConEmuANSI").ok() == Some("ON".into());
let wezterm = std::env::var("TERM_PROGRAM").ok() == Some("WezTerm".into());
(windows_terminal || conemu || wezterm) && stream.is_terminal()
}
pub struct Hyperlink<D: fmt::Display> { pub struct Hyperlink<D: fmt::Display> {
url: Option<D>, url: Option<D>,
} }

View File

@ -2832,6 +2832,8 @@ pub struct TermConfig {
pub struct ProgressConfig { pub struct ProgressConfig {
pub when: ProgressWhen, pub when: ProgressWhen,
pub width: Option<usize>, pub width: Option<usize>,
/// Communicate progress status with a terminal
pub term_integration: Option<bool>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
@ -2864,10 +2866,12 @@ where
"auto" => Ok(Some(ProgressConfig { "auto" => Ok(Some(ProgressConfig {
when: ProgressWhen::Auto, when: ProgressWhen::Auto,
width: None, width: None,
term_integration: None,
})), })),
"never" => Ok(Some(ProgressConfig { "never" => Ok(Some(ProgressConfig {
when: ProgressWhen::Never, when: ProgressWhen::Never,
width: None, width: None,
term_integration: None,
})), })),
"always" => Err(E::custom("\"always\" progress requires a `width` key")), "always" => Err(E::custom("\"always\" progress requires a `width` key")),
_ => Err(E::unknown_variant(s, &["auto", "never"])), _ => Err(E::unknown_variant(s, &["auto", "never"])),
@ -2889,6 +2893,7 @@ where
if let ProgressConfig { if let ProgressConfig {
when: ProgressWhen::Always, when: ProgressWhen::Always,
width: None, width: None,
..
} = pc } = pc
{ {
return Err(serde::de::Error::custom( return Err(serde::de::Error::custom(

View File

@ -74,6 +74,108 @@ struct Format {
style: ProgressStyle, style: ProgressStyle,
max_width: usize, max_width: usize,
max_print: usize, max_print: usize,
term_integration: TerminalIntegration,
}
/// Controls terminal progress integration via OSC sequences.
struct TerminalIntegration {
enabled: bool,
error: bool,
}
/// A progress status value printable as an ANSI OSC 9;4 escape code.
#[cfg_attr(test, derive(PartialEq, Debug))]
enum StatusValue {
/// No output.
None,
/// Remove progress.
Remove,
/// Progress value (0-100).
Value(f64),
/// Indeterminate state (no bar, just animation)
Indeterminate,
/// Progress value in an error state (0-100).
Error(f64),
}
enum ProgressOutput {
/// Print progress without a message
PrintNow,
/// Progress, message and progress report
TextAndReport(String, StatusValue),
/// Only progress report, no message and no text progress
Report(StatusValue),
}
impl TerminalIntegration {
#[cfg(test)]
fn new(enabled: bool) -> Self {
Self {
enabled,
error: false,
}
}
/// Creates a `TerminalIntegration` from Cargo's configuration.
/// Autodetect support if not explicitly enabled or disabled.
fn from_config(gctx: &GlobalContext) -> Self {
let enabled = gctx
.progress_config()
.term_integration
.unwrap_or_else(|| gctx.shell().is_err_term_integration_available());
Self {
enabled,
error: false,
}
}
fn progress_state(&self, value: StatusValue) -> StatusValue {
match (self.enabled, self.error) {
(true, false) => value,
(true, true) => match value {
StatusValue::Value(v) => StatusValue::Error(v),
_ => StatusValue::Error(100.0),
},
(false, _) => StatusValue::None,
}
}
pub fn remove(&self) -> StatusValue {
self.progress_state(StatusValue::Remove)
}
pub fn value(&self, percent: f64) -> StatusValue {
self.progress_state(StatusValue::Value(percent))
}
pub fn indeterminate(&self) -> StatusValue {
self.progress_state(StatusValue::Indeterminate)
}
pub fn error(&mut self) {
self.error = true;
}
}
impl std::fmt::Display for StatusValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// From https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC
// ESC ] 9 ; 4 ; st ; pr ST
// When st is 0: remove progress.
// When st is 1: set progress value to pr (number, 0-100).
// When st is 2: set error state in taskbar, pr is optional.
// When st is 3: set indeterminate state, pr is ignored.
// When st is 4: set paused state, pr is optional.
let (state, progress) = match self {
Self::None => return Ok(()), // No output
Self::Remove => (0, 0.0),
Self::Value(v) => (1, *v),
Self::Indeterminate => (3, 0.0),
Self::Error(v) => (2, *v),
};
write!(f, "\x1b]9;4;{state};{progress:.0}\x1b\\")
}
} }
impl<'gctx> Progress<'gctx> { impl<'gctx> Progress<'gctx> {
@ -126,6 +228,7 @@ impl<'gctx> Progress<'gctx> {
// 50 gives some space for text after the progress bar, // 50 gives some space for text after the progress bar,
// even on narrow (e.g. 80 char) terminals. // even on narrow (e.g. 80 char) terminals.
max_print: 50, max_print: 50,
term_integration: TerminalIntegration::from_config(gctx),
}, },
name: name.to_string(), name: name.to_string(),
done: false, done: false,
@ -223,7 +326,7 @@ impl<'gctx> Progress<'gctx> {
/// calling it too often. /// calling it too often.
pub fn print_now(&mut self, msg: &str) -> CargoResult<()> { pub fn print_now(&mut self, msg: &str) -> CargoResult<()> {
match &mut self.state { match &mut self.state {
Some(s) => s.print("", msg), Some(s) => s.print(ProgressOutput::PrintNow, msg),
None => Ok(()), None => Ok(()),
} }
} }
@ -234,6 +337,13 @@ impl<'gctx> Progress<'gctx> {
s.clear(); s.clear();
} }
} }
/// Sets the progress reporter to the error state.
pub fn indicate_error(&mut self) {
if let Some(s) = &mut self.state {
s.format.term_integration.error()
}
}
} }
impl Throttle { impl Throttle {
@ -269,6 +379,11 @@ impl Throttle {
impl<'gctx> State<'gctx> { impl<'gctx> State<'gctx> {
fn tick(&mut self, cur: usize, max: usize, msg: &str) -> CargoResult<()> { fn tick(&mut self, cur: usize, max: usize, msg: &str) -> CargoResult<()> {
if self.done { if self.done {
write!(
self.gctx.shell().err(),
"{}",
self.format.term_integration.remove()
)?;
return Ok(()); return Ok(());
} }
@ -280,21 +395,30 @@ impl<'gctx> State<'gctx> {
// return back to the beginning of the line for the next print. // return back to the beginning of the line for the next print.
self.try_update_max_width(); self.try_update_max_width();
if let Some(pbar) = self.format.progress(cur, max) { if let Some(pbar) = self.format.progress(cur, max) {
self.print(&pbar, msg)?; self.print(pbar, msg)?;
} }
Ok(()) Ok(())
} }
fn print(&mut self, prefix: &str, msg: &str) -> CargoResult<()> { fn print(&mut self, progress: ProgressOutput, msg: &str) -> CargoResult<()> {
self.throttle.update(); self.throttle.update();
self.try_update_max_width(); self.try_update_max_width();
let (mut line, report) = match progress {
ProgressOutput::PrintNow => (String::new(), None),
ProgressOutput::TextAndReport(prefix, report) => (prefix, Some(report)),
ProgressOutput::Report(report) => (String::new(), Some(report)),
};
// make sure we have enough room for the header // make sure we have enough room for the header
if self.format.max_width < 15 { if self.format.max_width < 15 {
// even if we don't have space we can still output progress report
if let Some(tb) = report {
write!(self.gctx.shell().err(), "{tb}\r")?;
}
return Ok(()); return Ok(());
} }
let mut line = prefix.to_string();
self.format.render(&mut line, msg); self.format.render(&mut line, msg);
while line.len() < self.format.max_width - 15 { while line.len() < self.format.max_width - 15 {
line.push(' '); line.push(' ');
@ -305,7 +429,11 @@ impl<'gctx> State<'gctx> {
let mut shell = self.gctx.shell(); let mut shell = self.gctx.shell();
shell.set_needs_clear(false); shell.set_needs_clear(false);
shell.status_header(&self.name)?; shell.status_header(&self.name)?;
write!(shell.err(), "{}\r", line)?; if let Some(tb) = report {
write!(shell.err(), "{line}{tb}\r")?;
} else {
write!(shell.err(), "{line}\r")?;
}
self.last_line = Some(line); self.last_line = Some(line);
shell.set_needs_clear(true); shell.set_needs_clear(true);
} }
@ -314,6 +442,12 @@ impl<'gctx> State<'gctx> {
} }
fn clear(&mut self) { fn clear(&mut self) {
// Always clear the progress report
let _ = write!(
self.gctx.shell().err(),
"{}",
self.format.term_integration.remove()
);
// No need to clear if the progress is not currently being displayed. // No need to clear if the progress is not currently being displayed.
if self.last_line.is_some() && !self.gctx.shell().is_cleared() { if self.last_line.is_some() && !self.gctx.shell().is_cleared() {
self.gctx.shell().err_erase_line(); self.gctx.shell().err_erase_line();
@ -331,7 +465,7 @@ impl<'gctx> State<'gctx> {
} }
impl Format { impl Format {
fn progress(&self, cur: usize, max: usize) -> Option<String> { fn progress(&self, cur: usize, max: usize) -> Option<ProgressOutput> {
assert!(cur <= max); assert!(cur <= max);
// Render the percentage at the far right and then figure how long the // Render the percentage at the far right and then figure how long the
// progress bar is // progress bar is
@ -339,11 +473,21 @@ impl Format {
let pct = if !pct.is_finite() { 0.0 } else { pct }; let pct = if !pct.is_finite() { 0.0 } else { pct };
let stats = match self.style { let stats = match self.style {
ProgressStyle::Percentage => format!(" {:6.02}%", pct * 100.0), ProgressStyle::Percentage => format!(" {:6.02}%", pct * 100.0),
ProgressStyle::Ratio => format!(" {}/{}", cur, max), ProgressStyle::Ratio => format!(" {cur}/{max}"),
ProgressStyle::Indeterminate => String::new(), ProgressStyle::Indeterminate => String::new(),
}; };
let report = match self.style {
ProgressStyle::Percentage | ProgressStyle::Ratio => {
self.term_integration.value(pct * 100.0)
}
ProgressStyle::Indeterminate => self.term_integration.indeterminate(),
};
let extra_len = stats.len() + 2 /* [ and ] */ + 15 /* status header */; let extra_len = stats.len() + 2 /* [ and ] */ + 15 /* status header */;
let Some(display_width) = self.width().checked_sub(extra_len) else { let Some(display_width) = self.width().checked_sub(extra_len) else {
if self.term_integration.enabled {
return Some(ProgressOutput::Report(report));
}
return None; return None;
}; };
@ -371,7 +515,7 @@ impl Format {
string.push(']'); string.push(']');
string.push_str(&stats); string.push_str(&stats);
Some(string) Some(ProgressOutput::TextAndReport(string, report))
} }
fn render(&self, string: &mut String, msg: &str) { fn render(&self, string: &mut String, msg: &str) {
@ -398,7 +542,11 @@ impl Format {
#[cfg(test)] #[cfg(test)]
fn progress_status(&self, cur: usize, max: usize, msg: &str) -> Option<String> { fn progress_status(&self, cur: usize, max: usize, msg: &str) -> Option<String> {
let mut ret = self.progress(cur, max)?; let mut ret = match self.progress(cur, max)? {
// Check only the variant that contains text.
ProgressOutput::TextAndReport(text, _) => text,
_ => return None,
};
self.render(&mut ret, msg); self.render(&mut ret, msg);
Some(ret) Some(ret)
} }
@ -420,6 +568,7 @@ fn test_progress_status() {
style: ProgressStyle::Ratio, style: ProgressStyle::Ratio,
max_print: 40, max_print: 40,
max_width: 60, max_width: 60,
term_integration: TerminalIntegration::new(false),
}; };
assert_eq!( assert_eq!(
format.progress_status(0, 4, ""), format.progress_status(0, 4, ""),
@ -493,6 +642,7 @@ fn test_progress_status_percentage() {
style: ProgressStyle::Percentage, style: ProgressStyle::Percentage,
max_print: 40, max_print: 40,
max_width: 60, max_width: 60,
term_integration: TerminalIntegration::new(false),
}; };
assert_eq!( assert_eq!(
format.progress_status(0, 77, ""), format.progress_status(0, 77, ""),
@ -518,6 +668,7 @@ fn test_progress_status_too_short() {
style: ProgressStyle::Percentage, style: ProgressStyle::Percentage,
max_print: 25, max_print: 25,
max_width: 25, max_width: 25,
term_integration: TerminalIntegration::new(false),
}; };
assert_eq!( assert_eq!(
format.progress_status(1, 1, ""), format.progress_status(1, 1, ""),
@ -528,6 +679,25 @@ fn test_progress_status_too_short() {
style: ProgressStyle::Percentage, style: ProgressStyle::Percentage,
max_print: 24, max_print: 24,
max_width: 24, max_width: 24,
term_integration: TerminalIntegration::new(false),
}; };
assert_eq!(format.progress_status(1, 1, ""), None); assert_eq!(format.progress_status(1, 1, ""), None);
} }
#[test]
fn test_term_integration_disabled() {
let report = TerminalIntegration::new(false);
let mut out = String::new();
out.push_str(&report.remove().to_string());
out.push_str(&report.value(10.0).to_string());
out.push_str(&report.indeterminate().to_string());
assert!(out.is_empty());
}
#[test]
fn test_term_integration_error_state() {
let mut report = TerminalIntegration::new(true);
assert_eq!(report.value(10.0), StatusValue::Value(10.0));
report.error();
assert_eq!(report.value(50.0), StatusValue::Error(50.0));
}

View File

@ -198,6 +198,7 @@ hyperlinks = true # whether cargo inserts links into output
unicode = true # whether cargo can render output using non-ASCII unicode characters unicode = true # whether cargo can render output using non-ASCII unicode characters
progress.when = 'auto' # whether cargo shows progress bar progress.when = 'auto' # whether cargo shows progress bar
progress.width = 80 # width of progress bar progress.width = 80 # width of progress bar
progress.term-integration = true # whether cargo reports progress to terminal emulator
``` ```
## Environment variables ## Environment variables
@ -1361,6 +1362,13 @@ Controls whether or not progress bar is shown in the terminal. Possible values:
Sets the width for progress bar. Sets the width for progress bar.
#### `term.progress.term-integration`
* Type: bool
* Default: auto-detect
* Environment: `CARGO_TERM_PROGRESS_TERM_INTEGRATION`
Report progress to the teminal emulator for display in places like the task bar.
[`cargo bench`]: ../commands/cargo-bench.md [`cargo bench`]: ../commands/cargo-bench.md
[`cargo login`]: ../commands/cargo-login.md [`cargo login`]: ../commands/cargo-login.md
[`cargo logout`]: ../commands/cargo-logout.md [`cargo logout`]: ../commands/cargo-logout.md