cascade_cli/utils/
spinner.rs

1use std::io::{self, Write};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::Arc;
4use std::thread;
5use std::time::Duration;
6
7/// A simple terminal spinner that runs in a background thread
8///
9/// The spinner automatically stops and cleans up when dropped, making it
10/// safe to use in code paths that may error or panic.
11///
12/// # Example
13/// ```no_run
14/// use cascade_cli::utils::spinner::Spinner;
15///
16/// let mut spinner = Spinner::new("Loading data".to_string());
17/// // ... do some work ...
18/// spinner.stop_with_message("✓ Data loaded");
19/// ```
20pub struct Spinner {
21    running: Arc<AtomicBool>,
22    handle: Option<thread::JoinHandle<()>>,
23    message: String,
24}
25
26impl Spinner {
27    /// Braille spinner frames for smooth animation
28    const FRAMES: &'static [&'static str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
29
30    /// Frame duration in milliseconds
31    const FRAME_DURATION_MS: u64 = 80;
32
33    /// Start a new spinner with the given message
34    ///
35    /// The spinner will animate in a background thread until stopped.
36    pub fn new(message: String) -> Self {
37        let running = Arc::new(AtomicBool::new(true));
38        let running_clone = Arc::clone(&running);
39        let message_clone = message.clone();
40
41        let handle = thread::spawn(move || {
42            let mut frame_idx = 0;
43            while running_clone.load(Ordering::Relaxed) {
44                let frame = Self::FRAMES[frame_idx % Self::FRAMES.len()];
45                print!("\r{} {}...", frame, message_clone);
46                io::stdout().flush().ok();
47
48                frame_idx += 1;
49                thread::sleep(Duration::from_millis(Self::FRAME_DURATION_MS));
50            }
51        });
52
53        Spinner {
54            running,
55            handle: Some(handle),
56            message,
57        }
58    }
59
60    /// Stop the spinner and clear the line
61    ///
62    /// This method is idempotent - it's safe to call multiple times.
63    pub fn stop(&mut self) {
64        if !self.running.load(Ordering::Relaxed) {
65            return; // Already stopped
66        }
67
68        self.running.store(false, Ordering::Relaxed);
69
70        if let Some(handle) = self.handle.take() {
71            handle.join().ok();
72        }
73
74        // Clear the spinner line
75        self.clear_line();
76    }
77
78    /// Stop the spinner and replace with a final message
79    ///
80    /// This is useful for showing success/failure status after completion.
81    pub fn stop_with_message(&mut self, message: &str) {
82        if !self.running.load(Ordering::Relaxed) {
83            return; // Already stopped
84        }
85
86        self.running.store(false, Ordering::Relaxed);
87
88        if let Some(handle) = self.handle.take() {
89            handle.join().ok();
90        }
91
92        // Clear line and print final message
93        self.clear_line();
94        println!("{}", message);
95    }
96
97    /// Update the spinner's message while it's running
98    ///
99    /// Note: This creates a brief flicker as we stop and restart the spinner.
100    /// For frequent updates, consider using stop_with_message() instead.
101    pub fn update_message(&mut self, new_message: String) {
102        self.stop();
103        *self = Self::new(new_message);
104    }
105
106    /// Clear the current line in the terminal
107    fn clear_line(&self) {
108        // Calculate clear width: spinner (1) + space (1) + message + ellipsis (3) + padding (5)
109        let clear_width = self.message.len() + 10;
110        print!("\r{}\r", " ".repeat(clear_width));
111        io::stdout().flush().ok();
112    }
113}
114
115impl Drop for Spinner {
116    fn drop(&mut self) {
117        // Ensure spinner is stopped when dropped (e.g., on panic or early return)
118        // This prevents orphaned spinner threads and terminal artifacts
119        if self.running.load(Ordering::Relaxed) {
120            self.stop();
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_spinner_creation_and_stop() {
131        let mut spinner = Spinner::new("Testing".to_string());
132        thread::sleep(Duration::from_millis(200));
133        spinner.stop();
134    }
135
136    #[test]
137    fn test_spinner_with_message() {
138        let mut spinner = Spinner::new("Loading".to_string());
139        thread::sleep(Duration::from_millis(200));
140        spinner.stop_with_message("✓ Done");
141    }
142}