cascade_cli/utils/
spinner.rs

1use indicatif::{ProgressBar, ProgressStyle};
2use std::time::Duration;
3
4/// Lightweight wrapper around `indicatif`'s spinner progress bar with
5/// convenience helpers for printing output while the spinner is active.
6pub struct Spinner {
7    pb: ProgressBar,
8}
9
10/// Cloneable handle that allows printing while a spinner is active.
11#[derive(Debug, Clone)]
12pub struct SpinnerPrinter {
13    pb: ProgressBar,
14}
15
16impl Spinner {
17    const TICK_RATE: Duration = Duration::from_millis(80);
18    const TEMPLATE: &'static str = "{spinner:.green} {msg}";
19
20    fn new_internal(message: String) -> Self {
21        let pb = ProgressBar::new_spinner();
22        pb.set_style(
23            ProgressStyle::with_template(Self::TEMPLATE)
24                .unwrap_or_else(|_| ProgressStyle::default_spinner()),
25        );
26        pb.set_message(message);
27        pb.enable_steady_tick(Self::TICK_RATE);
28
29        // Give indicatif a moment to draw the spinner before any println() calls
30        // This ensures the spinner appears at the correct position
31        std::thread::sleep(Duration::from_millis(20));
32
33        Spinner { pb }
34    }
35
36    /// Start a spinner with the provided message.
37    pub fn new(message: String) -> Self {
38        Self::new_internal(message)
39    }
40
41    /// Start a spinner intended to have output printed underneath it.
42    ///
43    /// (This currently behaves the same as `new`, but exists to preserve the
44    /// semantics of the previous implementation and allow future tweaks.)
45    pub fn new_with_output_below(message: String) -> Self {
46        Self::new_internal(message)
47    }
48
49    /// Print a line while keeping the spinner intact.
50    pub fn println<T: AsRef<str>>(&self, message: T) {
51        self.pb.println(message.as_ref());
52    }
53
54    /// Obtain a cloneable printer handle that can be used to emit lines from
55    /// other parts of the code while the spinner remains active.
56    pub fn printer(&self) -> SpinnerPrinter {
57        SpinnerPrinter {
58            pb: self.pb.clone(),
59        }
60    }
61
62    /// Temporarily suspend the spinner while executing the provided closure.
63    pub fn suspend<F: FnOnce()>(&self, f: F) {
64        self.pb.suspend(f);
65    }
66
67    /// Stop the spinner and clear it from the terminal.
68    pub fn stop(&self) {
69        self.pb.finish_and_clear();
70    }
71
72    /// Stop the spinner and replace it with a final message.
73    pub fn stop_with_message(&self, message: &str) {
74        self.pb.finish_with_message(message.to_string());
75    }
76
77    /// Update the spinner message while it is running.
78    pub fn update_message(&self, new_message: String) {
79        self.pb.set_message(new_message);
80    }
81}
82
83impl Drop for Spinner {
84    fn drop(&mut self) {
85        if !self.pb.is_finished() {
86            self.pb.finish_and_clear();
87        }
88    }
89}
90
91impl SpinnerPrinter {
92    /// Print a line beneath the spinner.
93    pub fn println<T: AsRef<str>>(&self, message: T) {
94        self.pb.println(message.as_ref());
95    }
96
97    /// Temporarily suspend the spinner while running the provided closure.
98    pub fn suspend<F: FnOnce()>(&self, f: F) {
99        self.pb.suspend(f);
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::thread;
107
108    #[test]
109    fn test_spinner_creation_and_stop() {
110        let spinner = Spinner::new("Testing".to_string());
111        thread::sleep(Duration::from_millis(200));
112        spinner.stop();
113    }
114
115    #[test]
116    fn test_spinner_with_message() {
117        let spinner = Spinner::new("Loading".to_string());
118        thread::sleep(Duration::from_millis(200));
119        spinner.stop_with_message("✓ Done");
120    }
121}