Skip to main content

presentar_terminal/
cli.rs

1//! CLI utilities for simple terminal output.
2//!
3//! This module provides lightweight utilities for CLI applications that don't
4//! need the full TUI infrastructure. The primary use case is showing loading
5//! spinners and progress indicators in command-line tools.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use presentar_terminal::cli::Spinner;
11//!
12//! // Start spinner while loading
13//! let spinner = Spinner::new().start();
14//!
15//! // Do some work...
16//! std::thread::sleep(std::time::Duration::from_secs(2));
17//!
18//! // Stop and clear spinner
19//! spinner.stop();
20//! println!("Done!");
21//! ```
22
23use crossterm::{
24    cursor::{Hide, MoveToColumn, Show},
25    execute,
26    style::Print,
27    terminal::{Clear, ClearType},
28};
29use std::io::{self, Write};
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::Arc;
32use std::thread::{self, JoinHandle};
33use std::time::Duration;
34
35/// Spinner animation frames.
36///
37/// Each style provides a sequence of characters that animate in order.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum SpinnerStyle {
40    /// Braille dots animation: ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏
41    #[default]
42    Dots,
43    /// Line animation: | / - \
44    Line,
45    /// Growing dots: .  .. ...
46    Growing,
47    /// Arc animation: ◐ ◓ ◑ ◒
48    Arc,
49    /// Bounce animation: ⠁ ⠂ ⠄ ⠂
50    Bounce,
51}
52
53impl SpinnerStyle {
54    /// Get the animation frames for this style.
55    #[must_use]
56    pub const fn frames(&self) -> &'static [&'static str] {
57        match self {
58            Self::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
59            Self::Line => &["|", "/", "-", "\\"],
60            Self::Growing => &[".  ", ".. ", "..."],
61            Self::Arc => &["◐", "◓", "◑", "◒"],
62            Self::Bounce => &["⠁", "⠂", "⠄", "⠂"],
63        }
64    }
65
66    /// Get the recommended interval between frames in milliseconds.
67    #[must_use]
68    pub const fn interval_ms(&self) -> u64 {
69        match self {
70            Self::Dots => 80,
71            Self::Line => 100,
72            Self::Growing => 300,
73            Self::Arc => 100,
74            Self::Bounce => 120,
75        }
76    }
77}
78
79/// A simple CLI spinner for indicating loading/progress.
80///
81/// The spinner runs in a background thread and can be stopped at any time.
82/// When stopped, it clears its output so the terminal is clean.
83///
84/// # Example
85///
86/// ```no_run
87/// use presentar_terminal::cli::Spinner;
88///
89/// let spinner = Spinner::new().start();
90/// // ... do work ...
91/// spinner.stop();
92/// ```
93#[derive(Debug)]
94pub struct Spinner {
95    style: SpinnerStyle,
96    message: Option<String>,
97}
98
99impl Default for Spinner {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl Spinner {
106    /// Create a new spinner with default style (Dots).
107    #[must_use]
108    pub const fn new() -> Self {
109        Self {
110            style: SpinnerStyle::Dots,
111            message: None,
112        }
113    }
114
115    /// Set the spinner animation style.
116    #[must_use]
117    pub const fn style(mut self, style: SpinnerStyle) -> Self {
118        self.style = style;
119        self
120    }
121
122    /// Set an optional message to display after the spinner.
123    #[must_use]
124    pub fn message(mut self, msg: impl Into<String>) -> Self {
125        self.message = Some(msg.into());
126        self
127    }
128
129    /// Start the spinner animation in a background thread.
130    ///
131    /// Returns a `SpinnerHandle` that can be used to stop the spinner.
132    pub fn start(self) -> SpinnerHandle {
133        let running = Arc::new(AtomicBool::new(true));
134        let running_clone = Arc::clone(&running);
135
136        let frames = self.style.frames();
137        let interval = Duration::from_millis(self.style.interval_ms());
138        let message = self.message;
139
140        let handle = thread::spawn(move || {
141            let mut stdout = io::stdout();
142            let mut frame_idx = 0;
143
144            // Hide cursor while spinning
145            let _ = execute!(stdout, Hide);
146
147            while running_clone.load(Ordering::Relaxed) {
148                let frame = frames[frame_idx % frames.len()];
149
150                // Clear line and print frame
151                let _ = execute!(
152                    stdout,
153                    MoveToColumn(0),
154                    Clear(ClearType::CurrentLine),
155                    Print(frame)
156                );
157
158                if let Some(ref msg) = message {
159                    let _ = execute!(stdout, Print(" "), Print(msg));
160                }
161
162                let _ = stdout.flush();
163
164                frame_idx = frame_idx.wrapping_add(1);
165                thread::sleep(interval);
166            }
167
168            // Clean up: clear line and show cursor
169            let _ = execute!(stdout, MoveToColumn(0), Clear(ClearType::CurrentLine), Show);
170            let _ = stdout.flush();
171        });
172
173        SpinnerHandle {
174            running,
175            handle: Some(handle),
176        }
177    }
178}
179
180/// Handle to a running spinner, used to stop it.
181///
182/// When dropped, the spinner is automatically stopped.
183#[derive(Debug)]
184pub struct SpinnerHandle {
185    running: Arc<AtomicBool>,
186    handle: Option<JoinHandle<()>>,
187}
188
189impl SpinnerHandle {
190    /// Stop the spinner and clear its output.
191    ///
192    /// This blocks until the spinner thread has finished.
193    pub fn stop(mut self) {
194        self.stop_internal();
195    }
196
197    /// Stop the spinner and print a final message in its place.
198    pub fn stop_with_message(mut self, msg: &str) {
199        self.stop_internal();
200        println!("{msg}");
201    }
202
203    fn stop_internal(&mut self) {
204        self.running.store(false, Ordering::Relaxed);
205        if let Some(handle) = self.handle.take() {
206            let _ = handle.join();
207        }
208    }
209}
210
211impl Drop for SpinnerHandle {
212    fn drop(&mut self) {
213        self.stop_internal();
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_spinner_style_frames() {
223        assert!(!SpinnerStyle::Dots.frames().is_empty());
224        assert!(!SpinnerStyle::Line.frames().is_empty());
225        assert!(!SpinnerStyle::Growing.frames().is_empty());
226        assert!(!SpinnerStyle::Arc.frames().is_empty());
227        assert!(!SpinnerStyle::Bounce.frames().is_empty());
228    }
229
230    #[test]
231    fn test_spinner_style_interval() {
232        assert!(SpinnerStyle::Dots.interval_ms() > 0);
233        assert!(SpinnerStyle::Line.interval_ms() > 0);
234    }
235
236    #[test]
237    fn test_spinner_builder() {
238        let spinner = Spinner::new()
239            .style(SpinnerStyle::Line)
240            .message("Loading...");
241
242        assert_eq!(spinner.style, SpinnerStyle::Line);
243        assert_eq!(spinner.message, Some("Loading...".to_string()));
244    }
245
246    #[test]
247    fn test_spinner_default() {
248        let spinner = Spinner::default();
249        assert_eq!(spinner.style, SpinnerStyle::Dots);
250        assert!(spinner.message.is_none());
251    }
252
253    #[test]
254    fn test_spinner_start_stop() {
255        // Quick test that spinner can start and stop without panicking
256        let handle = Spinner::new().start();
257        std::thread::sleep(Duration::from_millis(100));
258        handle.stop();
259    }
260
261    #[test]
262    fn test_spinner_drop_stops() {
263        // Test that dropping the handle stops the spinner
264        {
265            let _handle = Spinner::new().start();
266            std::thread::sleep(Duration::from_millis(50));
267            // handle dropped here
268        }
269        // If we get here without hanging, the test passes
270    }
271}