fast_rich/progress/
spinner.rs

1//! Spinner animations for indeterminate progress.
2
3use crate::style::{Color, Style};
4use crate::text::Span;
5use std::time::Instant;
6
7/// Spinner animation style.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum SpinnerStyle {
10    /// Dots animation (default)
11    #[default]
12    Dots,
13    /// Line animation
14    Line,
15    /// Dots2
16    Dots2,
17    /// Arc animation
18    Arc,
19    /// Circle animation
20    Circle,
21    /// Square animation
22    Square,
23    /// Star animation
24    Star,
25    /// Bounce animation
26    Bounce,
27    /// Box animation
28    Box,
29    /// Simple animation
30    Simple,
31}
32
33impl SpinnerStyle {
34    /// Get the frames for this spinner style.
35    pub fn frames(&self) -> &'static [&'static str] {
36        match self {
37            SpinnerStyle::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
38            SpinnerStyle::Line => &["-", "\\", "|", "/"],
39            SpinnerStyle::Dots2 => &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
40            SpinnerStyle::Arc => &["◜", "◠", "◝", "◞", "◡", "◟"],
41            SpinnerStyle::Circle => &["◐", "◓", "◑", "◒"],
42            SpinnerStyle::Square => &["◰", "◳", "◲", "◱"],
43            SpinnerStyle::Star => &["✶", "✸", "✹", "✺", "✹", "✷"],
44            SpinnerStyle::Bounce => &["⠁", "⠂", "⠄", "⠂"],
45            SpinnerStyle::Box => &["▖", "▘", "▝", "▗"],
46            SpinnerStyle::Simple => &["◴", "◷", "◶", "◵"],
47        }
48    }
49
50    /// Get the interval between frames in milliseconds.
51    pub fn interval_ms(&self) -> u64 {
52        match self {
53            SpinnerStyle::Dots | SpinnerStyle::Dots2 => 80,
54            SpinnerStyle::Line => 130,
55            SpinnerStyle::Arc | SpinnerStyle::Circle => 100,
56            SpinnerStyle::Square => 120,
57            SpinnerStyle::Star => 70,
58            SpinnerStyle::Bounce => 120,
59            SpinnerStyle::Box => 100,
60            SpinnerStyle::Simple => 100,
61        }
62    }
63}
64
65/// A spinner for indeterminate progress.
66#[derive(Debug, Clone)]
67pub struct Spinner {
68    /// Spinner style
69    style: SpinnerStyle,
70    /// Start time for animation
71    start_time: Instant,
72    /// Text to display after the spinner
73    text: String,
74    /// Style for the spinner character
75    spinner_style: Style,
76    /// Style for the text
77    text_style: Style,
78}
79
80impl Spinner {
81    /// Create a new spinner with optional text.
82    pub fn new(text: &str) -> Self {
83        Spinner {
84            style: SpinnerStyle::Dots,
85            start_time: Instant::now(),
86            text: text.to_string(),
87            spinner_style: Style::new().foreground(Color::Cyan),
88            text_style: Style::new(),
89        }
90    }
91
92    /// Set the spinner style.
93    pub fn style(mut self, style: SpinnerStyle) -> Self {
94        self.style = style;
95        self
96    }
97
98    /// Set the spinner character style.
99    pub fn spinner_style(mut self, style: Style) -> Self {
100        self.spinner_style = style;
101        self
102    }
103
104    /// Set the text style.
105    pub fn text_style(mut self, style: Style) -> Self {
106        self.text_style = style;
107        self
108    }
109
110    /// Set the text.
111    pub fn text(mut self, text: &str) -> Self {
112        self.text = text.to_string();
113        self
114    }
115
116    /// Update the text.
117    pub fn set_text(&mut self, text: &str) {
118        self.text = text.to_string();
119    }
120
121    /// Get the text.
122    pub fn get_text(&self) -> &str {
123        &self.text
124    }
125
126    /// Get the spinner style.
127    pub fn get_style(&self) -> SpinnerStyle {
128        self.style
129    }
130
131    /// Get the current frame index.
132    fn current_frame_index(&self) -> usize {
133        let elapsed_ms = self.start_time.elapsed().as_millis() as u64;
134        let interval = self.style.interval_ms();
135        let frames = self.style.frames();
136        ((elapsed_ms / interval) as usize) % frames.len()
137    }
138
139    /// Get the current frame character.
140    pub fn current_frame(&self) -> &'static str {
141        let frames = self.style.frames();
142        let idx = self.current_frame_index();
143        frames[idx]
144    }
145
146    /// Render the spinner to spans.
147    pub fn render(&self) -> Vec<Span> {
148        vec![
149            Span::styled(self.current_frame().to_string(), self.spinner_style),
150            Span::raw(" "),
151            Span::styled(self.text.clone(), self.text_style),
152        ]
153    }
154
155    /// Render to a string (for simple output).
156    pub fn to_string_colored(&self) -> String {
157        format!("{} {}", self.current_frame(), self.text)
158    }
159}
160
161impl Default for Spinner {
162    fn default() -> Self {
163        Spinner::new("")
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_spinner_frames() {
173        let style = SpinnerStyle::Dots;
174        let frames = style.frames();
175        assert!(!frames.is_empty());
176        assert_eq!(frames[0], "⠋");
177    }
178
179    #[test]
180    fn test_spinner_render() {
181        let spinner = Spinner::new("Loading...");
182        let spans = spinner.render();
183        assert_eq!(spans.len(), 3);
184    }
185
186    #[test]
187    fn test_all_spinner_styles() {
188        let styles = [
189            SpinnerStyle::Dots,
190            SpinnerStyle::Line,
191            SpinnerStyle::Dots2,
192            SpinnerStyle::Arc,
193            SpinnerStyle::Circle,
194            SpinnerStyle::Square,
195            SpinnerStyle::Star,
196            SpinnerStyle::Bounce,
197            SpinnerStyle::Box,
198            SpinnerStyle::Simple,
199        ];
200
201        for style in styles {
202            let frames = style.frames();
203            assert!(!frames.is_empty(), "{:?} has no frames", style);
204        }
205    }
206}