diff --git a/CHANGELOG.md b/CHANGELOG.md index f183427..2b13904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - `--output-file` on a path that already exists says so and suggests a new path, instead of showing an OS error; any other refusal keeps its cause and gets the same hint. Both say that no change was sent. - `-o` is short for `--output`, as in `voltage wallets list -o json`. - Color on a terminal: help, `error:`, `hint:`, and `details:` labels. `--color auto|always|never` (or `--no-color`) decides, else `NO_COLOR`, `CLICOLOR_FORCE`, and `CLICOLOR`. JSON is never colored. +- An animated spinner on interactive stderr, with the time waited after two seconds; `VOLTAGE_SPINNER=0` keeps it still. A payment wait on a terminal reads as a timeline: one line per status change and a closing `✓` or `✗` line. ## 0.2.0 - 2026-10-22 diff --git a/docs/quality.md b/docs/quality.md index a2efaee..ae4c92a 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -53,9 +53,10 @@ not precedent. Reviews must approve and document exceptions. `auth`. Keep settings, credential storage, and private files in `config`. Keep payment values and payment read views in `payment`. Keep secret text in `secret`. Keep result envelopes, redaction, and rendering, including the - table layout in `output::human`, in `output`. Keep prompts, pickers, progress, - notices, the terminal's width, and scrubbing of untrusted text bound for the - terminal in `terminal`. Keep which commands ask for missing scope, and in what + table layout in `output::human`, in `output`. Keep prompts, pickers, notices, the + terminal's width, and scrubbing of untrusted text bound for the terminal in + `terminal`; the animated status line in `progress`; a payment wait's status + lines in `timeline`; and the color palette and symbols in `style`. Keep which commands ask for missing scope, and in what order, in `pick`. Keep the default `--currency` of a payment, read from its wallet's line of credit, in `wallet_currency`. Keep exit-code categories and typed error detail in `error`. diff --git a/docs/reference/environment.md b/docs/reference/environment.md index f9aa246..ef8bd33 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -14,6 +14,7 @@ A flag beats a variable. A selected profile is a complete configuration, so `VOL | `VOLTAGE_CONFIG_DIR` | The configuration directory, as an absolute path | `--config-dir` | | `XDG_CONFIG_HOME` | Parent of the default configuration directory, `$XDG_CONFIG_HOME/voltage` (else `~/.config/voltage`) | `--config-dir`, `VOLTAGE_CONFIG_DIR` | | `COLUMNS` | Terminal width for human tables; otherwise the terminal's own width | — | +| `VOLTAGE_SPINNER` | `0` or `false` draws progress as a still line instead of an animated spinner, for screen readers, which read every redraw | — | | `NO_COLOR` | Any non-empty value turns off color in human output and help | `--color` | | `CLICOLOR_FORCE` | Any non-zero value colors human output and help even when redirected | `--color`, `NO_COLOR` | | `CLICOLOR` | `0` turns off color on a terminal | `--color`, `NO_COLOR`, `CLICOLOR_FORCE` | diff --git a/docs/reference/output.md b/docs/reference/output.md index 560c811..94b3bed 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -25,7 +25,9 @@ API fields stay inside `data`. With `--all`, JSON output collects the pages into ## Streams -Diagnostics, prompts, and progress go to stderr. Interactive stderr shows immediate, cancellable status for network requests and waits, with the time waited so far on payment waits and device approval; redirected stderr gets no spinner/control codes. `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs. +Diagnostics, prompts, and progress go to stderr. Interactive stderr shows an animated spinner for network requests and waits, with the time waited so far on payment waits and device approval and on any wait past two seconds; it is cut to the terminal's width and cleared when the wait ends or is cancelled. `VOLTAGE_SPINNER=0` (or `false`) or `TERM=dumb` draws it as a still line; a Windows console without ANSI support shows none. Redirected stderr gets no spinner or control codes. + +On interactive stderr a payment wait reads as a timeline: `› Payment ID accepted; waiting for completed`, one `› status · time` line each time the status changes, and a closing `✓ Payment ID completed · 4s` or `✗ Payment ID failed · 4s` before the error. Redirected stderr keeps the plain `Payment ID status: sending; waiting for completed.` lines, repeated every 15 seconds while the status holds. Symbols are Unicode when the locale is UTF-8 and `TERM` is not `dumb` or `linux` (on Windows, in Windows Terminal), and ASCII otherwise (`ok`, `x`, `>`). `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs. Human output and help are colored on a terminal: `error:` is red, `hint:` cyan, and `details:` dim, and color only marks a label, so the words read the same without it. `--color always|never` (or `--no-color`) decides; otherwise `NO_COLOR`, `CLICOLOR_FORCE`, `CLICOLOR`, `TERM=dumb`, and whether each stream is a terminal decide, separately for stdout and stderr. JSON, NDJSON, `--output-file`, and completions are never colored, even with `--color always`. diff --git a/src/all_orgs.rs b/src/all_orgs.rs index 4ca5438..6267031 100644 --- a/src/all_orgs.rs +++ b/src/all_orgs.rs @@ -20,7 +20,6 @@ use crate::{ config::{Credential, Scope}, error::{ErrorDetail, ErrorKind}, output::{Envelope, Outcome, Output}, - terminal::Terminal, }; use futures_util::{StreamExt, stream}; use serde::Serialize; @@ -89,7 +88,7 @@ pub async fn wallets( let organizations = organizations(&listed); let token = &login.access_token; // The exchanges run side by side, so only the one line below shows progress. - let quiet = Terminal::new(true, global.no_input); + let quiet = global.terminal().quieted(); let reads = stream::iter(&organizations) .map(|organization| async move { let scope = Scope { diff --git a/src/api.rs b/src/api.rs index 5a86d53..5f7bf88 100644 --- a/src/api.rs +++ b/src/api.rs @@ -18,12 +18,11 @@ use crate::{ error::{CredentialSources, ErrorDetail, ErrorKind, ResponseLimit}, input::{PaginationMode, Request}, output::{Envelope, Outcome, Output}, - payment::{ - Currency, PaymentDirection, PaymentView, ReceiveKind, StatusText, WaitProgress, WaitTarget, - }, + payment::{Currency, PaymentDirection, PaymentView, ReceiveKind, WaitProgress, WaitTarget}, registry::{AuthScheme, Method, Operation, OperationId, operation}, secret::Secret, - terminal::{self, Terminal}, + terminal::Terminal, + timeline::Timeline, }; use futures_util::StreamExt; use serde::{Deserialize, Deserializer, Serialize}; @@ -49,8 +48,6 @@ const READY_POLL_START: Duration = Duration::from_millis(100); const COMPLETION_POLL_START: Duration = Duration::from_millis(250); /// A checkout session projection usually appears within this long. const SESSION_POLL_START: Duration = Duration::from_millis(100); -/// Waits repeat their status notice at least this often. -const STATUS_NOTICE_INTERVAL: Duration = Duration::from_secs(15); /// The one read that reconciles an interrupted payment submission. const RECONCILE_TIMEOUT: Duration = Duration::from_secs(5); const JOURNAL_DIR: &str = "requests"; @@ -678,7 +675,7 @@ pub async fn organization_wallets( let request = Request::build(invocation, &scope, None)?; let credential = ApiCredential::OrganizationToken(token); let mut api = Api::new(global, None, Authorization::Account(credential, None))?; - api.terminal = Terminal::new(true, global.no_input); + api.terminal = global.terminal().quieted(); Ok(api .send(Method::Get, &request.path, &request.query, None) .await? @@ -1313,10 +1310,8 @@ impl Call<'_> { scope.single_env()? ); let target = until.as_str(); - if invocation.operation.method != Method::Get { - api.terminal - .important(format!("Payment {id} accepted; waiting for {target}.")); - } + let accepted = invocation.operation.method != Method::Get; + let mut timeline = Timeline::start(api.terminal, id, target, accepted); let mut backoff = Backoff::new( match until { WaitTarget::Ready => READY_POLL_START, @@ -1326,21 +1321,10 @@ impl Call<'_> { ); let mut pause = backoff.pause(); let mut invoice_presented = false; - let mut last_status: Option = None; - let mut last_notice = Instant::now() - STATUS_NOTICE_INTERVAL; - let started = Instant::now(); loop { let view = PaymentView::from_body(&response.body); - if let Some(status) = &view.status - && (Some(status) != last_status.as_ref() - || last_notice.elapsed() >= STATUS_NOTICE_INTERVAL) - { - api.terminal.notice(format!( - "Payment {id} status: {}; waiting for {target}.", - status.as_str() - )); - last_status = Some(status.clone()); - last_notice = Instant::now(); + if let Some(status) = &view.status { + timeline.status(status); } if invocation.presents_invoice() && !invoice_presented @@ -1355,6 +1339,7 @@ impl Call<'_> { } match view.progress(until) { WaitProgress::Unsuccessful => { + timeline.unsuccessful(view.status.as_ref()); return Err(Error::api("Payment reached an unsuccessful terminal state") .with_detail(ErrorDetail::Payment(response.body)) .redacted(&api.secrets())); @@ -1369,6 +1354,7 @@ impl Call<'_> { api.terminal, )?; } + timeline.reached(outcome); return self.out.write( Envelope::new(Some(response.status), response.body, Some(id), outcome), &api.secrets(), @@ -1383,11 +1369,9 @@ impl Call<'_> { .with_detail(ErrorDetail::pending(id))); } api.terminal - .during( - format!( - "Waiting for the payment to be {target}... {}", - terminal::elapsed(started.elapsed()) - ), + .waiting( + format!("Waiting for the payment to be {target}..."), + timeline.started, tokio::time::sleep(pause), ) .await; diff --git a/src/auth.rs b/src/auth.rs index 12875dd..5d4e12a 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -15,7 +15,7 @@ use crate::{ }, error::{CredentialRejection, CredentialSources, ErrorDetail, ResponseLimit}, secret::Secret, - terminal::{self, Terminal}, + terminal::Terminal, }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -275,11 +275,9 @@ impl AuthService { return Err(Error::timeout("Login expired; run voltage login again")); } self.terminal - .during( - format!( - "Waiting for device approval... {}", - terminal::elapsed(started.elapsed()) - ), + .waiting( + "Waiting for device approval...", + started, tokio::time::sleep(Duration::from_secs(interval)), ) .await; diff --git a/src/cli.rs b/src/cli.rs index 6fd34d5..7d3fe78 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -140,7 +140,7 @@ impl GlobalFlags { } pub fn terminal(&self) -> Terminal { - Terminal::new(self.quiet, self.no_input) + Terminal::new(self.quiet, self.no_input).with_color(self.color()) } pub fn scope_selection(&self) -> ScopeSelection { diff --git a/src/lib.rs b/src/lib.rs index 46dee1b..20f057e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,11 +17,13 @@ mod output; mod payment; mod pick; mod price; +mod progress; pub mod registry; pub mod secret; mod startup; mod style; mod terminal; +mod timeline; mod wallet_currency; mod wallet_shortlist; diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 0000000..f1264f1 --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,268 @@ +//! The status line on an interactive stderr: a spinner, a label, and the time waited, redrawn +//! until its guard drops, which clears it. Without animation the label is drawn once. + +use crate::{ + style::{Glyphs, Role, paint}, + terminal, +}; +use std::{ + io::Write, + sync::{Arc, Mutex, PoisonError}, + time::Duration, +}; +use tokio::{task::JoinHandle, time::Instant}; + +const FRAME: Duration = Duration::from_millis(100); +/// A quick wait shows no time; one the caller times shows it from the start. +const SHOW_WAITED_AFTER: Duration = Duration::from_secs(2); + +/// How the status line is drawn on this stderr. +#[derive(Clone, Copy, Debug)] +pub struct Look { + pub animate: bool, + pub color: bool, + pub glyphs: Glyphs, +} + +/// Clears its status line when dropped, including when Ctrl-C cancels the owning future. +#[must_use = "progress is cleared as soon as the guard is dropped"] +pub struct Progress { + line: Option>, + redraw: Option>, +} + +impl Progress { + pub fn hidden() -> Self { + Self { + line: None, + redraw: None, + } + } + + /// Draw `label` now and, when animated, every frame after. `since` starts the time of a + /// wait that spans several status lines. + pub fn show(label: String, since: Option, look: Look) -> Self { + Self::start(label, since, look, to_stderr) + } + + /// `show`, writing each frame with `write`. + fn start(label: String, since: Option, look: Look, write: fn(&[u8])) -> Self { + let line = Arc::new(StatusLine { + label, + since: since.unwrap_or_else(Instant::now), + timed: since.is_some(), + look, + // Read once: where crossterm cannot open `/dev/tty`, each read runs `tput`. + width: terminal::stderr_width(), + write, + cleared: Mutex::new(false), + }); + line.draw(0); + // Outside a runtime the line stays drawn but still. + let redraw = look + .animate + .then(tokio::runtime::Handle::try_current) + .and_then(Result::ok) + .map(|runtime| runtime.spawn(redraw(Arc::clone(&line)))); + Self { + line: Some(line), + redraw, + } + } +} + +impl Drop for Progress { + fn drop(&mut self) { + if let Some(redraw) = self.redraw.take() { + redraw.abort(); + } + if let Some(line) = &self.line { + line.clear(); + } + } +} + +async fn redraw(line: Arc) { + let mut frames = tokio::time::interval_at(Instant::now() + FRAME, FRAME); + // Runs until the guard aborts it; the frame number only picks a glyph, so it may wrap. + let mut frame: usize = 0; + loop { + frames.tick().await; + frame = frame.wrapping_add(1); + line.draw(frame); + } +} + +struct StatusLine { + label: String, + since: Instant, + timed: bool, + look: Look, + /// The terminal's width when the line was first drawn. + width: Option, + /// Where frames and the clear go: stderr, except in tests. + write: fn(&[u8]), + /// Set once the line is cleared. A redraw already running when the guard drops checks it + /// under the lock, so no frame can follow the clear. + cleared: Mutex, +} + +impl StatusLine { + fn draw(&self, frame: usize) { + let cleared = self.cleared.lock().unwrap_or_else(PoisonError::into_inner); + if *cleared { + return; + } + let text = self.text(frame, self.since.elapsed(), self.width); + (self.write)(format!("\r\x1b[2K{text}").as_bytes()); + } + + fn clear(&self) { + let mut cleared = self.cleared.lock().unwrap_or_else(PoisonError::into_inner); + *cleared = true; + (self.write)(b"\r\x1b[2K"); + } + + /// The line for one frame, cut to fit `width` so clearing never leaves a wrapped remnant. + fn text(&self, frame: usize, waited: Duration, width: Option) -> String { + let label = if self.timed || waited >= SHOW_WAITED_AFTER { + format!("{} {}", self.label, terminal::elapsed(waited)) + } else { + self.label.clone() + }; + if !self.look.animate { + return fit(&label, width); + } + let frames = self.look.glyphs.frames; + let glyph = frames[frame % frames.len()]; + let label = fit( + &label, + width.map(|width| width.saturating_sub(glyph.chars().count() + 1)), + ); + if self.look.color { + format!("{} {label}", paint(Role::Pending, glyph)) + } else { + format!("{glyph} {label}") + } + } +} + +/// A frame or clear on stderr; a failed write has nowhere to be reported. +fn to_stderr(bytes: &[u8]) { + let mut stderr = std::io::stderr().lock(); + let _ = stderr.write_all(bytes); + let _ = stderr.flush(); +} + +/// `text` cut to leave the last of `width` columns free, so the cursor never wraps. +fn fit(text: &str, width: Option) -> String { + match width { + Some(width) if text.chars().count() >= width => { + text.chars().take(width.saturating_sub(1)).collect() + } + _ => text.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(timed: bool, look: Look) -> StatusLine { + StatusLine { + label: "Waiting...".into(), + since: Instant::now(), + timed, + look, + width: None, + write: |_| {}, + cleared: Mutex::new(false), + } + } + + const STILL: Look = Look { + animate: false, + color: false, + glyphs: Glyphs::UNICODE, + }; + const SPINNING: Look = Look { + animate: true, + ..STILL + }; + + #[test] + fn frames_advance_and_wrap_with_the_label_after_the_glyph() { + let spinner = line(false, SPINNING); + let short = Duration::from_millis(300); + assert_eq!(spinner.text(0, short, None), "⠋ Waiting..."); + assert_eq!(spinner.text(1, short, None), "⠙ Waiting..."); + assert_eq!(spinner.text(10, short, None), "⠋ Waiting..."); + let ascii = line( + false, + Look { + glyphs: Glyphs::ASCII, + ..SPINNING + }, + ); + assert_eq!(ascii.text(5, short, None), "\\ Waiting..."); + let colored = line( + false, + Look { + color: true, + ..SPINNING + }, + ); + let text = colored.text(0, short, None); + assert!(text.starts_with(&paint(Role::Pending, "⠋")), "{text:?}"); + assert!(text.ends_with(" Waiting...")); + } + + #[test] + fn time_shows_from_two_seconds_or_from_the_start_of_a_timed_wait() { + let quick = line(false, STILL); + assert_eq!( + quick.text(0, Duration::from_millis(1999), None), + "Waiting..." + ); + assert_eq!(quick.text(0, Duration::from_secs(2), None), "Waiting... 2s"); + let timed = line(true, STILL); + assert_eq!(timed.text(0, Duration::ZERO, None), "Waiting... 0s"); + assert_eq!( + timed.text(0, Duration::from_secs(65), None), + "Waiting... 1m 05s" + ); + } + + #[test] + fn a_line_never_reaches_the_last_column() { + let still = line(false, STILL); + assert_eq!(still.text(0, Duration::ZERO, Some(8)), "Waiting"); + assert_eq!(still.text(0, Duration::ZERO, Some(11)), "Waiting..."); + assert_eq!(still.text(0, Duration::ZERO, Some(10)), "Waiting.."); + let spinner = line(false, SPINNING); + assert_eq!(spinner.text(0, Duration::ZERO, Some(8)), "⠋ Waiti"); + assert_eq!(fit("abc", Some(0)), ""); + assert_eq!(fit("abc", None), "abc"); + } + + #[tokio::test] + async fn dropping_the_guard_stops_the_redraw_and_clears_once() { + let progress = Progress::start("x".into(), None, SPINNING, |_| {}); + let redraw = progress + .redraw + .as_ref() + .map(JoinHandle::abort_handle) + .unwrap(); + let line = Arc::clone(progress.line.as_ref().unwrap()); + drop(progress); + assert!(*line.cleared.lock().unwrap()); + for _ in 0..10 { + tokio::task::yield_now().await; + } + assert!(redraw.is_finished()); + // A still line never starts a redraw. + let still = Progress::start("x".into(), None, STILL, |_| {}); + assert!(still.redraw.is_none()); + assert!(Progress::hidden().line.is_none()); + } +} diff --git a/src/style.rs b/src/style.rs index e2c790d..1ae4eaf 100644 --- a/src/style.rs +++ b/src/style.rs @@ -6,6 +6,7 @@ //! plain, so every escape sequence the CLI writes comes from this palette. Machine-readable //! output never passes through here. +use crate::terminal::scrub_line; use clap::{ ValueEnum, builder::{ @@ -70,6 +71,17 @@ impl ColorChoice { } } + /// Whether text written straight to stderr, outside an anstream writer, may carry styles. + pub fn colors_stderr(self) -> bool { + match self { + Self::Always => true, + Self::Never => false, + Self::Auto => { + anstream::AutoStream::choice(&std::io::stderr()) == anstream::ColorChoice::Always + } + } + } + fn as_stream(self) -> anstream::ColorChoice { match self { Self::Auto => anstream::ColorChoice::Auto, @@ -88,6 +100,10 @@ pub enum Role { Hint, /// `details:`, which introduces lines a person may skip Muted, + /// `✓` and a completed status + Success, + /// The spinner and in-flight statuses such as `sending` + Pending, } impl Role { @@ -96,18 +112,88 @@ impl Role { Self::Failure => AnsiColor::Red.on_default().bold(), Self::Hint => AnsiColor::Cyan.on_default().bold(), Self::Muted => Style::new().dimmed(), + Self::Success => AnsiColor::Green.on_default(), + Self::Pending => AnsiColor::Yellow.on_default(), } } } +/// One line of terminal text built only from painted labels and scrubbed text, so its only +/// escape sequences are the palette's. +#[derive(Debug, Default)] +pub struct Line(String); + +impl Line { + pub fn paint(mut self, role: Role, label: &'static str) -> Self { + self.0.push_str(&paint(role, label)); + self + } + + /// Untrusted or variable text, with every unsafe character made a space. + pub fn text(mut self, text: &str) -> Self { + self.0.push_str(&scrub_line(text)); + self + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Status symbols: Unicode where the terminal can draw it, ASCII otherwise. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Glyphs { + pub done: &'static str, + pub failed: &'static str, + pub step: &'static str, + /// Separates a status line's facts, as in `completed · 4s`. + pub separator: &'static str, + pub frames: &'static [&'static str], +} + +impl Glyphs { + pub const UNICODE: Self = Self { + done: "✓", + failed: "✗", + step: "›", + separator: "·", + frames: &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + }; + pub const ASCII: Self = Self { + done: "ok", + failed: "x", + step: ">", + separator: "-", + frames: &["-", "\\", "|", "/"], + }; + + /// Unicode on Windows Terminal, or where `TERM` is not `dumb` or `linux` and the locale + /// that applies (the first set of `LC_ALL`, `LC_CTYPE`, `LANG`) is UTF-8. + pub fn detect(var: impl Fn(&str) -> Option) -> Self { + let unicode = if cfg!(windows) { + var("WT_SESSION").is_some() + } else { + !matches!(var("TERM").as_deref(), Some("dumb" | "linux")) + && ["LC_ALL", "LC_CTYPE", "LANG"] + .into_iter() + .find_map(|name| var(name).filter(|value| !value.is_empty())) + .is_some_and(|locale| { + let locale = locale.to_ascii_lowercase(); + locale.contains("utf-8") || locale.contains("utf8") + }) + }; + if unicode { Self::UNICODE } else { Self::ASCII } + } +} + /// `label` wrapped in its role's style. Labels are fixed text, never API or user data. pub fn paint(role: Role, label: &'static str) -> String { let style = role.style(); format!("{style}{label}{style:#}") } -/// Write `text` to stderr, keeping its styles only when `choice` allows color there. A -/// stderr write failure has nowhere to be reported, so it is ignored. +/// Write `text` to stderr, keeping its styles only when `choice` allows color there; anstream +/// strips them otherwise. A stderr write failure has nowhere to be reported, so it is ignored. pub fn write_stderr(choice: ColorChoice, text: &str) { let mut stderr = anstream::AutoStream::new(std::io::stderr().lock(), choice.as_stream()); let _ = stderr.write_all(text.as_bytes()); @@ -165,13 +251,66 @@ mod tests { #[test] fn a_painted_label_is_the_label_once_styles_are_stripped() { - for role in [Role::Failure, Role::Hint, Role::Muted] { + for role in [ + Role::Failure, + Role::Hint, + Role::Muted, + Role::Success, + Role::Pending, + ] { let painted = paint(role, "error:"); assert!(painted.starts_with('\u{1b}') && painted.ends_with("\u{1b}[0m")); assert_eq!(anstream::adapter::strip_str(&painted).to_string(), "error:"); } } + #[test] + fn lines_scrub_text_and_paint_labels() { + let line = Line::default() + .paint(Role::Success, "✓") + .text(" Payment \u{1b}[2Jx\ncompleted"); + assert_eq!( + anstream::adapter::strip_str(line.as_str()).to_string(), + "✓ Payment [2Jx completed" + ); + assert_eq!(line.as_str().matches('\u{1b}').count(), 2); + } + + #[test] + fn glyphs_are_unicode_only_for_a_utf8_locale_on_a_capable_terminal() { + let detect = |vars: &[(&str, &str)]| { + Glyphs::detect(|name| { + vars.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| (*value).to_owned()) + }) + }; + if cfg!(windows) { + assert_eq!(detect(&[("WT_SESSION", "1")]), Glyphs::UNICODE); + assert_eq!(detect(&[("LANG", "en_US.UTF-8")]), Glyphs::ASCII); + return; + } + assert_eq!(detect(&[("LANG", "en_US.UTF-8")]), Glyphs::UNICODE); + assert_eq!(detect(&[("LC_CTYPE", "C.utf8")]), Glyphs::UNICODE); + assert_eq!(detect(&[]), Glyphs::ASCII); + assert_eq!(detect(&[("LANG", "C")]), Glyphs::ASCII); + // LC_ALL overrides LANG, and an empty value is unset. + assert_eq!( + detect(&[("LC_ALL", "C"), ("LANG", "en_US.UTF-8")]), + Glyphs::ASCII + ); + assert_eq!( + detect(&[("LC_ALL", ""), ("LANG", "en_US.UTF-8")]), + Glyphs::UNICODE + ); + for term in ["dumb", "linux"] { + assert_eq!( + detect(&[("TERM", term), ("LANG", "en_US.UTF-8")]), + Glyphs::ASCII + ); + } + } + #[test] fn choices_map_one_to_one_onto_clap_and_anstream() { for (choice, clap, stream) in [ diff --git a/src/terminal.rs b/src/terminal.rs index 9d9e9a6..0a4b7c3 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1,27 +1,59 @@ //! Terminal interaction policy: prompts, progress, and diagnostics on stderr. Results and //! errors are written by `output`. -use crate::{Error, Result, secret::Secret}; +use crate::{ + Error, Result, + progress::{Look, Progress}, + secret::Secret, + style::{self, ColorChoice, Glyphs, Line}, +}; use inquire::{InquireError, Select, Text}; use std::{ fmt::{self, Display}, io::{ErrorKind, IsTerminal, Write}, time::Duration, }; -use tokio::sync::oneshot; +use tokio::{sync::oneshot, time::Instant}; use uuid::Uuid; -/// What the process may show to and ask of the person at the terminal, from `--quiet` and -/// `--no-input`. +/// What the process may show to and ask of the person at the terminal, from `--quiet`, +/// `--no-input`, and `--color`. #[derive(Clone, Copy)] pub struct Terminal { quiet: bool, no_input: bool, + color: ColorChoice, } impl Terminal { pub fn new(quiet: bool, no_input: bool) -> Self { - Self { quiet, no_input } + Self { + quiet, + no_input, + color: ColorChoice::Auto, + } + } + + pub fn with_color(self, color: ColorChoice) -> Self { + Self { color, ..self } + } + + /// The same terminal with optional progress and notices hidden. + pub fn quieted(self) -> Self { + Self { + quiet: true, + ..self + } + } + + /// Status lines get symbols and color only on an interactive stderr; redirected stderr + /// keeps plain sentences for logs. + pub fn decorates(self) -> bool { + std::io::stderr().is_terminal() + } + + pub fn glyphs(self) -> Glyphs { + Glyphs::detect(|name| std::env::var(name).ok()) } /// A prompt needs an interactive stdin and must not be disabled by `--no-input`. @@ -32,12 +64,7 @@ impl Terminal { /// Optional status for a human, cleared when the guard drops; no control codes on /// redirected stderr. pub fn progress(self, label: impl Display) -> Progress { - let active = !self.quiet && std::io::stderr().is_terminal(); - if active { - eprint!("{label}"); - let _ = std::io::stderr().flush(); - } - Progress { active } + self.status(label, None) } /// Show `label` while `work` runs. The status cannot outlive the work: it clears on @@ -47,6 +74,30 @@ impl Terminal { work.await } + /// `during` for one step of a longer wait: the line shows the time since `since`. + pub async fn waiting( + self, + label: impl Display, + since: Instant, + work: impl Future, + ) -> T { + let _progress = self.status(label, Some(since)); + work.await + } + + fn status(self, label: impl Display, since: Option) -> Progress { + if self.quiet || !self.decorates() || !draws_control_codes() { + return Progress::hidden(); + } + let look = Look { + animate: animates(|name| std::env::var(name).ok()), + color: self.color.colors_stderr(), + glyphs: self.glyphs(), + }; + // Labels are fixed text today; scrubbing keeps every escape the line's own. + Progress::show(scrub_line(&label.to_string()), since, look) + } + /// Optional information that `--quiet` suppresses. pub fn notice(self, message: impl Display) { if !self.quiet { @@ -59,6 +110,18 @@ impl Terminal { eprintln!("{}", scrub_text(&message.to_string())); } + /// `notice` for a line with symbols or color. + pub fn notice_line(self, line: Line) { + if !self.quiet { + self.important_line(line); + } + } + + /// `important` for a line with symbols or color. + pub fn important_line(self, line: Line) { + style::write_stderr(self.color, &format!("{}\n", line.as_str())); + } + /// A picker also draws on stderr, so it needs both streams to be interactive. pub fn can_pick(self) -> bool { self.can_prompt() && std::io::stderr().is_terminal() @@ -180,6 +243,15 @@ impl Display for Choice { /// size while stdout is interactive. crossterm falls back to running `tput` when the size /// query fails. `None` means output is not width-limited. pub fn width() -> Option { + width_when(std::io::stdout().is_terminal()) +} + +/// `width` for stderr, where status lines go. +pub fn stderr_width() -> Option { + width_when(std::io::stderr().is_terminal()) +} + +fn width_when(interactive: bool) -> Option { if let Some(columns) = std::env::var("COLUMNS") .ok() .and_then(|value| value.parse::().ok()) @@ -187,7 +259,7 @@ pub fn width() -> Option { { return Some(columns); } - if !std::io::stdout().is_terminal() { + if !interactive { return None; } crossterm::terminal::size() @@ -298,18 +370,24 @@ pub fn elapsed(duration: Duration) -> String { } } -/// Clears its status line when dropped, including when Ctrl-C cancels the owning future. -#[must_use = "progress is cleared as soon as the guard is dropped"] -pub struct Progress { - active: bool, +/// A spinner redraws its line; `TERM=dumb` cannot clear one, and `VOLTAGE_SPINNER=0` or +/// `false` asks for a still line, as screen readers read every redraw. +fn animates(var: impl Fn(&str) -> Option) -> bool { + let still = var("VOLTAGE_SPINNER") + .is_some_and(|value| value == "0" || value.eq_ignore_ascii_case("false")); + var("TERM").as_deref() != Some("dumb") && !still } -impl Drop for Progress { - fn drop(&mut self) { - if self.active { - eprint!("\r\x1b[2K"); - let _ = std::io::stderr().flush(); - } +/// A status line clears itself with control codes. A Windows console without ANSI support +/// would print them, so it gets no status line; crossterm turns ANSI on where it can. +fn draws_control_codes() -> bool { + #[cfg(windows)] + { + crossterm::ansi_support::supports_ansi() + } + #[cfg(not(windows))] + { + true } } @@ -318,6 +396,26 @@ mod tests { use super::*; use crate::error::ErrorKind as Exit; + #[test] + fn a_dumb_terminal_or_voltage_spinner_0_keeps_progress_still() { + let animates_with = |vars: &[(&str, &str)]| { + animates(|name| { + vars.iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| (*value).to_owned()) + }) + }; + assert!(animates_with(&[])); + assert!(animates_with(&[ + ("TERM", "xterm"), + ("VOLTAGE_SPINNER", "1") + ])); + assert!(!animates_with(&[("TERM", "dumb")])); + assert!(!animates_with(&[("VOLTAGE_SPINNER", "0")])); + assert!(!animates_with(&[("VOLTAGE_SPINNER", "FALSE")])); + assert!(animates_with(&[("VOLTAGE_SPINNER", "off")])); + } + #[test] fn a_picker_answer_becomes_a_choice_nothing_an_interruption_or_a_transport_error() { let id = Uuid::nil(); diff --git a/src/timeline.rs b/src/timeline.rs new file mode 100644 index 0000000..d4b1e3b --- /dev/null +++ b/src/timeline.rs @@ -0,0 +1,169 @@ +//! What a payment wait tells the person at the terminal. An interactive stderr gets one line +//! per status change and a closing line with a symbol; redirected stderr keeps plain +//! sentences, repeated while the status holds, so logs read as they always have. + +use crate::{ + output::Outcome, + payment::{PaymentStatus, StatusText}, + style::{Line, Role}, + terminal::{self, Terminal}, +}; +use std::time::Duration; +use tokio::time::Instant; +use uuid::Uuid; + +/// Redirected stderr repeats an unchanged status this often, so a log shows the wait is alive. +const NOTICE_INTERVAL: Duration = Duration::from_secs(15); + +pub struct Timeline { + terminal: Terminal, + id: Uuid, + target: &'static str, + pub started: Instant, + last: Option, + last_notice: Option, +} + +impl Timeline { + /// Begin a wait for `target`; `accepted` announces that the payment was just submitted. + pub fn start(terminal: Terminal, id: Uuid, target: &'static str, accepted: bool) -> Self { + let timeline = Self { + terminal, + id, + target, + started: Instant::now(), + last: None, + last_notice: None, + }; + if accepted { + if terminal.decorates() { + terminal.important_line( + timeline + .step() + .text(&format!("Payment {id} accepted; waiting for {target}")), + ); + } else { + terminal.important(format!("Payment {id} accepted; waiting for {target}.")); + } + } + timeline + } + + /// Report a polled status: a new line when it changes, and on redirected stderr also + /// when it has held for a while. On a terminal a final status is left to the closing line. + pub fn status(&mut self, status: &StatusText) { + let changed = self.last.as_ref() != Some(status); + if self.terminal.decorates() { + if changed && !is_final(status) { + let line = with_status(self.step(), status); + self.terminal.notice_line(self.waited(line)); + } + } else if changed + || self + .last_notice + .is_none_or(|noticed| noticed.elapsed() >= NOTICE_INTERVAL) + { + self.terminal.notice(format!( + "Payment {} status: {}; waiting for {}.", + self.id, + status.as_str(), + self.target + )); + self.last_notice = Some(Instant::now()); + } + self.last = Some(status.clone()); + } + + /// The wait reached its target. + pub fn reached(&self, outcome: Outcome) { + if self.terminal.decorates() { + let glyphs = self.terminal.glyphs(); + let line = Line::default() + .paint(Role::Success, glyphs.done) + .text(&format!(" Payment {} ", self.id)) + .paint(Role::Success, outcome.as_str()); + self.terminal.notice_line(self.waited(line)); + } + } + + /// The payment failed or expired; the error report follows. The report already names + /// the payment and its state, so this line is optional and `--quiet` hides it. + pub fn unsuccessful(&self, status: Option<&StatusText>) { + if self.terminal.decorates() { + let glyphs = self.terminal.glyphs(); + let line = Line::default() + .paint(Role::Failure, glyphs.failed) + .text(&format!(" Payment {} ", self.id)); + let line = match status { + Some(status) => with_status(line, status), + None => line.text("did not succeed"), + }; + self.terminal.notice_line(self.waited(line)); + } + } + + fn step(&self) -> Line { + Line::default() + .paint(Role::Hint, self.terminal.glyphs().step) + .text(" ") + } + + fn waited(&self, line: Line) -> Line { + let separator = self.terminal.glyphs().separator; + line.text(&format!( + " {separator} {}", + terminal::elapsed(self.started.elapsed()) + )) + } +} + +/// A known status in its color; text the API added later stays plain. +fn with_status(line: Line, status: &StatusText) -> Line { + match status { + StatusText::Known(known) => line.paint(status_role(*known), known.as_str()), + StatusText::Unknown(text) => line.text(text), + } +} + +fn is_final(status: &StatusText) -> bool { + matches!( + status, + StatusText::Known( + PaymentStatus::Completed | PaymentStatus::Failed | PaymentStatus::Expired + ) + ) +} + +fn status_role(status: PaymentStatus) -> Role { + match status { + PaymentStatus::Completed => Role::Success, + PaymentStatus::Failed | PaymentStatus::Expired => Role::Failure, + PaymentStatus::Sending + | PaymentStatus::Receiving + | PaymentStatus::Approved + | PaymentStatus::Generating => Role::Pending, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn statuses_are_colored_by_meaning_and_unknown_text_stays_plain() { + assert_eq!(status_role(PaymentStatus::Completed), Role::Success); + assert_eq!(status_role(PaymentStatus::Expired), Role::Failure); + assert_eq!(status_role(PaymentStatus::Failed), Role::Failure); + assert_eq!(status_role(PaymentStatus::Sending), Role::Pending); + assert!(is_final(&StatusText::Known(PaymentStatus::Expired))); + assert!(!is_final(&StatusText::Known(PaymentStatus::Receiving))); + assert!(!is_final(&StatusText::Unknown("completed later".into()))); + let known = with_status(Line::default(), &StatusText::Known(PaymentStatus::Sending)); + assert_eq!( + known.as_str(), + crate::style::paint(Role::Pending, "sending") + ); + let unknown = with_status(Line::default(), &StatusText::Unknown("new\u{1b}[2J".into())); + assert_eq!(unknown.as_str(), "new [2J"); + } +} diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 7466dc7..cb81858 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -5064,7 +5064,13 @@ async fn on_a_terminal_with( let observer = controller.try_clone().unwrap(); let before = local_modes(&observer); let mut command = human_process_at(dir, &server.uri()); + // A UTF-8 color terminal, whatever the test runner's own, so symbols are predictable. command + .env("TERM", "xterm-256color") + .env("LANG", "C.UTF-8") + .env_remove("LC_ALL") + .env_remove("LC_CTYPE") + .env_remove("VOLTAGE_SPINNER") .env_remove("VOLTAGE_EXECUTE") .args(args) .stdin(Stdio::from(terminal.try_clone().unwrap())) @@ -5660,11 +5666,107 @@ async fn a_payment_wait_on_a_terminal_shows_how_long_it_has_waited() { ) .await; assert_eq!(run.code, Some(0), "{}", run.shown); + let shown = strip_styles(&run.shown); assert!( - run.shown - .contains("Waiting for the payment to be completed... 0s"), + shown.contains("⠋ Waiting for the payment to be completed... 0s"), + "{shown}" + ); + // Each status gets one line, and the wait ends with a symbol; color marks only words. + assert!(shown.contains("› sending · 0s"), "{shown}"); + assert!( + shown.contains(&format!("✓ Payment {RESOURCE} completed · ")), + "{shown}" + ); + assert!(!shown.contains("status: sending"), "{shown}"); + assert!(run.shown.contains("\u{1b}[32m✓"), "{:?}", run.shown); + server.verify().await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_submitted_payment_on_a_terminal_reads_as_a_timeline() { + let (server, dir) = fixture().await; + respond_once(&server, route("POST", payments_path()), accepted()).await; + route("GET", payment_path()) + .respond_with(in_order(vec![ + ok(json!({"id": RESOURCE, "status": "receiving"})), + ok(json!({"id": RESOURCE, "status": "receiving"})), + ok(json!({"id": RESOURCE, "status": "completed"})), + ])) + .expect(3) + .mount(&server) + .await; + let args = receive_args(&["--wallet", WALLET, "--wait", "completed", "--execute"]); + let run = on_a_terminal(dir.path(), &server, &args, &[]).await; + assert_eq!(run.code, Some(0), "{}", run.shown); + let shown = strip_styles(&run.shown); + let accepted = shown + .find(&format!( + "› Payment {RESOURCE} accepted; waiting for completed\r\n" + )) + .expect(&shown); + let receiving = shown.find("› receiving · ").expect(&shown); + let done = shown + .find(&format!("✓ Payment {RESOURCE} completed · ")) + .expect(&shown); + assert!(accepted < receiving && receiving < done, "{shown}"); + // An unchanged status is not repeated on a terminal. + assert_eq!(shown.matches("› receiving").count(), 1, "{shown}"); + server.verify().await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_failed_payment_on_a_terminal_ends_with_a_cross_before_the_error() { + let (server, dir) = fixture().await; + route("GET", payment_path()) + .respond_with(ok(json!({"id": RESOURCE, "status": "failed"}))) + .expect(2) + .mount(&server) + .await; + let args = [ + "payments", + "get", + RESOURCE, + "--org", + ORG, + "--env", + ENV, + "--wait", + "completed", + "--color", + "never", + "--output", + "table", + ]; + // `--quiet` hides the closing line; the error, which names the payment, remains. + let quiet = on_a_terminal( + dir.path(), + &server, + &[&args[..], &["--quiet"]].concat(), + &[], + ) + .await; + assert_eq!(quiet.code, Some(1), "{}", quiet.shown); + assert!(!quiet.shown.contains("✗ Payment"), "{}", quiet.shown); + assert!( + quiet.shown.contains("error: Payment reached"), "{}", - run.shown + quiet.shown + ); + let run = on_a_terminal(dir.path(), &server, &args, &[]).await; + assert_eq!(run.code, Some(1), "{}", run.shown); + // Without color, only the status line's own clearing codes remain. + let shown = run.shown.replace("\r\u{1b}[2K", ""); + assert!(!shown.contains('\u{1b}'), "{shown:?}"); + // The closing line reports a final status, so it gets no step line of its own. + assert!(!shown.contains("› failed"), "{shown}"); + let failed = shown + .find(&format!("✗ Payment {RESOURCE} failed · 0s")) + .expect(&shown); + assert!( + shown[failed..].contains("error: Payment reached"), + "{shown}" ); server.verify().await; }