OXYGENHUB
# d8c086ca3eb2 oxygen / src / cli / ui.rs
7.3 KiB · Rust · last changed Rename detection: a removed and an added file at least 50% alike become a Move of the existing node, on snapshots and Git import; Renamed status (spec 0.6o) 1 month ago source blame history raw
135212de Andy McCall ↶ 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);
94084087 Andy McCall ↶ 11
static UNICODE: AtomicBool = AtomicBool::new(false);
135212de Andy McCall ↶ 12
static INIT: std::sync::Once = std::sync::Once::new();
13
94084087 Andy McCall ↶ 14
/// Decide once whether to use colour, glyphs, and animation.
135212de Andy McCall ↶ 15
pub fn init(plain_flag: bool) {
16
    INIT.call_once(|| {
17
        let no_color = std::env::var_os("NO_COLOR").is_some();
18
        let tty = Term::stdout().features().is_attended();
19
        let plain = plain_flag || no_color || !tty;
20
        PLAIN.store(plain, Ordering::Relaxed);
21
        console::set_colors_enabled(!plain);
94084087 Andy McCall ↶ 22
        let utf8 = ["LC_ALL", "LC_CTYPE", "LANG"]
23
            .iter()
24
            .filter_map(|k| std::env::var(k).ok())
25
            .find(|v| !v.is_empty())
26
            .map(|v| v.to_ascii_uppercase().contains("UTF-8") || v.to_ascii_uppercase().contains("UTF8"))
27
            .unwrap_or(false);
28
        UNICODE.store(!plain && utf8, Ordering::Relaxed);
135212de Andy McCall ↶ 29
    });
30
}
31
32
pub fn plain() -> bool {
33
    PLAIN.load(Ordering::Relaxed)
34
}
35
94084087 Andy McCall ↶ 36
/// Whether the terminal takes Unicode glyphs (a UTF-8 locale, not `--plain`).
37
pub fn unicode() -> bool {
38
    UNICODE.load(Ordering::Relaxed)
39
}
40
41
/// A glyph with an ASCII fallback for plain output and non-UTF-8 locales.
42
pub fn glyph<'a>(fancy: &'a str, ascii: &'a str) -> &'a str {
43
    if unicode() { fancy } else { ascii }
44
}
45
46
/// A finished action: a tick and the verb in green.
47
pub fn done<T: std::fmt::Display>(v: T) -> String {
48
    format!("{} {}", ok(glyph("✔", "*")), ok(v))
49
}
50
51
/// A warning line prefix.
52
pub fn caution() -> String {
53
    warn(glyph("⚠", "!"))
54
}
55
56
/// An informational note prefix.
57
pub fn note() -> String {
58
    dim(glyph("ℹ", "i"))
59
}
60
61
/// A section title.
62
pub fn section<T: std::fmt::Display>(v: T) -> String {
63
    format!("{} {}", s(glyph("▍", "#")).cyan(), s(v).bold())
64
}
65
66
/// `  key   value`, with the key dimmed and padded.
67
pub fn kv<T: std::fmt::Display>(key: &str, value: T) -> String {
68
    format!("  {} {value}", dim(format!("{key:<8}")))
69
}
70
71
/// An author name.
72
pub fn author<T: std::fmt::Display>(v: T) -> String {
73
    s(v).magenta().to_string()
74
}
75
76
/// A bookmark or reference name.
77
pub fn bookmark<T: std::fmt::Display>(v: T) -> String {
78
    s(v).green().bold().to_string()
79
}
80
81
/// The subject line of a message.
82
pub fn subject<T: std::fmt::Display>(v: T) -> String {
83
    s(v).bold().to_string()
84
}
85
86
/// A dimmed separator between the parts of a metadata line.
87
pub fn sep() -> String {
88
    dim(format!(" {} ", glyph("·", "-")))
89
}
90
91
/// Milliseconds since the epoch as a relative time ("3 minutes ago"), or the
92
/// date when older than a month.
93
pub fn ago(ms: u64) -> String {
94
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
95
    let secs = now.saturating_sub(ms) / 1000;
96
    let unit = |n: u64, w: &str| format!("{n} {w}{} ago", if n == 1 { "" } else { "s" });
97
    match secs {
98
        0..=44 => "just now".into(),
99
        45..=89 => "a minute ago".into(),
100
        90..=2_699 => unit(secs.div_ceil(60), "minute"),
101
        2_700..=5_399 => "an hour ago".into(),
102
        5_400..=79_199 => unit(secs.div_ceil(3_600), "hour"),
103
        79_200..=129_599 => "yesterday".into(),
104
        129_600..=2_591_999 => unit(secs.div_ceil(86_400), "day"),
105
        _ => super::format::when(ms).split(' ').next().unwrap_or("").to_string(),
106
    }
107
}
108
109
/// The bullet for a log entry: the working copy, a head, or an ordinary change.
110
pub fn log_bullet(working_copy: bool, head: bool, conflicted: bool) -> String {
111
    if working_copy {
112
        s(glyph("◉", "@")).cyan().bold().to_string()
113
    } else if conflicted {
114
        err(glyph("✱", "!"))
115
    } else if head {
116
        ok(glyph("●", "*"))
117
    } else {
118
        dim(glyph("○", "o"))
119
    }
120
}
121
122
/// The vertical rail joining log entries.
123
pub fn rail() -> String {
124
    dim(glyph("│", "|"))
125
}
126
127
/// A status glyph and word for a working-copy change.
128
pub fn status_mark(tag: &str) -> String {
129
    let (g, a, word) = match tag {
130
        "A" => ("✚", "+", "added"),
131
        "M" => ("✎", "~", "modified"),
132
        "D" => ("✖", "-", "removed"),
133
        "X" => ("⚙", "x", "mode"),
134
        "C!" => ("‼", "!", "markers"),
6e723b81 Andy McCall ↶ 135
        "R" => ("➜", ">", "renamed"),
136
        "V" => ("✔", "r", "resolved"),
94084087 Andy McCall ↶ 137
        _ => ("?", "?", "unknown"),
138
    };
139
    let text = format!("{} {word:<9}", glyph(g, a));
140
    self::tag_style(tag, &text)
141
}
142
143
fn tag_style(t: &str, text: &str) -> String {
144
    match t {
6e723b81 Andy McCall ↶ 145
        "A" | "V" => s(text).green().to_string(),
146
        "R" => s(text).cyan().to_string(),
94084087 Andy McCall ↶ 147
        "M" | "X" => s(text).yellow().to_string(),
148
        "D" | "C!" => s(text).red().to_string(),
149
        _ => text.to_string(),
150
    }
151
}
152
135212de Andy McCall ↶ 153
fn s<T: std::fmt::Display>(v: T) -> StyledObject<T> {
154
    style(v)
155
}
156
157
/// A ChangeId prefix.
158
pub fn id<T: std::fmt::Display>(v: T) -> String {
159
    s(v).cyan().bold().to_string()
160
}
161
/// A hash prefix.
162
pub fn hash<T: std::fmt::Display>(v: T) -> String {
163
    s(v).dim().to_string()
164
}
165
pub fn path<T: std::fmt::Display>(v: T) -> String {
166
    s(v).bold().to_string()
167
}
168
pub fn ok<T: std::fmt::Display>(v: T) -> String {
169
    s(v).green().to_string()
170
}
171
pub fn warn<T: std::fmt::Display>(v: T) -> String {
172
    s(v).yellow().to_string()
173
}
174
pub fn err<T: std::fmt::Display>(v: T) -> String {
175
    s(v).red().bold().to_string()
176
}
177
pub fn dim<T: std::fmt::Display>(v: T) -> String {
178
    s(v).dim().to_string()
179
}
180
pub fn heading<T: std::fmt::Display>(v: T) -> String {
181
    s(v).bold().underlined().to_string()
182
}
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
189
pub fn spinner(msg: &str) -> Spinner {
190
    if plain() {
191
        return Spinner { bar: None };
192
    }
193
    let bar = ProgressBar::new_spinner();
194
    bar.set_style(ProgressStyle::with_template("{spinner:.cyan} {msg}").unwrap().tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✔"]));
195
    bar.set_message(msg.to_string());
196
    bar.enable_steady_tick(Duration::from_millis(80));
197
    Spinner { bar: Some(bar) }
198
}
199
200
impl Spinner {
201
    /// Replace the spinner with a final line (or print it plainly).
202
    pub fn done(self, msg: &str) {
203
        match &self.bar {
204
            Some(b) => b.finish_with_message(msg.to_string()),
205
            None => println!("{msg}"),
206
        }
207
    }
208
    /// Clear the spinner without printing.
209
    pub fn clear(self) {
210
        if let Some(b) = &self.bar {
211
            b.finish_and_clear();
212
        }
213
    }
214
}
94084087 Andy McCall ↶ 215
216
#[cfg(test)]
217
mod tests {
218
    use super::*;
219
220
    #[test]
221
    fn relative_times() {
222
        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
223
        assert_eq!(ago(now), "just now");
224
        assert_eq!(ago(now - 60_000), "a minute ago");
225
        assert_eq!(ago(now - 5 * 60_000), "5 minutes ago");
226
        assert_eq!(ago(now - 3 * 3_600_000), "3 hours ago");
227
        assert_eq!(ago(now - 25 * 3_600_000), "yesterday");
228
        assert_eq!(ago(now - 4 * 86_400_000), "4 days ago");
229
        assert_eq!(ago(0), "1970-01-01");
230
        assert_eq!(glyph("✔", "*"), if unicode() { "✔" } else { "*" });
231
    }
232
}