Skip to main content

cli_ui/prompt/
spinner.rs

1#![allow(missing_docs)]
2//! Animated spinner — ports `@clack/prompts` `spinner()`.
3//!
4//! Runs an animation thread on stderr until `stop`, `cancel`, or `error` is
5//! called. Supports a live message update via [`Spinner::message`].
6//!
7//! # Example
8//! ```rust,no_run
9//! use cli_ui::prompt::spinner;
10//!
11//! let s = spinner();
12//! s.start("Installing dependencies");
13//! std::thread::sleep(std::time::Duration::from_millis(800));
14//! s.message("Resolving graph");
15//! std::thread::sleep(std::time::Duration::from_millis(800));
16//! s.stop("Installed 42 packages");
17//! ```
18
19use crate::styles::{paint, DIM, WHITE};
20use std::io::Write;
21use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
22use std::sync::{Arc, Mutex};
23use std::thread::{self, JoinHandle};
24use std::time::{Duration, Instant};
25
26const FRAMES: &[&str] = &["◒", "◐", "◓", "◑"];
27const FRAME_DELAY: Duration = Duration::from_millis(80);
28
29const STEP_SUBMIT: &str = "◇";
30const STEP_CANCEL: &str = "■";
31const STEP_ERROR: &str = "▲";
32
33#[derive(Clone, Copy, PartialEq, Eq)]
34pub enum Indicator {
35    Dots,
36    Timer,
37}
38
39struct Inner {
40    message: Mutex<String>,
41    running: AtomicBool,
42    cancelled: AtomicBool,
43    indicator: AtomicU8, // 0 = Dots, 1 = Timer
44}
45
46/// Handle returned from [`spinner()`]. All methods are safe to call from any thread.
47pub struct Spinner {
48    inner: Arc<Inner>,
49    handle: Mutex<Option<JoinHandle<()>>>,
50    started: AtomicBool,
51}
52
53/// Create a new spinner. Call [`Spinner::start`] to begin animating.
54pub fn spinner() -> Spinner {
55    Spinner {
56        inner: Arc::new(Inner {
57            message: Mutex::new(String::new()),
58            running: AtomicBool::new(false),
59            cancelled: AtomicBool::new(false),
60            indicator: AtomicU8::new(0),
61        }),
62        handle: Mutex::new(None),
63        started: AtomicBool::new(false),
64    }
65}
66
67impl Spinner {
68    /// Switch to elapsed-time indicator (`[1m 2s]`) instead of trailing dots.
69    pub fn with_timer(self) -> Self {
70        self.inner.indicator.store(1, Ordering::Relaxed);
71        self
72    }
73
74    pub fn is_cancelled(&self) -> bool {
75        self.inner.cancelled.load(Ordering::Relaxed)
76    }
77
78    /// Start animation. Becomes a no-op if already started.
79    pub fn start(&self, msg: impl Into<String>) {
80        if self.started.swap(true, Ordering::SeqCst) {
81            return;
82        }
83        *self.inner.message.lock().unwrap() = trim_dots(msg.into());
84        self.inner.running.store(true, Ordering::SeqCst);
85        let inner = self.inner.clone();
86        let handle = thread::spawn(move || run_loop(inner));
87        *self.handle.lock().unwrap() = Some(handle);
88    }
89
90    /// Update the message displayed next to the spinner.
91    pub fn message(&self, msg: impl Into<String>) {
92        *self.inner.message.lock().unwrap() = trim_dots(msg.into());
93    }
94
95    /// Stop animation, replace spinner with green `◇` success glyph + message.
96    pub fn stop(&self, msg: impl Into<String>) {
97        self.terminate(msg.into(), Status::Submit);
98    }
99
100    /// Stop animation as cancelled — red `■` glyph.
101    pub fn cancel(&self, msg: impl Into<String>) {
102        self.inner.cancelled.store(true, Ordering::SeqCst);
103        self.terminate(msg.into(), Status::Cancel);
104    }
105
106    /// Stop animation as error — red `▲` glyph.
107    pub fn error(&self, msg: impl Into<String>) {
108        self.terminate(msg.into(), Status::Error);
109    }
110
111    /// Stop the animation without printing a final line.
112    pub fn clear(&self) {
113        self.inner.running.store(false, Ordering::SeqCst);
114        if let Some(h) = self.handle.lock().unwrap().take() {
115            let _ = h.join();
116        }
117        let mut out = std::io::stderr();
118        let _ = write!(out, "\r\x1b[2K");
119        let _ = out.flush();
120    }
121
122    fn terminate(&self, msg: String, status: Status) {
123        self.inner.running.store(false, Ordering::SeqCst);
124        if let Some(h) = self.handle.lock().unwrap().take() {
125            let _ = h.join();
126        }
127        let final_msg = if msg.is_empty() {
128            self.inner.message.lock().unwrap().clone()
129        } else {
130            msg
131        };
132        let c = super::settings::colors();
133        let glyph = match status {
134            Status::Submit => paint(c.success, STEP_SUBMIT),
135            Status::Cancel => paint(c.cancel, STEP_CANCEL),
136            Status::Error => paint(c.error, STEP_ERROR),
137        };
138        let mut out = std::io::stderr();
139        let _ = write!(out, "\r\x1b[2K{}  {}\n", glyph, paint(WHITE, &final_msg));
140        let _ = out.flush();
141    }
142}
143
144impl Drop for Spinner {
145    fn drop(&mut self) {
146        if self.inner.running.load(Ordering::Relaxed) {
147            self.clear();
148        }
149    }
150}
151
152enum Status {
153    Submit,
154    Cancel,
155    Error,
156}
157
158fn trim_dots(mut s: String) -> String {
159    while s.ends_with('.') {
160        s.pop();
161    }
162    s
163}
164
165fn run_loop(inner: Arc<Inner>) {
166    let mut idx = 0usize;
167    let mut dot_phase = 0u8;
168    let origin = Instant::now();
169    let mut out = std::io::stderr();
170    while inner.running.load(Ordering::Relaxed) {
171        let frame = FRAMES[idx % FRAMES.len()];
172        let msg = inner.message.lock().unwrap().clone();
173        let indicator = inner.indicator.load(Ordering::Relaxed);
174        let suffix = if indicator == 1 {
175            format!(" {}", paint(DIM, &format_timer(origin.elapsed())))
176        } else {
177            let n = (dot_phase / 8) as usize;
178            ".".repeat(n.min(3))
179        };
180        let _ = write!(
181            out,
182            "\r\x1b[2K{}  {}{}",
183            paint(super::settings::colors().accent, frame),
184            paint(WHITE, &msg),
185            suffix
186        );
187        let _ = out.flush();
188        idx = idx.wrapping_add(1);
189        dot_phase = dot_phase.wrapping_add(1);
190        thread::sleep(FRAME_DELAY);
191    }
192}
193
194fn format_timer(d: Duration) -> String {
195    let secs = d.as_secs();
196    let m = secs / 60;
197    let s = secs % 60;
198    if m > 0 {
199        format!("[{m}m {s}s]")
200    } else {
201        format!("[{s}s]")
202    }
203}