//! Terminal presentation: colour, emphasis, and a live spinner while a //! command works. Everything degrades to plain text when stdout is not a //! terminal, when `NO_COLOR` is set, or with `--plain`. use console::{style, StyledObject, Term}; use indicatif::{ProgressBar, ProgressStyle}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; static PLAIN: AtomicBool = AtomicBool::new(false); static UNICODE: AtomicBool = AtomicBool::new(false); static INIT: std::sync::Once = std::sync::Once::new(); /// Decide once whether to use colour, glyphs, and animation. pub fn init(plain_flag: bool) { INIT.call_once(|| { let no_color = std::env::var_os("NO_COLOR").is_some(); let tty = Term::stdout().features().is_attended(); let plain = plain_flag || no_color || !tty; PLAIN.store(plain, Ordering::Relaxed); console::set_colors_enabled(!plain); let utf8 = ["LC_ALL", "LC_CTYPE", "LANG"] .iter() .filter_map(|k| std::env::var(k).ok()) .find(|v| !v.is_empty()) .map(|v| { v.to_ascii_uppercase().contains("UTF-8") || v.to_ascii_uppercase().contains("UTF8") }) .unwrap_or(false); UNICODE.store(!plain && utf8, Ordering::Relaxed); }); } pub fn plain() -> bool { PLAIN.load(Ordering::Relaxed) } /// Whether the terminal takes Unicode glyphs (a UTF-8 locale, not `--plain`). pub fn unicode() -> bool { UNICODE.load(Ordering::Relaxed) } /// A glyph with an ASCII fallback for plain output and non-UTF-8 locales. pub fn glyph<'a>(fancy: &'a str, ascii: &'a str) -> &'a str { if unicode() { fancy } else { ascii } } /// A finished action: a tick and the verb in green. pub fn done(v: T) -> String { format!("{} {}", ok(glyph("✔", "*")), ok(v)) } /// A warning line prefix. pub fn caution() -> String { warn(glyph("⚠", "!")) } /// An informational note prefix. pub fn note() -> String { dim(glyph("ℹ", "i")) } /// A section title. pub fn section(v: T) -> String { format!("{} {}", s(glyph("▍", "#")).cyan(), s(v).bold()) } /// ` key value`, with the key dimmed and padded. pub fn kv(key: &str, value: T) -> String { format!(" {} {value}", dim(format!("{key:<8}"))) } /// An author name. pub fn author(v: T) -> String { s(v).magenta().to_string() } /// A bookmark or reference name. pub fn bookmark(v: T) -> String { s(v).green().bold().to_string() } /// The subject line of a message. pub fn subject(v: T) -> String { s(v).bold().to_string() } /// A dimmed separator between the parts of a metadata line. pub fn sep() -> String { dim(format!(" {} ", glyph("·", "-"))) } /// Milliseconds since the epoch as a relative time ("3 minutes ago"), or the /// date when older than a month. pub fn ago(ms: u64) -> String { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let secs = now.saturating_sub(ms) / 1000; let unit = |n: u64, w: &str| format!("{n} {w}{} ago", if n == 1 { "" } else { "s" }); match secs { 0..=44 => "just now".into(), 45..=89 => "a minute ago".into(), 90..=2_699 => unit(secs.div_ceil(60), "minute"), 2_700..=5_399 => "an hour ago".into(), 5_400..=79_199 => unit(secs.div_ceil(3_600), "hour"), 79_200..=129_599 => "yesterday".into(), 129_600..=2_591_999 => unit(secs.div_ceil(86_400), "day"), _ => super::format::when(ms) .split(' ') .next() .unwrap_or("") .to_string(), } } /// The bullet for a log entry: the working copy, a head, or an ordinary change. pub fn log_bullet(working_copy: bool, head: bool, conflicted: bool) -> String { if working_copy { s(glyph("◉", "@")).cyan().bold().to_string() } else if conflicted { err(glyph("✱", "!")) } else if head { ok(glyph("●", "*")) } else { dim(glyph("○", "o")) } } /// A status glyph and word for a working-copy change. pub fn status_mark(tag: &str) -> String { let (g, a, word) = match tag { "A" => ("✚", "+", "added"), "M" => ("✎", "~", "modified"), "D" => ("✖", "-", "removed"), "X" => ("⚙", "x", "mode"), "C!" => ("‼", "!", "markers"), "R" => ("➜", ">", "renamed"), "V" => ("✔", "r", "resolved"), _ => ("?", "?", "unknown"), }; let text = format!("{} {word:<9}", glyph(g, a)); self::tag_style(tag, &text) } fn tag_style(t: &str, text: &str) -> String { match t { "A" | "V" => s(text).green().to_string(), "R" => s(text).cyan().to_string(), "M" | "X" => s(text).yellow().to_string(), "D" | "C!" => s(text).red().to_string(), _ => text.to_string(), } } fn s(v: T) -> StyledObject { style(v) } /// A ChangeId prefix. pub fn id(v: T) -> String { s(v).cyan().bold().to_string() } /// A hash prefix. pub fn hash(v: T) -> String { s(v).dim().to_string() } pub fn path(v: T) -> String { s(v).bold().to_string() } pub fn ok(v: T) -> String { s(v).green().to_string() } pub fn warn(v: T) -> String { s(v).yellow().to_string() } pub fn err(v: T) -> String { s(v).red().bold().to_string() } pub fn dim(v: T) -> String { s(v).dim().to_string() } pub fn heading(v: T) -> String { s(v).bold().underlined().to_string() } /// A live spinner with a message, shown only on a terminal. Drop or call /// `done` to clear it; `done` prints the final line in its place. pub struct Spinner { bar: Option, } pub fn spinner(msg: &str) -> Spinner { if plain() { return Spinner { bar: None }; } let bar = ProgressBar::new_spinner(); bar.set_style( ProgressStyle::with_template("{spinner:.cyan} {msg}") .unwrap() .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✔"]), ); bar.set_message(msg.to_string()); bar.enable_steady_tick(Duration::from_millis(80)); Spinner { bar: Some(bar) } } impl Spinner { /// Replace the spinner with a final line (or print it plainly). pub fn done(self, msg: &str) { match &self.bar { Some(b) => b.finish_with_message(msg.to_string()), None => println!("{msg}"), } } /// Clear the spinner without printing. pub fn clear(self) { if let Some(b) = &self.bar { b.finish_and_clear(); } } } #[cfg(test)] mod tests { use super::*; #[test] fn relative_times() { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as u64; assert_eq!(ago(now), "just now"); assert_eq!(ago(now - 60_000), "a minute ago"); assert_eq!(ago(now - 5 * 60_000), "5 minutes ago"); assert_eq!(ago(now - 3 * 3_600_000), "3 hours ago"); assert_eq!(ago(now - 25 * 3_600_000), "yesterday"); assert_eq!(ago(now - 4 * 86_400_000), "4 days ago"); assert_eq!(ago(0), "1970-01-01"); assert_eq!(glyph("✔", "*"), if unicode() { "✔" } else { "*" }); } }