Skip to main content

pimalaya_cli/
spinner.rs

1//! Lightweight terminal spinner built on crossterm.
2//!
3//! Renders a single-line braille spinner with a live message on
4//! stderr (so stdout stays clean for piped data), runs on a
5//! background OS thread, and disables itself when stderr is not a
6//! TTY.
7//!
8//! Typical use during a discovery flow:
9//!
10//! ```no_run
11//! use pimalaya_cli::spinner::Spinner;
12//!
13//! let spinner = Spinner::start("Probing autoconfig…");
14//! spinner.set_message("Trying imap.example.com:993");
15//! // … run the discovery work …
16//! spinner.success("Found IMAP server at imap.example.com:993");
17//! ```
18
19use std::{
20    io::{IsTerminal, Write, stderr},
21    sync::{
22        Arc, Mutex, OnceLock,
23        atomic::{AtomicBool, Ordering},
24    },
25    thread::{self, JoinHandle},
26    time::Duration,
27};
28
29use crossterm::{
30    QueueableCommand, cursor,
31    style::{Color, Print, PrintStyledContent, Stylize},
32    terminal::{Clear, ClearType},
33};
34
35const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
36
37const FRAME_INTERVAL: Duration = Duration::from_millis(80);
38
39/// A single-line terminal spinner with a live message.
40pub struct Spinner {
41    message: Arc<Mutex<String>>,
42    stop: Arc<AtomicBool>,
43    handle: Option<JoinHandle<()>>,
44}
45
46impl Spinner {
47    /// Start the spinner with an initial message.
48    ///
49    /// When stderr is not a TTY no thread is spawned and ticks are
50    /// suppressed; terminal completion lines (`success`/`failure`)
51    /// are still printed so logs remain informative.
52    ///
53    /// The first TTY-bound call also installs a process-wide SIGINT /
54    /// SIGTERM / SIGHUP handler and a panic hook that restore the
55    /// cursor before letting the process die: Drop is skipped on
56    /// signal-induced termination and on panic = abort, so without
57    /// these hooks `cursor::Hide` from the render loop would leak
58    /// past process exit.
59    pub fn start(message: impl Into<String>) -> Self {
60        let message = Arc::new(Mutex::new(message.into()));
61        let stop = Arc::new(AtomicBool::new(false));
62
63        let handle = if stderr().is_terminal() {
64            install_terminal_guards();
65            let message = message.clone();
66            let stop = stop.clone();
67            Some(thread::spawn(move || render_loop(message, stop)))
68        } else {
69            None
70        };
71
72        Self {
73            message,
74            stop,
75            handle,
76        }
77    }
78
79    /// Replace the message displayed next to the spinner frame.
80    pub fn set_message(&self, message: impl Into<String>) {
81        if let Ok(mut current) = self.message.lock() {
82            *current = message.into();
83        }
84    }
85
86    /// Stop the spinner and print a green `✓` followed by `message`.
87    pub fn success(self, message: impl AsRef<str>) {
88        self.finish("✓", Color::Green, message.as_ref());
89    }
90
91    /// Stop the spinner and print a red `✗` followed by `message`.
92    pub fn failure(self, message: impl AsRef<str>) {
93        self.finish("✗", Color::Red, message.as_ref());
94    }
95
96    /// Stop the spinner and clear its line without printing anything
97    /// in its place.
98    pub fn clear(mut self) {
99        self.shutdown();
100    }
101
102    fn finish(mut self, glyph: &str, color: Color, message: &str) {
103        self.shutdown();
104
105        let mut out = stderr();
106        out.queue(PrintStyledContent(glyph.with(color))).ok();
107        out.queue(Print(format!(" {message}\n"))).ok();
108        out.flush().ok();
109    }
110
111    fn shutdown(&mut self) {
112        self.stop.store(true, Ordering::Relaxed);
113        if let Some(handle) = self.handle.take() {
114            handle.join().ok();
115        }
116    }
117}
118
119impl Drop for Spinner {
120    fn drop(&mut self) {
121        self.shutdown();
122    }
123}
124
125static TERMINAL_GUARDS: OnceLock<()> = OnceLock::new();
126
127fn install_terminal_guards() {
128    TERMINAL_GUARDS.get_or_init(|| {
129        // SIGINT/SIGTERM/SIGHUP: terminate the process bypassing Drop,
130        // so the render loop never reaches its `cursor::Show` tail.
131        // ctrlc with the `termination` feature catches all three on
132        // Unix and Ctrl-C/Ctrl-Break console events on Windows. The
133        // exit code follows the 128 + signal convention so shells see
134        // the canonical "killed by SIGINT" status (130).
135        let _ = ctrlc::set_handler(|| {
136            restore_terminal();
137            std::process::exit(130);
138        });
139
140        // panic = abort skips Drop too. Chain a hook in front of the
141        // default so the panic message still reaches stderr after the
142        // cursor is restored.
143        let default_hook = std::panic::take_hook();
144        std::panic::set_hook(Box::new(move |info| {
145            restore_terminal();
146            default_hook(info);
147        }));
148    });
149}
150
151fn restore_terminal() {
152    let mut out = stderr();
153    out.queue(cursor::MoveToColumn(0)).ok();
154    out.queue(Clear(ClearType::CurrentLine)).ok();
155    out.queue(cursor::Show).ok();
156    out.flush().ok();
157}
158
159fn render_loop(message: Arc<Mutex<String>>, stop: Arc<AtomicBool>) {
160    let mut out = stderr();
161
162    out.queue(cursor::Hide).ok();
163    out.flush().ok();
164
165    let mut frame = 0;
166    while !stop.load(Ordering::Relaxed) {
167        let snapshot = message.lock().map(|m| m.clone()).unwrap_or_default();
168
169        out.queue(cursor::MoveToColumn(0)).ok();
170        out.queue(Clear(ClearType::CurrentLine)).ok();
171        out.queue(PrintStyledContent(FRAMES[frame].with(Color::Cyan)))
172            .ok();
173        out.queue(Print(format!(" {snapshot}"))).ok();
174        out.flush().ok();
175
176        frame = (frame + 1) % FRAMES.len();
177        thread::sleep(FRAME_INTERVAL);
178    }
179
180    out.queue(cursor::MoveToColumn(0)).ok();
181    out.queue(Clear(ClearType::CurrentLine)).ok();
182    out.queue(cursor::Show).ok();
183    out.flush().ok();
184}