Skip to main content

stacksdapp_shell/
steps.rs

1//! Shared spinner steps and banner chrome for  -style CLI output.
2
3use colored::Colorize;
4use std::io::{self, Write};
5use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
6use std::sync::{Arc, Mutex, OnceLock};
7use std::thread::{self, JoinHandle};
8use std::time::Duration;
9
10const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
11
12/// Serializes human stdout so child-process logs cannot fight the live spinner.
13fn stdout_lock() -> &'static Mutex<()> {
14    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
15    LOCK.get_or_init(|| Mutex::new(()))
16}
17
18/// How many live spinners are currently active (for `\r` line clearing).
19static ACTIVE_SPINNERS: AtomicUsize = AtomicUsize::new(0);
20
21/// Print a human line without corrupting an active spinner row.
22pub fn println_human_safe(line: impl AsRef<str>) {
23    if crate::is_quiet() {
24        return;
25    }
26    let _guard = stdout_lock().lock().unwrap_or_else(|e| e.into_inner());
27    if ACTIVE_SPINNERS.load(Ordering::SeqCst) > 0 {
28        print!("\r\x1b[2K");
29    }
30    println!("{}", line.as_ref());
31    let _ = io::stdout().flush();
32}
33
34/// In-place spinner that occupies the checkmark column until [`LiveStep::finish`].
35pub struct LiveStep {
36    label: String,
37    stop: Arc<AtomicBool>,
38    handle: Option<JoinHandle<()>>,
39    finished: bool,
40}
41
42impl LiveStep {
43    pub fn finish(mut self) {
44        self.complete(true);
45    }
46
47    pub fn fail(mut self) {
48        self.complete(false);
49    }
50
51    fn complete(&mut self, ok: bool) {
52        if self.finished {
53            return;
54        }
55        self.finished = true;
56        self.stop.store(true, Ordering::SeqCst);
57        if let Some(h) = self.handle.take() {
58            let _ = h.join();
59        }
60        let _guard = stdout_lock().lock().unwrap_or_else(|e| e.into_inner());
61        ACTIVE_SPINNERS.fetch_sub(1, Ordering::SeqCst);
62        print!("\r\x1b[2K");
63        if ok {
64            println!(
65                "{} {}",
66                "✓".truecolor(52, 211, 153).bold(),
67                self.label.white()
68            );
69        } else {
70            println!(
71                "{} {}",
72                "✗".truecolor(239, 68, 68).bold(),
73                self.label.white()
74            );
75        }
76        let _ = io::stdout().flush();
77    }
78}
79
80impl Drop for LiveStep {
81    fn drop(&mut self) {
82        if !self.finished {
83            self.complete(false);
84        }
85    }
86}
87
88/// Print a centered banner between grey rules.
89pub fn print_banner(title: &str) {
90    if crate::is_quiet() {
91        return;
92    }
93    println!();
94    println!("{}", "━".repeat(46).truecolor(75, 85, 99));
95    println!("{:^46}", title.bold().white());
96    println!("{}", "━".repeat(46).truecolor(75, 85, 99));
97    println!();
98}
99
100pub fn kv(key: &str, value: &str) {
101    if crate::is_quiet() {
102        return;
103    }
104    println!("{:<12} {}", key.truecolor(156, 163, 175), value.white());
105}
106
107pub fn rule() {
108    if crate::is_quiet() {
109        return;
110    }
111    println!("{}", "─".repeat(46).truecolor(75, 85, 99));
112}
113
114pub fn begin_step(label: &str) -> LiveStep {
115    if crate::is_quiet() {
116        return LiveStep {
117            label: label.to_string(),
118            stop: Arc::new(AtomicBool::new(true)),
119            handle: None,
120            finished: true,
121        };
122    }
123
124    let stop = Arc::new(AtomicBool::new(false));
125    let stop_c = Arc::clone(&stop);
126    let label_c = label.to_string();
127
128    ACTIVE_SPINNERS.fetch_add(1, Ordering::SeqCst);
129    {
130        let _guard = stdout_lock().lock().unwrap_or_else(|e| e.into_inner());
131        print!(
132            "\r\x1b[2K{} {}",
133            SPINNER[0].truecolor(167, 139, 250),
134            label.truecolor(156, 163, 175)
135        );
136        let _ = io::stdout().flush();
137    }
138
139    let handle = thread::spawn(move || {
140        let mut i = 0usize;
141        while !stop_c.load(Ordering::Relaxed) {
142            {
143                let _guard = stdout_lock().lock().unwrap_or_else(|e| e.into_inner());
144                print!(
145                    "\r\x1b[2K{} {}",
146                    SPINNER[i % SPINNER.len()].truecolor(167, 139, 250),
147                    label_c.truecolor(156, 163, 175)
148                );
149                let _ = io::stdout().flush();
150            }
151            i = i.wrapping_add(1);
152            thread::sleep(Duration::from_millis(80));
153        }
154    });
155
156    LiveStep {
157        label: label.to_string(),
158        stop,
159        handle: Some(handle),
160        finished: false,
161    }
162}
163
164pub fn step_ok(label: &str) {
165    if crate::is_quiet() {
166        return;
167    }
168    println!("{} {}", "✓".truecolor(52, 211, 153).bold(), label.white());
169}
170
171pub fn mint(s: &str) -> colored::ColoredString {
172    s.truecolor(52, 211, 153)
173}
174
175pub fn grey(s: &str) -> colored::ColoredString {
176    s.truecolor(156, 163, 175)
177}
178
179pub fn lavender(s: &str) -> colored::ColoredString {
180    s.truecolor(167, 139, 250)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::begin_step;
186    use crate::{init, Format, Shell};
187
188    #[test]
189    fn quiet_begin_step_does_not_print_completion() {
190        init(Shell {
191            verbosity: 0,
192            quiet: true,
193            format: Format::Human,
194            color: crate::ColorMode::Never,
195        });
196
197        let step = begin_step("Environment configured");
198        step.finish();
199    }
200}