Skip to main content

xbp_cli/cli/
ui.rs

1use colored::Colorize;
2use indicatif::{ProgressBar, ProgressStyle};
3use std::future::Future;
4use std::time::Duration;
5use supports_color::Stream;
6
7const SPINNER_SETS: &[&[&str]] = &[
8    &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
9    &["◐", "◓", "◑", "◒"],
10    &["▖", "▘", "▝", "▗"],
11    &["∙∙∙", "●∙∙", "∙●∙", "∙∙●", "∙●∙"],
12];
13
14pub struct Loader {
15    pb: ProgressBar,
16    label: String,
17}
18
19impl Loader {
20    pub fn start(label: &str) -> Self {
21        let spinner_frames = select_spinner_set(label);
22        let pb = ProgressBar::new_spinner();
23        let style = ProgressStyle::with_template("{spinner:.cyan} {msg}")
24            .unwrap_or_else(|_| ProgressStyle::default_spinner())
25            .tick_strings(spinner_frames);
26        pb.set_style(style);
27        pb.set_message(format!("{}", label.bright_cyan()));
28        pb.enable_steady_tick(Duration::from_millis(95));
29        Self {
30            pb,
31            label: label.to_string(),
32        }
33    }
34
35    pub fn success(&self) {
36        self.pb
37            .finish_with_message(format!("{} {}", "OK".bright_green().bold(), self.label));
38    }
39
40    pub fn success_with(&self, message: &str) {
41        self.pb
42            .finish_with_message(format!("{} {}", "OK".bright_green().bold(), message));
43    }
44
45    pub fn fail(&self, details: &str) {
46        self.pb.finish_with_message(format!(
47            "{} {} {}",
48            "ERR".bright_red().bold(),
49            self.label,
50            format!("({})", details).bright_black()
51        ));
52    }
53
54    pub fn update(&self, message: &str) {
55        self.pb.set_message(format!("{}", message.bright_cyan()));
56    }
57
58    pub fn log(&self, message: &str) {
59        self.pb.println(message);
60    }
61
62    pub fn suspend<T, F>(&self, op: F) -> T
63    where
64        F: FnOnce() -> T,
65    {
66        self.pb.suspend(op)
67    }
68}
69
70pub async fn with_loader<T, E, F>(label: &str, op: F) -> Result<T, E>
71where
72    E: std::fmt::Display,
73    F: Future<Output = Result<T, E>>,
74{
75    let loader = Loader::start(label);
76    let result = op.await;
77    match &result {
78        Ok(_) => loader.success(),
79        Err(err) => loader.fail(&err.to_string()),
80    }
81    result
82}
83
84pub fn print_cli_header(command: &str, debug: bool) {
85    let mode = if debug {
86        "DEBUG".bright_yellow().bold().to_string()
87    } else {
88        "NORMAL".bright_blue().bold().to_string()
89    };
90    println!(
91        "{} {} {} {}",
92        "xbp".bright_magenta().bold(),
93        "→".bright_black(),
94        command.bright_white().bold(),
95        format!("[{}]", mode).bright_black()
96    );
97}
98
99pub fn configure_color_output() {
100    colored::control::set_override(should_emit_ansi());
101}
102
103pub fn should_emit_ansi() -> bool {
104    // Respect common color control environment variables first.
105    let disable_via_clicolor = std::env::var("CLICOLOR")
106        .map(|value| value == "0")
107        .unwrap_or(false);
108    if std::env::var_os("NO_COLOR").is_some() || disable_via_clicolor {
109        return false;
110    }
111    if std::env::var_os("FORCE_COLOR").is_some() || std::env::var_os("CLICOLOR_FORCE").is_some() {
112        return true;
113    }
114
115    if legacy_cmd_without_color_hints() {
116        return false;
117    }
118
119    let stdout_color = supports_color::on(Stream::Stdout).is_some();
120    let stderr_color = supports_color::on(Stream::Stderr).is_some();
121    stdout_color || stderr_color
122}
123
124pub fn section(title: &str) {
125    println!(
126        "\n{} {}",
127        "◆".bright_blue().bold(),
128        title.bright_blue().bold()
129    );
130}
131
132pub fn divider(width: usize) {
133    println!("{}", "─".repeat(width).bright_black());
134}
135
136pub fn status_line(label: &str, status: &str, ok: bool) {
137    let icon = if ok {
138        "✓".bright_green().bold()
139    } else {
140        "✗".bright_red().bold()
141    };
142    let status = if ok {
143        status.bright_green().to_string()
144    } else {
145        status.bright_red().to_string()
146    };
147    println!("  {} {} {}", icon, label.bright_white(), status);
148}
149
150pub fn tip(message: &str) {
151    println!("{} {}", "Hint:".bright_yellow().bold(), message);
152}
153
154fn select_spinner_set(label: &str) -> &'static [&'static str] {
155    let hash = label
156        .bytes()
157        .fold(0_u64, |acc, b| acc.wrapping_mul(16777619) ^ u64::from(b));
158    let idx = (hash as usize) % SPINNER_SETS.len();
159    SPINNER_SETS[idx]
160}
161
162#[cfg(windows)]
163fn legacy_cmd_without_color_hints() -> bool {
164    let Some(comspec) = std::env::var("ComSpec").ok() else {
165        return false;
166    };
167
168    if !comspec.to_ascii_lowercase().ends_with("cmd.exe") {
169        return false;
170    }
171
172    let has_color_hints = std::env::var_os("WT_SESSION").is_some()
173        || std::env::var_os("ANSICON").is_some()
174        || std::env::var("TERM_PROGRAM").is_ok()
175        || std::env::var("TERM").is_ok_and(|value| value != "dumb")
176        || std::env::var("ConEmuANSI")
177            .map(|value| value.eq_ignore_ascii_case("ON"))
178            .unwrap_or(false);
179
180    !has_color_hints
181}
182
183#[cfg(not(windows))]
184fn legacy_cmd_without_color_hints() -> bool {
185    false
186}
187
188#[cfg(all(test, windows))]
189mod tests {
190    use super::legacy_cmd_without_color_hints;
191    use once_cell::sync::Lazy;
192    use std::sync::Mutex;
193
194    static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
195
196    #[test]
197    fn cmd_without_color_hints_disables_ansi() {
198        let _guard = ENV_LOCK.lock().expect("env lock");
199
200        let keys = [
201            "ComSpec",
202            "WT_SESSION",
203            "ANSICON",
204            "TERM_PROGRAM",
205            "TERM",
206            "ConEmuANSI",
207        ];
208        let snapshot: Vec<(String, Option<String>)> = keys
209            .iter()
210            .map(|key| ((*key).to_string(), std::env::var(key).ok()))
211            .collect();
212
213        std::env::set_var("ComSpec", r"C:\Windows\System32\cmd.exe");
214        for key in [
215            "WT_SESSION",
216            "ANSICON",
217            "TERM_PROGRAM",
218            "TERM",
219            "ConEmuANSI",
220        ] {
221            std::env::remove_var(key);
222        }
223
224        assert!(legacy_cmd_without_color_hints());
225
226        std::env::set_var("WT_SESSION", "1");
227        assert!(!legacy_cmd_without_color_hints());
228
229        for (key, value) in snapshot {
230            match value {
231                Some(value) => std::env::set_var(key, value),
232                None => std::env::remove_var(key),
233            }
234        }
235    }
236}