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    /// Finish the spinner so a determinate bar can take over the next line.
70    pub fn finish_for_handoff(&self) {
71        self.pb.finish_and_clear();
72    }
73}
74
75/// Left-to-right determinate progress bar (tqdm-style) for multi-item work
76/// like secrets push / pull.
77pub struct TaskBar {
78    pb: ProgressBar,
79    label: String,
80}
81
82impl TaskBar {
83    pub fn start(label: &str, total: u64) -> Self {
84        let pb = ProgressBar::new(total.max(1));
85        let style = ProgressStyle::with_template(
86            "{spinner:.cyan} {msg} [{bar:32.cyan/blue}] {pos}/{len} ({percent}%)",
87        )
88        .unwrap_or_else(|_| ProgressStyle::default_bar())
89        .progress_chars("█▓░")
90        .tick_strings(select_spinner_set(label));
91        pb.set_style(style);
92        pb.set_message(format!("{}", label.bright_cyan()));
93        pb.enable_steady_tick(Duration::from_millis(95));
94        if total == 0 {
95            pb.set_position(0);
96        }
97        Self {
98            pb,
99            label: label.to_string(),
100        }
101    }
102
103    pub fn set_position(&self, completed: u64) {
104        self.pb.set_position(completed);
105    }
106
107    pub fn set_message(&self, message: &str) {
108        self.pb.set_message(format!("{}", message.bright_cyan()));
109    }
110
111    pub fn tick(&self, completed: u64, message: &str) {
112        self.set_message(message);
113        self.set_position(completed);
114    }
115
116    pub fn success_with(&self, message: &str) {
117        self.pb
118            .finish_with_message(format!("{} {}", "OK".bright_green().bold(), message));
119    }
120
121    pub fn fail(&self, details: &str) {
122        self.pb.finish_with_message(format!(
123            "{} {} {}",
124            "ERR".bright_red().bold(),
125            self.label,
126            format!("({})", details).bright_black()
127        ));
128    }
129}
130
131pub async fn with_loader<T, E, F>(label: &str, op: F) -> Result<T, E>
132where
133    E: std::fmt::Display,
134    F: Future<Output = Result<T, E>>,
135{
136    let loader = Loader::start(label);
137    let result = op.await;
138    match &result {
139        Ok(_) => loader.success(),
140        Err(err) => loader.fail(&err.to_string()),
141    }
142    result
143}
144
145pub fn print_cli_header(command: &str, debug: bool) {
146    let mode = if debug {
147        "DEBUG".bright_yellow().bold().to_string()
148    } else {
149        "NORMAL".bright_blue().bold().to_string()
150    };
151    println!(
152        "{} {} {} {}",
153        "xbp".bright_magenta().bold(),
154        "→".bright_black(),
155        command.bright_white().bold(),
156        format!("[{}]", mode).bright_black()
157    );
158}
159
160pub fn configure_color_output() {
161    colored::control::set_override(should_emit_ansi());
162}
163
164pub fn should_emit_ansi() -> bool {
165    // Respect common color control environment variables first.
166    let disable_via_clicolor = std::env::var("CLICOLOR")
167        .map(|value| value == "0")
168        .unwrap_or(false);
169    if std::env::var_os("NO_COLOR").is_some() || disable_via_clicolor {
170        return false;
171    }
172    if std::env::var_os("FORCE_COLOR").is_some() || std::env::var_os("CLICOLOR_FORCE").is_some() {
173        return true;
174    }
175
176    if legacy_cmd_without_color_hints() {
177        return false;
178    }
179
180    let stdout_color = supports_color::on(Stream::Stdout).is_some();
181    let stderr_color = supports_color::on(Stream::Stderr).is_some();
182    stdout_color || stderr_color
183}
184
185pub fn section(title: &str) {
186    println!(
187        "\n{} {}",
188        "◆".bright_blue().bold(),
189        title.bright_blue().bold()
190    );
191}
192
193pub fn divider(width: usize) {
194    println!("{}", "─".repeat(width).bright_black());
195}
196
197pub fn status_line(label: &str, status: &str, ok: bool) {
198    let icon = if ok {
199        "✓".bright_green().bold()
200    } else {
201        "✗".bright_red().bold()
202    };
203    let status = if ok {
204        status.bright_green().to_string()
205    } else {
206        status.bright_red().to_string()
207    };
208    println!("  {} {} {}", icon, label.bright_white(), status);
209}
210
211pub fn tip(message: &str) {
212    println!("{} {}", "Hint:".bright_yellow().bold(), message);
213}
214
215fn select_spinner_set(label: &str) -> &'static [&'static str] {
216    let hash = label
217        .bytes()
218        .fold(0_u64, |acc, b| acc.wrapping_mul(16777619) ^ u64::from(b));
219    let idx = (hash as usize) % SPINNER_SETS.len();
220    SPINNER_SETS[idx]
221}
222
223#[cfg(windows)]
224fn legacy_cmd_without_color_hints() -> bool {
225    let Some(comspec) = std::env::var("ComSpec").ok() else {
226        return false;
227    };
228
229    if !comspec.to_ascii_lowercase().ends_with("cmd.exe") {
230        return false;
231    }
232
233    let has_color_hints = std::env::var_os("WT_SESSION").is_some()
234        || std::env::var_os("ANSICON").is_some()
235        || std::env::var("TERM_PROGRAM").is_ok()
236        || std::env::var("TERM").is_ok_and(|value| value != "dumb")
237        || std::env::var("ConEmuANSI")
238            .map(|value| value.eq_ignore_ascii_case("ON"))
239            .unwrap_or(false);
240
241    !has_color_hints
242}
243
244#[cfg(not(windows))]
245fn legacy_cmd_without_color_hints() -> bool {
246    false
247}
248
249#[cfg(all(test, windows))]
250mod tests {
251    use super::legacy_cmd_without_color_hints;
252    use once_cell::sync::Lazy;
253    use std::sync::Mutex;
254
255    static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
256
257    #[test]
258    fn cmd_without_color_hints_disables_ansi() {
259        let _guard = ENV_LOCK.lock().expect("env lock");
260
261        let keys = [
262            "ComSpec",
263            "WT_SESSION",
264            "ANSICON",
265            "TERM_PROGRAM",
266            "TERM",
267            "ConEmuANSI",
268        ];
269        let snapshot: Vec<(String, Option<String>)> = keys
270            .iter()
271            .map(|key| ((*key).to_string(), std::env::var(key).ok()))
272            .collect();
273
274        std::env::set_var("ComSpec", r"C:\Windows\System32\cmd.exe");
275        for key in [
276            "WT_SESSION",
277            "ANSICON",
278            "TERM_PROGRAM",
279            "TERM",
280            "ConEmuANSI",
281        ] {
282            std::env::remove_var(key);
283        }
284
285        assert!(legacy_cmd_without_color_hints());
286
287        std::env::set_var("WT_SESSION", "1");
288        assert!(!legacy_cmd_without_color_hints());
289
290        for (key, value) in snapshot {
291            match value {
292                Some(value) => std::env::set_var(key, value),
293                None => std::env::remove_var(key),
294            }
295        }
296    }
297}