OXYGENHUB

Coverage / src/cli/ui.rs 66.1%

113 of 171 lines run, 45 of 117 functions · run #25 on main · run missed

1
#![allow(dead_code)] // A toolkit: not every helper is used yet.
2
//! Terminal presentation: colour, emphasis, and a live spinner while a
3
//! command works. Everything degrades to plain text when stdout is not a
4
//! terminal, when `NO_COLOR` is set, or with `--plain`.
5
6
use console::{style, StyledObject, Term};
7
use indicatif::{ProgressBar, ProgressStyle};
8
use std::sync::atomic::{AtomicBool, Ordering};
9
use std::time::Duration;
10
11
static PLAIN: AtomicBool = AtomicBool::new(false);
12
static UNICODE: AtomicBool = AtomicBool::new(false);
13
static INIT: std::sync::Once = std::sync::Once::new();
14
15
/// Decide once whether to use colour, glyphs, and animation.
16182×
pub fn init(plain_flag: bool) {
17182×
    INIT.call_once(|| {
18164×
        let no_color = std::env::var_os("NO_COLOR").is_some();
19164×
        let tty = Term::stdout().features().is_attended();
20164×
        let plain = plain_flag || no_color || !tty;
21164×
        PLAIN.store(plain, Ordering::Relaxed);
22164×
        console::set_colors_enabled(!plain);
23164×
        let utf8 = ["LC_ALL", "LC_CTYPE", "LANG"]
24164×
            .iter()
25492×
            .filter_map(|k| std::env::var(k).ok())
26164×
            .find(|v| !v.is_empty())
27164×
            .map(|v| v.to_ascii_uppercase().contains("UTF-8") || v.to_ascii_uppercase().contains("UTF8"))
28164×
            .unwrap_or(false);
29164×
        UNICODE.store(!plain && utf8, Ordering::Relaxed);
30164×
    });
31182×
}
32
3312×
pub fn plain() -> bool {
3412×
    PLAIN.load(Ordering::Relaxed)
3512×
}
36
37
/// Whether the terminal takes Unicode glyphs (a UTF-8 locale, not `--plain`).
38425×
pub fn unicode() -> bool {
39425×
    UNICODE.load(Ordering::Relaxed)
40425×
}
41
42
/// A glyph with an ASCII fallback for plain output and non-UTF-8 locales.
43424×
pub fn glyph<'a>(fancy: &'a str, ascii: &'a str) -> &'a str {
44424×
    if unicode() {
450×
        fancy
46
    } else {
47424×
        ascii
48
    }
49424×
}
50
51
/// A finished action: a tick and the verb in green.
52151×
pub fn done<T: std::fmt::Display>(v: T) -> String {
53151×
    format!("{} {}", ok(glyph("✔", "*")), ok(v))
54151×
}
55
56
/// A warning line prefix.
571×
pub fn caution() -> String {
581×
    warn(glyph("⚠", "!"))
591×
}
60
61
/// An informational note prefix.
62118×
pub fn note() -> String {
63118×
    dim(glyph("›", ">"))
64118×
}
65
66
/// A section title.
670×
pub fn section<T: std::fmt::Display>(v: T) -> String {
680×
    format!("{} {}", s(glyph("▍", "#")).cyan(), s(v).bold())
690×
}
70
71
/// `  key   value`, with the key dimmed and padded.
72282×
pub fn kv<T: std::fmt::Display>(key: &str, value: T) -> String {
73282×
    format!("  {} {value}", dim(format!("{key:<8}")))
74282×
}
75
76
/// An author name.
7769×
pub fn author<T: std::fmt::Display>(v: T) -> String {
7869×
    s(v).magenta().to_string()
7969×
}
80
81
/// A bookmark or reference name.
820×
pub fn bookmark<T: std::fmt::Display>(v: T) -> String {
830×
    s(v).green().bold().to_string()
840×
}
85
86
/// The subject line of a message.
87157×
pub fn subject<T: std::fmt::Display>(v: T) -> String {
88157×
    s(v).bold().to_string()
89157×
}
90
91
/// A dimmed separator between the parts of a metadata line.
92150×
pub fn sep() -> String {
93150×
    dim(format!(" {} ", glyph("·", "-")))
94150×
}
95
96
/// Milliseconds since the epoch as a relative time ("3 minutes ago"), or the
97
/// date when older than a month.
987×
pub fn ago(ms: u64) -> String {
997×
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
1007×
    let secs = now.saturating_sub(ms) / 1000;
1017×
    let unit = |n: u64, w: &str| format!("{n} {w}{} ago", if n == 1 { "" } else { "s" });
1027×
    match secs {
1037×
        0..=44 => "just now".into(),
1046×
        45..=89 => "a minute ago".into(),
1055×
        90..=2_699 => unit(secs.div_ceil(60), "minute"),
1064×
        2_700..=5_399 => "an hour ago".into(),
1074×
        5_400..=79_199 => unit(secs.div_ceil(3_600), "hour"),
1083×
        79_200..=129_599 => "yesterday".into(),
1092×
        129_600..=2_591_999 => unit(secs.div_ceil(86_400), "day"),
1101×
        _ => super::format::when(ms).split(' ').next().unwrap_or("").to_string(),
111
    }
1127×
}
113
114
/// The bullet for a log entry: the working copy, a head, or an ordinary change.
1150×
pub fn log_bullet(working_copy: bool, head: bool, conflicted: bool) -> String {
1160×
    if working_copy {
1170×
        s(glyph("◉", "@")).cyan().bold().to_string()
1180×
    } else if conflicted {
1190×
        err(glyph("✱", "!"))
1200×
    } else if head {
1210×
        ok(glyph("●", "*"))
122
    } else {
1230×
        dim(glyph("○", "o"))
124
    }
1250×
}
126
127
/// A status glyph and word for a working-copy change.
1280×
pub fn status_mark(tag: &str) -> String {
1290×
    let (g, a, word) = match tag {
1300×
        "A" => ("✚", "+", "added"),
1310×
        "M" => ("✎", "~", "modified"),
1320×
        "D" => ("✖", "-", "removed"),
1330×
        "X" => ("⚙", "x", "mode"),
1340×
        "C!" => ("‼", "!", "markers"),
1350×
        "R" => ("➜", ">", "renamed"),
1360×
        "V" => ("✔", "r", "resolved"),
1370×
        _ => ("?", "?", "unknown"),
138
    };
1390×
    let text = format!("{} {word:<9}", glyph(g, a));
1400×
    self::tag_style(tag, &text)
1410×
}
142
1430×
fn tag_style(t: &str, text: &str) -> String {
1440×
    match t {
1450×
        "A" | "V" => s(text).green().to_string(),
1460×
        "R" => s(text).cyan().to_string(),
1470×
        "M" | "X" => s(text).yellow().to_string(),
1480×
        "D" | "C!" => s(text).red().to_string(),
1490×
        _ => text.to_string(),
150
    }
1510×
}
152
1531583×
fn s<T: std::fmt::Display>(v: T) -> StyledObject<T> {
1541583×
    style(v)
1551583×
}
156
157
/// A ChangeId prefix.
15848×
pub fn id<T: std::fmt::Display>(v: T) -> String {
15948×
    s(v).cyan().bold().to_string()
16048×
}
161
/// A hash prefix.
162130×
pub fn hash<T: std::fmt::Display>(v: T) -> String {
163130×
    s(v).dim().to_string()
164130×
}
165118×
pub fn path<T: std::fmt::Display>(v: T) -> String {
166118×
    s(v).bold().to_string()
167118×
}
168302×
pub fn ok<T: std::fmt::Display>(v: T) -> String {
169302×
    s(v).green().to_string()
170302×
}
1713×
pub fn warn<T: std::fmt::Display>(v: T) -> String {
1723×
    s(v).yellow().to_string()
1733×
}
17422×
pub fn err<T: std::fmt::Display>(v: T) -> String {
17522×
    s(v).red().bold().to_string()
17622×
}
177734×
pub fn dim<T: std::fmt::Display>(v: T) -> String {
178734×
    s(v).dim().to_string()
179734×
}
1800×
pub fn heading<T: std::fmt::Display>(v: T) -> String {
1810×
    s(v).bold().underlined().to_string()
1820×
}
183
/// A live spinner with a message, shown only on a terminal. Drop or call
184
/// `done` to clear it; `done` prints the final line in its place.
185
pub struct Spinner {
186
    bar: Option<ProgressBar>,
187
}
188
18912×
pub fn spinner(msg: &str) -> Spinner {
19012×
    if plain() {
19112×
        return Spinner { bar: None };
1920×
    }
1930×
    let bar = ProgressBar::new_spinner();
1940×
    bar.set_style(
1950×
        ProgressStyle::with_template("{spinner:.cyan} {msg}")
1960×
            .unwrap()
1970×
            .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", " "]),
198
    );
1990×
    bar.set_message(msg.to_string());
2000×
    bar.enable_steady_tick(Duration::from_millis(80));
2010×
    Spinner { bar: Some(bar) }
20212×
}
203
204
impl Spinner {
205
    /// Replace the spinner with a final line (or print it plainly). The
206
    /// line is printed as given: callers start it with their own mark.
2072×
    pub fn done(self, msg: &str) {
2082×
        if let Some(b) = &self.bar {
2090×
            b.finish_and_clear();
2102×
        }
2112×
        println!("{msg}");
2122×
    }
213
    /// Say what is happening now: the spinner's message, or, with no
214
    /// terminal, a line on stderr, so a long command is never silent.
2150×
    pub fn set(&self, msg: &str) {
2160×
        match &self.bar {
2170×
            Some(b) => b.set_message(msg.to_string()),
2180×
            None => eprintln!("{} {msg}", dim(glyph("›", ">"))),
219
        }
2200×
    }
221
    /// Clear the spinner without printing.
2229×
    pub fn clear(self) {
2239×
        if let Some(b) = &self.bar {
2240×
            b.finish_and_clear();
2259×
        }
2269×
    }
227
}
228
229
#[cfg(test)]
230
mod tests {
231
    use super::*;
232
233
    #[test]
2341×
    fn relative_times() {
2351×
        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
2361×
        assert_eq!(ago(now), "just now");
2371×
        assert_eq!(ago(now - 60_000), "a minute ago");
2381×
        assert_eq!(ago(now - 5 * 60_000), "5 minutes ago");
2391×
        assert_eq!(ago(now - 3 * 3_600_000), "3 hours ago");
2401×
        assert_eq!(ago(now - 25 * 3_600_000), "yesterday");
2411×
        assert_eq!(ago(now - 4 * 86_400_000), "4 days ago");
2421×
        assert_eq!(ago(0), "1970-01-01");
2431×
        assert_eq!(glyph("✔", "*"), if unicode() { "✔" } else { "*" });
2441×
    }
245
}