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    /// ANSI color code for muted green (terminal color 35 - matches theme)
34    const COLOR_START: &'static str = "\x1b[38;5;35m";
35    const COLOR_END: &'static str = "\x1b[0m";
36
37    /// Start a new spinner with the given message
38    ///
39    /// The spinner will animate in a background thread until stopped.
40    pub fn new(message: String) -> Self {
41        let running = Arc::new(AtomicBool::new(true));
42        let running_clone = Arc::clone(&running);
43        let message_clone = message.clone();
44
45        let handle = thread::spawn(move || {
46            let mut frame_idx = 0;
47            while running_clone.load(Ordering::Relaxed) {
48                let frame = Self::FRAMES[frame_idx % Self::FRAMES.len()];
49                print!(
50                    "\r{}{}{} {}...",
51                    Self::COLOR_START,
52                    frame,
53                    Self::COLOR_END,
54                    message_clone
55                );
56                io::stdout().flush().ok();
57
58                frame_idx += 1;
59                thread::sleep(Duration::from_millis(Self::FRAME_DURATION_MS));
60            }
61        });
62
63        Spinner {
64            running,
65            handle: Some(handle),
66            message,
67        }
68    }
69
70    /// Start a new spinner that stays on one line while content appears below
71    ///
72    /// This variant prints the message with a newline, then updates only the
73    /// spinner character using ANSI cursor positioning. Content can be printed
74    /// below without being overwritten by the spinner.
75    pub fn new_with_output_below(message: String) -> Self {
76        let running = Arc::new(AtomicBool::new(true));
77        let running_clone = Arc::clone(&running);
78        let message_clone = message.clone();
79
80        // Print initial message with newline (with muted green spinner)
81        println!(
82            "{}{}{} {}...",
83            Self::COLOR_START,
84            Self::FRAMES[0],
85            Self::COLOR_END,
86            message_clone
87        );
88        io::stdout().flush().ok();
89
90        let handle = thread::spawn(move || {
91            let mut frame_idx = 1; // Start at 1 since we already printed frame 0
92            while running_clone.load(Ordering::Relaxed) {
93                let frame = Self::FRAMES[frame_idx % Self::FRAMES.len()];
94
95                // Move cursor up 1 line, go to column 0, print colored spinner, move cursor down 1 line
96                // This updates just the spinner character without touching content below
97                print!(
98                    "\x1b[1A\x1b[0G{}{}{}\x1b[1B\x1b[0G",
99                    Self::COLOR_START,
100                    frame,
101                    Self::COLOR_END
102                );
103                io::stdout().flush().ok();
104
105                frame_idx += 1;
106                thread::sleep(Duration::from_millis(Self::FRAME_DURATION_MS));
107            }
108        });
109
110        Spinner {
111            running,
112            handle: Some(handle),
113            message,
114        }
115    }
116
117    /// Stop the spinner and clear the line
118    ///
119    /// This method is idempotent - it's safe to call multiple times.
120    pub fn stop(&mut self) {
121        if !self.running.load(Ordering::Relaxed) {
122            return; // Already stopped
123        }
124
125        self.running.store(false, Ordering::Relaxed);
126
127        if let Some(handle) = self.handle.take() {
128            handle.join().ok();
129        }
130
131        // Clear the spinner line
132        self.clear_line();
133    }
134
135    /// Stop the spinner and replace with a final message
136    ///
137    /// This is useful for showing success/failure status after completion.
138    pub fn stop_with_message(&mut self, message: &str) {
139        if !self.running.load(Ordering::Relaxed) {
140            return; // Already stopped
141        }
142
143        self.running.store(false, Ordering::Relaxed);
144
145        if let Some(handle) = self.handle.take() {
146            handle.join().ok();
147        }
148
149        // Clear line and print final message
150        self.clear_line();
151        println!("{}", message);
152    }
153
154    /// Update the spinner's message while it's running
155    ///
156    /// Note: This creates a brief flicker as we stop and restart the spinner.
157    /// For frequent updates, consider using stop_with_message() instead.
158    pub fn update_message(&mut self, new_message: String) {
159        self.stop();
160        *self = Self::new(new_message);
161    }
162
163    /// Clear the current line in the terminal
164    fn clear_line(&self) {
165        // Calculate clear width: spinner (1) + space (1) + message + ellipsis (3) + padding (5)
166        let clear_width = self.message.len() + 10;
167        print!("\r{}\r", " ".repeat(clear_width));
168        io::stdout().flush().ok();
169    }
170}
171
172impl Drop for Spinner {
173    fn drop(&mut self) {
174        // Ensure spinner is stopped when dropped (e.g., on panic or early return)
175        // This prevents orphaned spinner threads and terminal artifacts
176        if self.running.load(Ordering::Relaxed) {
177            self.stop();
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_spinner_creation_and_stop() {
188        let mut spinner = Spinner::new("Testing".to_string());
189        thread::sleep(Duration::from_millis(200));
190        spinner.stop();
191    }
192
193    #[test]
194    fn test_spinner_with_message() {
195        let mut spinner = Spinner::new("Loading".to_string());
196        thread::sleep(Duration::from_millis(200));
197        spinner.stop_with_message("✓ Done");
198    }
199}