From cbd05082547daf4f10044bb2fc8a8eb8696a05d8 Mon Sep 17 00:00:00 2001 From: Alexander Sergeev Date: Sun, 12 May 2024 00:58:56 +0300 Subject: [PATCH] feat: add taskbar progress reporting --- src/cargo/core/compiler/job_queue/mod.rs | 3 +- src/cargo/core/shell.rs | 23 +++ src/cargo/util/context/mod.rs | 5 + src/cargo/util/progress.rs | 180 +++++++++++++++++++++-- src/doc/src/reference/config.md | 22 ++- 5 files changed, 216 insertions(+), 17 deletions(-) diff --git a/src/cargo/core/compiler/job_queue/mod.rs b/src/cargo/core/compiler/job_queue/mod.rs index 7ab12a3a265..b04e6a42bae 100644 --- a/src/cargo/core/compiler/job_queue/mod.rs +++ b/src/cargo/core/compiler/job_queue/mod.rs @@ -854,7 +854,7 @@ impl<'gctx> DrainState<'gctx> { } fn handle_error( - &self, + &mut self, shell: &mut Shell, err_state: &mut ErrorsDuringDrain, new_err: impl Into, @@ -863,6 +863,7 @@ impl<'gctx> DrainState<'gctx> { if new_err.print_always || err_state.count == 0 { crate::display_error(&new_err.error, shell); if err_state.count == 0 && !self.active.is_empty() { + self.progress.indicate_error(); let _ = shell.warn("build failed, waiting for other jobs to finish..."); } err_state.count += 1; diff --git a/src/cargo/core/shell.rs b/src/cargo/core/shell.rs index 174986ee446..67b94718410 100644 --- a/src/cargo/core/shell.rs +++ b/src/cargo/core/shell.rs @@ -56,6 +56,7 @@ impl Shell { stderr_tty: std::io::stderr().is_terminal(), stdout_unicode: supports_unicode(&std::io::stdout()), stderr_unicode: supports_unicode(&std::io::stderr()), + progress_report: supports_progress_report(), }, verbosity: Verbosity::Verbose, needs_clear: false, @@ -286,6 +287,17 @@ impl Shell { } } + pub fn supports_osc9_4(&self) -> bool { + match &self.output { + ShellOut::Write(_) => false, + ShellOut::Stream { + progress_report, + stderr_tty, + .. + } => *progress_report && *stderr_tty, + } + } + /// Gets the current color choice. /// /// If we are not using a color stream, this will always return `Never`, even if the color @@ -426,6 +438,8 @@ enum ShellOut { hyperlinks: bool, stdout_unicode: bool, stderr_unicode: bool, + /// Whether the terminal supports progress notifications via OSC 9;4 sequences + progress_report: bool, }, } @@ -565,6 +579,15 @@ fn supports_unicode(stream: &dyn IsTerminal) -> bool { !stream.is_terminal() || supports_unicode::supports_unicode() } +/// Detects if the terminal supports OSC 9;4 progress notifications. +#[allow(clippy::disallowed_methods)] // ALLOWED: to read terminal app signature +fn supports_progress_report() -> bool { + // Windows Terminal session. + std::env::var("WT_SESSION").is_ok() + // Compatibility with ConEmu's ANSI support. + || std::env::var("ConEmuANSI").ok() == Some("ON".into()) +} + fn supports_hyperlinks() -> bool { #[allow(clippy::disallowed_methods)] // We are reading the state of the system, not config if std::env::var_os("TERM_PROGRAM").as_deref() == Some(std::ffi::OsStr::new("iTerm.app")) { diff --git a/src/cargo/util/context/mod.rs b/src/cargo/util/context/mod.rs index 9824eff9017..cb3bdfb1f8f 100644 --- a/src/cargo/util/context/mod.rs +++ b/src/cargo/util/context/mod.rs @@ -2747,8 +2747,10 @@ pub struct TermConfig { #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct ProgressConfig { + #[serde(default)] pub when: ProgressWhen, pub width: Option, + pub taskbar: Option, } #[derive(Debug, Default, Deserialize)] @@ -2781,10 +2783,12 @@ where "auto" => Ok(Some(ProgressConfig { when: ProgressWhen::Auto, width: None, + taskbar: None, })), "never" => Ok(Some(ProgressConfig { when: ProgressWhen::Never, width: None, + taskbar: None, })), "always" => Err(E::custom("\"always\" progress requires a `width` key")), _ => Err(E::unknown_variant(s, &["auto", "never"])), @@ -2806,6 +2810,7 @@ where if let ProgressConfig { when: ProgressWhen::Always, width: None, + .. } = pc { return Err(serde::de::Error::custom( diff --git a/src/cargo/util/progress.rs b/src/cargo/util/progress.rs index 655fabcd4d9..8600d35eab6 100644 --- a/src/cargo/util/progress.rs +++ b/src/cargo/util/progress.rs @@ -74,6 +74,110 @@ struct Format { style: ProgressStyle, max_width: usize, max_print: usize, + taskbar: TaskbarProgress, +} + +/// Taskbar progressbar +/// +/// Outputs ANSI sequences according to the `Operating system commands`. +struct TaskbarProgress { + enabled: bool, + error: bool, +} + +/// A taskbar progress value printable as ANSI OSC 9;4 escape code +#[cfg_attr(test, derive(PartialEq, Debug))] +enum TaskbarValue { + /// 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 taskbar progress + TextAndTaskbar(String, TaskbarValue), + /// Only taskbar progress, no message and no text progress + Taskbar(TaskbarValue), +} + +impl TaskbarProgress { + #[cfg(test)] + fn new(enabled: bool) -> Self { + Self { + enabled, + error: false, + } + } + + /// Create a `TaskbarProgress` from Cargo's configuration. + /// Autodetect support if not explicitly enabled or disabled. + fn from_config(gctx: &GlobalContext) -> Self { + let enabled = gctx + .progress_config() + .taskbar + .unwrap_or_else(|| gctx.shell().supports_osc9_4()); + + Self { + enabled, + error: false, + } + } + + fn progress_state(&self, value: TaskbarValue) -> TaskbarValue { + match (self.enabled, self.error) { + (true, false) => value, + (true, true) => match value { + TaskbarValue::Value(v) => TaskbarValue::Error(v), + _ => TaskbarValue::Error(100.0), + }, + (false, _) => TaskbarValue::None, + } + } + + pub fn remove(&self) -> TaskbarValue { + self.progress_state(TaskbarValue::Remove) + } + + pub fn value(&self, percent: f64) -> TaskbarValue { + self.progress_state(TaskbarValue::Value(percent)) + } + + pub fn indeterminate(&self) -> TaskbarValue { + self.progress_state(TaskbarValue::Indeterminate) + } + + pub fn error(&mut self) { + self.error = true; + } +} + +impl std::fmt::Display for TaskbarValue { + 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> { @@ -126,6 +230,7 @@ impl<'gctx> Progress<'gctx> { // 50 gives some space for text after the progress bar, // even on narrow (e.g. 80 char) terminals. max_print: 50, + taskbar: TaskbarProgress::from_config(gctx), }, name: name.to_string(), done: false, @@ -223,7 +328,7 @@ impl<'gctx> Progress<'gctx> { /// calling it too often. pub fn print_now(&mut self, msg: &str) -> CargoResult<()> { match &mut self.state { - Some(s) => s.print("", msg), + Some(s) => s.print(ProgressOutput::PrintNow, msg), None => Ok(()), } } @@ -234,6 +339,13 @@ impl<'gctx> Progress<'gctx> { s.clear(); } } + + /// Sets the taskbar progress to the error state. + pub fn indicate_error(&mut self) { + if let Some(s) = &mut self.state { + s.format.taskbar.error() + } + } } impl Throttle { @@ -269,6 +381,7 @@ impl Throttle { impl<'gctx> State<'gctx> { fn tick(&mut self, cur: usize, max: usize, msg: &str) -> CargoResult<()> { if self.done { + write!(self.gctx.shell().err(), "{}", self.format.taskbar.remove())?; return Ok(()); } @@ -280,21 +393,30 @@ impl<'gctx> State<'gctx> { // return back to the beginning of the line for the next print. self.try_update_max_width(); if let Some(pbar) = self.format.progress(cur, max) { - self.print(&pbar, msg)?; + self.print(pbar, msg)?; } Ok(()) } - fn print(&mut self, prefix: &str, msg: &str) -> CargoResult<()> { + fn print(&mut self, progress: ProgressOutput, msg: &str) -> CargoResult<()> { self.throttle.update(); self.try_update_max_width(); + let (mut line, taskbar) = match progress { + ProgressOutput::PrintNow => (String::new(), None), + ProgressOutput::TextAndTaskbar(prefix, taskbar_value) => (prefix, Some(taskbar_value)), + ProgressOutput::Taskbar(taskbar_value) => (String::new(), Some(taskbar_value)), + }; + // make sure we have enough room for the header if self.format.max_width < 15 { + // even if we don't have space we can still output taskbar progress + if let Some(tb) = taskbar { + write!(self.gctx.shell().err(), "{tb}\r")?; + } return Ok(()); } - let mut line = prefix.to_string(); self.format.render(&mut line, msg); while line.len() < self.format.max_width - 15 { line.push(' '); @@ -305,7 +427,11 @@ impl<'gctx> State<'gctx> { let mut shell = self.gctx.shell(); shell.set_needs_clear(false); shell.status_header(&self.name)?; - write!(shell.err(), "{}\r", line)?; + if let Some(tb) = taskbar { + write!(shell.err(), "{line}{tb}\r")?; + } else { + write!(shell.err(), "{line}\r")?; + } self.last_line = Some(line); shell.set_needs_clear(true); } @@ -314,6 +440,8 @@ impl<'gctx> State<'gctx> { } fn clear(&mut self) { + // Always clear the taskbar progress + let _ = write!(self.gctx.shell().err(), "{}", self.format.taskbar.remove()); // No need to clear if the progress is not currently being displayed. if self.last_line.is_some() && !self.gctx.shell().is_cleared() { self.gctx.shell().err_erase_line(); @@ -331,7 +459,7 @@ impl<'gctx> State<'gctx> { } impl Format { - fn progress(&self, cur: usize, max: usize) -> Option { + fn progress(&self, cur: usize, max: usize) -> Option { assert!(cur <= max); // Render the percentage at the far right and then figure how long the // progress bar is @@ -339,11 +467,19 @@ impl Format { let pct = if !pct.is_finite() { 0.0 } else { pct }; let stats = match self.style { ProgressStyle::Percentage => format!(" {:6.02}%", pct * 100.0), - ProgressStyle::Ratio => format!(" {}/{}", cur, max), + ProgressStyle::Ratio => format!(" {cur}/{max}"), ProgressStyle::Indeterminate => String::new(), }; + let taskbar = match self.style { + ProgressStyle::Percentage | ProgressStyle::Ratio => self.taskbar.value(pct * 100.0), + ProgressStyle::Indeterminate => self.taskbar.indeterminate(), + }; + let extra_len = stats.len() + 2 /* [ and ] */ + 15 /* status header */; let Some(display_width) = self.width().checked_sub(extra_len) else { + if self.taskbar.enabled { + return Some(ProgressOutput::Taskbar(taskbar)); + } return None; }; @@ -371,7 +507,7 @@ impl Format { string.push(']'); string.push_str(&stats); - Some(string) + Some(ProgressOutput::TextAndTaskbar(string, taskbar)) } fn render(&self, string: &mut String, msg: &str) { @@ -398,7 +534,11 @@ impl Format { #[cfg(test)] fn progress_status(&self, cur: usize, max: usize, msg: &str) -> Option { - let mut ret = self.progress(cur, max)?; + let mut ret = match self.progress(cur, max)? { + // Check only the variant that contains text + ProgressOutput::TextAndTaskbar(text, _) => text, + _ => return None, + }; self.render(&mut ret, msg); Some(ret) } @@ -420,6 +560,7 @@ fn test_progress_status() { style: ProgressStyle::Ratio, max_print: 40, max_width: 60, + taskbar: TaskbarProgress::new(false), }; assert_eq!( format.progress_status(0, 4, ""), @@ -493,6 +634,7 @@ fn test_progress_status_percentage() { style: ProgressStyle::Percentage, max_print: 40, max_width: 60, + taskbar: TaskbarProgress::new(false), }; assert_eq!( format.progress_status(0, 77, ""), @@ -518,6 +660,7 @@ fn test_progress_status_too_short() { style: ProgressStyle::Percentage, max_print: 25, max_width: 25, + taskbar: TaskbarProgress::new(false), }; assert_eq!( format.progress_status(1, 1, ""), @@ -528,6 +671,25 @@ fn test_progress_status_too_short() { style: ProgressStyle::Percentage, max_print: 24, max_width: 24, + taskbar: TaskbarProgress::new(false), }; assert_eq!(format.progress_status(1, 1, ""), None); } + +#[test] +fn test_taskbar_disabled() { + let taskbar = TaskbarProgress::new(false); + let mut out = String::new(); + out.push_str(&taskbar.remove().to_string()); + out.push_str(&taskbar.value(10.0).to_string()); + out.push_str(&taskbar.indeterminate().to_string()); + assert!(out.is_empty()); +} + +#[test] +fn test_taskbar_error_state() { + let mut progress = TaskbarProgress::new(true); + assert_eq!(progress.value(10.0), TaskbarValue::Value(10.0)); + progress.error(); + assert_eq!(progress.value(50.0), TaskbarValue::Error(50.0)); +} diff --git a/src/doc/src/reference/config.md b/src/doc/src/reference/config.md index 11ac2f9a5dc..1f40676a108 100644 --- a/src/doc/src/reference/config.md +++ b/src/doc/src/reference/config.md @@ -191,13 +191,14 @@ metadata_key1 = "value" metadata_key2 = "value" [term] -quiet = false # whether cargo output is quiet -verbose = false # whether cargo provides verbose output -color = 'auto' # whether cargo colorizes output -hyperlinks = true # whether cargo inserts links into output -unicode = true # whether cargo can render output using non-ASCII unicode characters -progress.when = 'auto' # whether cargo shows progress bar -progress.width = 80 # width of progress bar +quiet = false # whether cargo output is quiet +verbose = false # whether cargo provides verbose output +color = 'auto' # whether cargo colorizes output +hyperlinks = true # whether cargo inserts links into output +unicode = true # whether cargo can render output using non-ASCII unicode characters +progress.when = 'auto' # whether cargo shows progress bar +progress.width = 80 # width of progress bar +progress.taskbar = true # whether cargo reports progress to terminal emulator ``` ## 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. +#### `term.progress.taskbar` +* Type: bool +* Default: auto-detect +* Environment: `CARGO_TERM_PROGRESS_TASKBAR` + +Report progess to the teminal emulator for display in places like the task bar. + [`cargo bench`]: ../commands/cargo-bench.md [`cargo login`]: ../commands/cargo-login.md [`cargo logout`]: ../commands/cargo-logout.md