OXYGENHUB

Coverage / src/cli/ui.rs 85.2%

156 of 183 lines run, 59 of 98 functions · run #427 on main · run missed

1
//! Terminal presentation: colour, emphasis, and a live spinner while a
2
//! command works. Everything degrades to plain text when stdout is not a
3
//! terminal, when `NO_COLOR` is set, or with `--plain`.
4
5
use console::{style, StyledObject, Term};
6
use indicatif::{ProgressBar, ProgressStyle};
7
use std::sync::atomic::{AtomicBool, Ordering};
8
use std::time::Duration;
9
10
static PLAIN: AtomicBool = AtomicBool::new(false);
11
static UNICODE: AtomicBool = AtomicBool::new(false);
12
static INIT: std::sync::Once = std::sync::Once::new();
13
14
/// Decide once whether to use colour, glyphs, and animation.
15209×
pub fn init(plain_flag: bool) {
16209×
    INIT.call_once(|| {
17198×
        let no_color = std::env::var_os("NO_COLOR").is_some();
18198×
        let tty = Term::stdout().features().is_attended();
19198×
        let plain = plain_flag || no_color || !tty;
20198×
        PLAIN.store(plain, Ordering::Relaxed);
21198×
        console::set_colors_enabled(!plain);
22198×
        let utf8 = ["LC_ALL", "LC_CTYPE", "LANG"]
23198×
            .iter()
24594×
            .filter_map(|k| std::env::var(k).ok())
25198×
            .find(|v| !v.is_empty())
26198×
            .map(|v| {
270×
                v.to_ascii_uppercase().contains("UTF-8") || v.to_ascii_uppercase().contains("UTF8")
280×
            })
29198×
            .unwrap_or(false);
30198×
        UNICODE.store(!plain && utf8, Ordering::Relaxed);
31198×
    });
32209×
}
33
3479×
pub fn plain() -> bool {
3579×
    PLAIN.load(Ordering::Relaxed)
3679×
}
37
38
/// Whether the terminal takes Unicode glyphs (a UTF-8 locale, not `--plain`).
39257×
pub fn unicode() -> bool {
40257×
    UNICODE.load(Ordering::Relaxed)
41257×
}
42
43
/// A glyph with an ASCII fallback for plain output and non-UTF-8 locales.
44256×
pub fn glyph<'a>(fancy: &'a str, ascii: &'a str) -> &'a str {
45256×
    if unicode() {
460×
        fancy
47
    } else {
48256×
        ascii
49
    }
50256×
}
51
52
/// A finished action: a tick and the verb in green.
53116×
pub fn done<T: std::fmt::Display>(v: T) -> String {
54116×
    format!("{} {}", ok(glyph("✔", "*")), ok(v))
55116×
}
56
57
/// A warning line prefix.
584×
pub fn caution() -> String {
594×
    warn(glyph("⚠", "!"))
604×
}
61
62
/// An informational note prefix.
6324×
pub fn note() -> String {
6424×
    dim(glyph("›", ">"))
6524×
}
66
67
/// A section title.
687×
pub fn section<T: std::fmt::Display>(v: T) -> String {
697×
    format!("{} {}", s(glyph("▍", "#")).cyan(), s(v).bold())
707×
}
71
72
/// `  key   value`, with the key dimmed and padded.
7377×
pub fn kv<T: std::fmt::Display>(key: &str, value: T) -> String {
7477×
    format!("  {} {value}", dim(format!("{key:<8}")))
7577×
}
76
77
/// An author name.
7836×
pub fn author<T: std::fmt::Display>(v: T) -> String {
7936×
    s(v).magenta().to_string()
8036×
}
81
82
/// A bookmark or reference name.
83101×
pub fn bookmark<T: std::fmt::Display>(v: T) -> String {
84101×
    s(v).green().bold().to_string()
85101×
}
86
87
/// The subject line of a message.
8838×
pub fn subject<T: std::fmt::Display>(v: T) -> String {
8938×
    s(v).bold().to_string()
9038×
}
91
92
/// A dimmed separator between the parts of a metadata line.
9330×
pub fn sep() -> String {
9430×
    dim(format!(" {} ", glyph("·", "-")))
9530×
}
96
97
/// Milliseconds since the epoch as a relative time ("3 minutes ago"), or the
98
/// date when older than a month.
9915×
pub fn ago(ms: u64) -> String {
10015×
    let now = std::time::SystemTime::now()
10115×
        .duration_since(std::time::UNIX_EPOCH)
10215×
        .map(|d| d.as_millis() as u64)
10315×
        .unwrap_or(0);
10415×
    let secs = now.saturating_sub(ms) / 1000;
10515×
    let unit = |n: u64, w: &str| format!("{n} {w}{} ago", if n == 1 { "" } else { "s" });
10615×
    match secs {
10715×
        0..=44 => "just now".into(),
1086×
        45..=89 => "a minute ago".into(),
1095×
        90..=2_699 => unit(secs.div_ceil(60), "minute"),
1104×
        2_700..=5_399 => "an hour ago".into(),
1114×
        5_400..=79_199 => unit(secs.div_ceil(3_600), "hour"),
1123×
        79_200..=129_599 => "yesterday".into(),
1132×
        129_600..=2_591_999 => unit(secs.div_ceil(86_400), "day"),
1141×
        _ => super::format::when(ms)
1151×
            .split(' ')
1161×
            .next()
1171×
            .unwrap_or("")
1181×
            .to_string(),
119
    }
12015×
}
121
122
/// The bullet for a log entry: the working copy, a head, or an ordinary change.
1234×
pub fn log_bullet(working_copy: bool, head: bool, conflicted: bool) -> String {
1244×
    if working_copy {
1251×
        s(glyph("◉", "@")).cyan().bold().to_string()
1263×
    } else if conflicted {
1270×
        err(glyph("✱", "!"))
1283×
    } else if head {
1292×
        ok(glyph("●", "*"))
130
    } else {
1311×
        dim(glyph("○", "o"))
132
    }
1334×
}
134
135
/// A status glyph and word for a working-copy change.
1363×
pub fn status_mark(tag: &str) -> String {
1373×
    let (g, a, word) = match tag {
1383×
        "A" => ("✚", "+", "added"),
1392×
        "M" => ("✎", "~", "modified"),
1401×
        "D" => ("✖", "-", "removed"),
1411×
        "X" => ("⚙", "x", "mode"),
1420×
        "C!" => ("‼", "!", "markers"),
1430×
        "R" => ("➜", ">", "renamed"),
1440×
        "V" => ("✔", "r", "resolved"),
1450×
        _ => ("?", "?", "unknown"),
146
    };
1473×
    let text = format!("{} {word:<9}", glyph(g, a));
1483×
    self::tag_style(tag, &text)
1493×
}
150
1513×
fn tag_style(t: &str, text: &str) -> String {
1523×
    match t {
1533×
        "A" | "V" => s(text).green().to_string(),
1542×
        "R" => s(text).cyan().to_string(),
1552×
        "M" | "X" => s(text).yellow().to_string(),
1560×
        "D" | "C!" => s(text).red().to_string(),
1570×
        _ => text.to_string(),
158
    }
1593×
}
160
1611083×
fn s<T: std::fmt::Display>(v: T) -> StyledObject<T> {
1621083×
    style(v)
1631083×
}
164
165
/// A ChangeId prefix.
166139×
pub fn id<T: std::fmt::Display>(v: T) -> String {
167139×
    s(v).cyan().bold().to_string()
168139×
}
169
/// A hash prefix.
17053×
pub fn hash<T: std::fmt::Display>(v: T) -> String {
17153×
    s(v).dim().to_string()
17253×
}
17348×
pub fn path<T: std::fmt::Display>(v: T) -> String {
17448×
    s(v).bold().to_string()
17548×
}
176269×
pub fn ok<T: std::fmt::Display>(v: T) -> String {
177269×
    s(v).green().to_string()
178269×
}
17910×
pub fn warn<T: std::fmt::Display>(v: T) -> String {
18010×
    s(v).yellow().to_string()
18110×
}
18214×
pub fn err<T: std::fmt::Display>(v: T) -> String {
18314×
    s(v).red().bold().to_string()
18414×
}
185357×
pub fn dim<T: std::fmt::Display>(v: T) -> String {
186357×
    s(v).dim().to_string()
187357×
}
1880×
pub fn heading<T: std::fmt::Display>(v: T) -> String {
1890×
    s(v).bold().underlined().to_string()
1900×
}
191
/// A live spinner with a message, shown only on a terminal. Drop or call
192
/// `done` to clear it; `done` prints the final line in its place.
193
pub struct Spinner {
194
    bar: Option<ProgressBar>,
195
}
196
19779×
pub fn spinner(msg: &str) -> Spinner {
19879×
    if plain() {
19979×
        return Spinner { bar: None };
2000×
    }
2010×
    let bar = ProgressBar::new_spinner();
2020×
    bar.set_style(
2030×
        ProgressStyle::with_template("{spinner:.cyan} {msg}")
2040×
            .unwrap()
2050×
            .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", " "]),
206
    );
2070×
    bar.set_message(msg.to_string());
2080×
    bar.enable_steady_tick(Duration::from_millis(80));
2090×
    Spinner { bar: Some(bar) }
21079×
}
211
212
impl Spinner {
213
    /// Replace the spinner with a final line (or print it plainly). The
214
    /// line is printed as given: callers start it with their own mark.
21551×
    pub fn done(self, msg: &str) {
21651×
        if let Some(b) = &self.bar {
2170×
            b.finish_and_clear();
21851×
        }
21951×
        println!("{msg}");
22051×
    }
221
    /// Say what is happening now: the spinner's message, or, with no
222
    /// terminal, a line on stderr, so a long command is never silent.
2235×
    pub fn set(&self, msg: &str) {
2245×
        match &self.bar {
2250×
            Some(b) => b.set_message(msg.to_string()),
2265×
            None => eprintln!("{} {msg}", dim(glyph("›", ">"))),
227
        }
2285×
    }
229
    /// Clear the spinner without printing.
23026×
    pub fn clear(self) {
23126×
        if let Some(b) = &self.bar {
2320×
            b.finish_and_clear();
23326×
        }
23426×
    }
235
}
236
237
#[cfg(test)]
238
mod tests {
239
    use super::*;
240
241
    #[test]
2421×
    fn relative_times() {
2431×
        let now = std::time::SystemTime::now()
2441×
            .duration_since(std::time::UNIX_EPOCH)
2451×
            .unwrap()
2461×
            .as_millis() as u64;
2471×
        assert_eq!(ago(now), "just now");
2481×
        assert_eq!(ago(now - 60_000), "a minute ago");
2491×
        assert_eq!(ago(now - 5 * 60_000), "5 minutes ago");
2501×
        assert_eq!(ago(now - 3 * 3_600_000), "3 hours ago");
2511×
        assert_eq!(ago(now - 25 * 3_600_000), "yesterday");
2521×
        assert_eq!(ago(now - 4 * 86_400_000), "4 days ago");
2531×
        assert_eq!(ago(0), "1970-01-01");
2541×
        assert_eq!(glyph("✔", "*"), if unicode() { "✔" } else { "*" });
2551×
    }
256
}