Skip to main content

skillfile_core/
output.rs

1use std::io::{IsTerminal, Write};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::Arc;
4
5static QUIET: AtomicBool = AtomicBool::new(false);
6
7pub fn set_quiet(quiet: bool) {
8    QUIET.store(quiet, Ordering::Relaxed);
9}
10
11pub fn is_quiet() -> bool {
12    QUIET.load(Ordering::Relaxed)
13}
14
15/// Print a progress message to stderr (suppressed with `--quiet`).
16///
17/// Usage: `progress!("Syncing {count} entries...");`
18#[macro_export]
19macro_rules! progress {
20    ($($arg:tt)*) => {
21        if !$crate::output::is_quiet() {
22            eprintln!($($arg)*);
23        }
24    };
25}
26
27/// Print an inline progress message to stderr without a newline (suppressed with `--quiet`).
28///
29/// Usage: `progress_inline!("  resolving ...");`
30#[macro_export]
31macro_rules! progress_inline {
32    ($($arg:tt)*) => {
33        if !$crate::output::is_quiet() {
34            eprint!($($arg)*);
35        }
36    };
37}
38
39// ===========================================================================
40// Spinner
41// ===========================================================================
42
43const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
44
45fn run_spinner_loop(stop: &AtomicBool, msg: &str) {
46    let mut i = 0usize;
47    let mut stderr = std::io::stderr();
48    while !stop.load(Ordering::Relaxed) {
49        let _ = write!(
50            stderr,
51            "\r{} {msg}",
52            SPINNER_FRAMES[i % SPINNER_FRAMES.len()]
53        );
54        let _ = stderr.flush();
55        i += 1;
56        std::thread::sleep(std::time::Duration::from_millis(80));
57    }
58    // Clear the spinner line
59    let _ = write!(stderr, "\r{}\r", " ".repeat(msg.len() + 3));
60    let _ = stderr.flush();
61}
62
63/// An animated spinner that prints to stderr on a background thread.
64///
65/// The spinner is suppressed in quiet mode or when stderr is not a terminal.
66/// Drop the spinner (or call [`Spinner::finish`]) to stop it and clear the line.
67///
68/// ```no_run
69/// use skillfile_core::output::Spinner;
70///
71/// let spinner = Spinner::new("Searching registries");
72/// // ... blocking work ...
73/// spinner.finish(); // or just let it drop
74/// ```
75pub struct Spinner {
76    stop: Arc<AtomicBool>,
77    handle: Option<std::thread::JoinHandle<()>>,
78}
79
80impl Spinner {
81    pub fn new(message: &str) -> Self {
82        let stop = Arc::new(AtomicBool::new(false));
83
84        if is_quiet() || !std::io::stderr().is_terminal() {
85            return Self { stop, handle: None };
86        }
87
88        let stop_clone = stop.clone();
89        let msg = message.to_string();
90
91        let handle = std::thread::spawn(move || run_spinner_loop(&stop_clone, &msg));
92
93        Self {
94            stop,
95            handle: Some(handle),
96        }
97    }
98
99    /// Stop the spinner and clear the line. Equivalent to dropping.
100    pub fn finish(self) {
101        drop(self);
102    }
103}
104
105impl Drop for Spinner {
106    fn drop(&mut self) {
107        self.stop.store(true, Ordering::Relaxed);
108        if let Some(h) = self.handle.take() {
109            let _ = h.join();
110        }
111    }
112}