Skip to main content

rusty_rich/
spinner.rs

1//! Spinner — animated spinner. Equivalent to Rich's `spinner.py`.
2
3use std::time::Duration;
4
5// ---------------------------------------------------------------------------
6// Spinner frames
7// ---------------------------------------------------------------------------
8
9/// Predefined spinner animations (matching Rich's spinner set).
10#[derive(Debug, Clone)]
11pub struct SpinnerFrames {
12    pub frames: &'static [&'static str],
13    pub interval: f64, // seconds per frame
14}
15
16// ===========================================================================
17// Classic / dots spinners
18// ===========================================================================
19
20pub const SPINNER_DOTS: SpinnerFrames = SpinnerFrames {
21    frames: &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
22    interval: 0.08,
23};
24
25pub const SPINNER_LINE: SpinnerFrames = SpinnerFrames {
26    frames: &["-", "\\", "|", "/"],
27    interval: 0.1,
28};
29
30pub const SPINNER_DOTS2: SpinnerFrames = SpinnerFrames {
31    frames: &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
32    interval: 0.08,
33};
34
35pub const SPINNER_DOTS3: SpinnerFrames = SpinnerFrames {
36    frames: &["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
37    interval: 0.08,
38};
39
40pub const SPINNER_DOTS4: SpinnerFrames = SpinnerFrames {
41    frames: &["⠄", "⠆", "⠇", "⠋", "⠙", "⠸", "⠰", "⠠", "⠰", "⠸", "⠙", "⠋", "⠇", "⠆"],
42    interval: 0.08,
43};
44
45pub const SPINNER_DOTS5: SpinnerFrames = SpinnerFrames {
46    frames: &["⠋", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋"],
47    interval: 0.08,
48};
49
50pub const SPINNER_DOTS6: SpinnerFrames = SpinnerFrames {
51    frames: &[
52        "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠴", "⠲", "⠒",
53        "⠂", "⠂", "⠒", "⠚", "⠙", "⠉", "⠁",
54    ],
55    interval: 0.08,
56};
57
58pub const SPINNER_DOTS7: SpinnerFrames = SpinnerFrames {
59    frames: &[
60        "⠈", "⠉", "⠋", "⠓", "⠒", "⠐", "⠐", "⠒", "⠖", "⠦", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒",
61        "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈",
62    ],
63    interval: 0.08,
64};
65
66pub const SPINNER_DOTS8: SpinnerFrames = SpinnerFrames {
67    frames: &[
68        "⠁", "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠠", "⠠",
69        "⠤", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈", "⠈",
70    ],
71    interval: 0.08,
72};
73
74pub const SPINNER_DOTS9: SpinnerFrames = SpinnerFrames {
75    frames: &["⢹", "⢺", "⢼", "⣸", "⣇", "⡧", "⡗", "⡏"],
76    interval: 0.08,
77};
78
79pub const SPINNER_DOTS10: SpinnerFrames = SpinnerFrames {
80    frames: &["⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"],
81    interval: 0.08,
82};
83
84pub const SPINNER_DOTS11: SpinnerFrames = SpinnerFrames {
85    frames: &["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
86    interval: 0.1,
87};
88
89pub const SPINNER_SIMPLE_DOTS: SpinnerFrames = SpinnerFrames {
90    frames: &[".  ", ".. ", "...", " ..", "  .", "   "],
91    interval: 0.2,
92};
93
94// ===========================================================================
95// Icon / theme spinners
96// ===========================================================================
97
98pub const SPINNER_MOON: SpinnerFrames = SpinnerFrames {
99    frames: &["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
100    interval: 0.08,
101};
102
103pub const SPINNER_SMILEY: SpinnerFrames = SpinnerFrames {
104    frames: &["😄", "😝"],
105    interval: 0.2,
106};
107
108// ===========================================================================
109// New spinner styles (20+ from Python Rich)
110// ===========================================================================
111
112pub const SPINNER_ARC: SpinnerFrames = SpinnerFrames {
113    frames: &["◜", "◠", "◝", "◞", "◡", "◟"],
114    interval: 0.1,
115};
116
117pub const SPINNER_ARROW: SpinnerFrames = SpinnerFrames {
118    frames: &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
119    interval: 0.1,
120};
121
122pub const SPINNER_ARROW2: SpinnerFrames = SpinnerFrames {
123    frames: &["⬆️", "↗️", "➡️", "↘️", "⬇️", "↙️", "⬅️", "↖️"],
124    interval: 0.1,
125};
126
127pub const SPINNER_ARROW3: SpinnerFrames = SpinnerFrames {
128    frames: &["▹", "▸", "▹", "▸", "▹", "▸"],
129    interval: 0.1,
130};
131
132pub const SPINNER_BOUNCING_BAR: SpinnerFrames = SpinnerFrames {
133    frames: &["[    ]", "[=   ]", "[==  ]", "[=== ]", "[ ===]", "[  ==]", "[   =]", "[    ]"],
134    interval: 0.15,
135};
136
137pub const SPINNER_BOUNCING_BALL: SpinnerFrames = SpinnerFrames {
138    frames: &[
139        "( ●    )", "(  ●   )", "(   ●  )", "(    ● )", "(     ●)", "(    ● )", "(   ●  )",
140        "(  ●   )",
141    ],
142    interval: 0.15,
143};
144
145pub const SPINNER_CHRISTMAS: SpinnerFrames = SpinnerFrames {
146    frames: &["🌲", "🎄"],
147    interval: 0.4,
148};
149
150pub const SPINNER_CIRCLE: SpinnerFrames = SpinnerFrames {
151    frames: &["◐", "◓", "◑", "◒"],
152    interval: 0.1,
153};
154
155pub const SPINNER_CLOCK: SpinnerFrames = SpinnerFrames {
156    frames: &[
157        "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛",
158    ],
159    interval: 0.1,
160};
161
162pub const SPINNER_EARTH: SpinnerFrames = SpinnerFrames {
163    frames: &["🌍", "🌎", "🌏"],
164    interval: 0.2,
165};
166
167pub const SPINNER_GRENADE: SpinnerFrames = SpinnerFrames {
168    frames: &["،  💣  ", "۔  💣  ", " ﹒ 💣  "],
169    interval: 0.1,
170};
171
172pub const SPINNER_GROW_HORIZONTAL: SpinnerFrames = SpinnerFrames {
173    frames: &["▏", "▎", "▍", "▌", "▋", "▊", "▉", "▉", "▊", "▋", "▌", "▍", "▎", "▏"],
174    interval: 0.08,
175};
176
177pub const SPINNER_GROW_VERTICAL: SpinnerFrames = SpinnerFrames {
178    frames: &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▁"],
179    interval: 0.08,
180};
181
182pub const SPINNER_HAMBURGER: SpinnerFrames = SpinnerFrames {
183    frames: &["☱", "☲", "☴"],
184    interval: 0.12,
185};
186
187pub const SPINNER_HEARTS: SpinnerFrames = SpinnerFrames {
188    frames: &["🩷", "❤️", "🧡", "💛", "💚", "💙", "🩵", "💜", "🤎", "🖤", "🩶", "🤍"],
189    interval: 0.12,
190};
191
192pub const SPINNER_MONKEY: SpinnerFrames = SpinnerFrames {
193    frames: &["🐒", "🐒", "🐒", "🐒", "🙈", "🙉", "🙊", "🐒", "🐒", "🐒", "🐒"],
194    interval: 0.15,
195};
196
197pub const SPINNER_NOISE: SpinnerFrames = SpinnerFrames {
198    frames: &["▓", "▒", "░", "▓", "▒", "░", "▓", "▒", "░"],
199    interval: 0.08,
200};
201
202pub const SPINNER_PONG: SpinnerFrames = SpinnerFrames {
203    frames: &[
204        "▐⠂       ▌", "▐⠈       ▌", "▐ ⠂      ▌", "▐ ⠠      ▌",
205        "▐  ⡀     ▌", "▐  ⠠     ▌", "▐   ⠂    ▌", "▐   ⠈    ▌",
206        "▐    ⠂   ▌", "▐    ⠠   ▌", "▐     ⡀  ▌", "▐     ⠠  ▌",
207        "▐      ⠂ ▌", "▐      ⠈ ▌", "▐       ⠂▌", "▐       ⠠▌",
208        "▐       ⡀▌", "▐      ⠠ ▌", "▐      ⠂ ▌", "▐     ⠈  ▌",
209        "▐     ⠂  ▌", "▐    ⠠   ▌", "▐    ⡀   ▌", "▐   ⠠    ▌",
210        "▐   ⠂    ▌", "▐  ⠈     ▌", "▐  ⠂     ▌", "▐ ⠠      ▌",
211        "▐ ⠂      ▌", "▐⠈       ▌",
212    ],
213    interval: 0.08,
214};
215
216pub const SPINNER_RUNNER: SpinnerFrames = SpinnerFrames {
217    frames: &["🚶", "🏃", "🏃", "🏃", "🚶", "🚶"],
218    interval: 0.15,
219};
220
221pub const SPINNER_SHARK: SpinnerFrames = SpinnerFrames {
222    frames: &["🦈", "🌀", "🦈", "🌀", "🦈", "🌀"],
223    interval: 0.15,
224};
225
226pub const SPINNER_TOGGLE: SpinnerFrames = SpinnerFrames {
227    frames: &["⊶", "⊷"],
228    interval: 0.2,
229};
230
231pub const SPINNER_TRIANGLE: SpinnerFrames = SpinnerFrames {
232    frames: &["◢", "◣", "◤", "◥"],
233    interval: 0.1,
234};
235
236pub const SPINNER_VERTICAL_BARS: SpinnerFrames = SpinnerFrames {
237    frames: &["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁"],
238    interval: 0.08,
239};
240
241/// Default spinner.
242pub const DEFAULT_SPINNER: SpinnerFrames = SPINNER_DOTS;
243
244// ===========================================================================
245// Name-based lookup
246// =========================================================================--
247
248/// All known spinners mapped by name (lowercase) for runtime lookup.
249pub const SPINNERS: &[(&str, &SpinnerFrames)] = &[
250    ("arc", &SPINNER_ARC),
251    ("arrow", &SPINNER_ARROW),
252    ("arrow2", &SPINNER_ARROW2),
253    ("arrow3", &SPINNER_ARROW3),
254    ("bouncingBar", &SPINNER_BOUNCING_BAR),
255    ("bouncingBall", &SPINNER_BOUNCING_BALL),
256    ("christmas", &SPINNER_CHRISTMAS),
257    ("circle", &SPINNER_CIRCLE),
258    ("clock", &SPINNER_CLOCK),
259    ("dots", &SPINNER_DOTS),
260    ("dots2", &SPINNER_DOTS2),
261    ("dots3", &SPINNER_DOTS3),
262    ("dots4", &SPINNER_DOTS4),
263    ("dots5", &SPINNER_DOTS5),
264    ("dots6", &SPINNER_DOTS6),
265    ("dots7", &SPINNER_DOTS7),
266    ("dots8", &SPINNER_DOTS8),
267    ("dots9", &SPINNER_DOTS9),
268    ("dots10", &SPINNER_DOTS10),
269    ("dots11", &SPINNER_DOTS11),
270    ("earth", &SPINNER_EARTH),
271    ("grenade", &SPINNER_GRENADE),
272    ("growHorizontal", &SPINNER_GROW_HORIZONTAL),
273    ("growVertical", &SPINNER_GROW_VERTICAL),
274    ("hamburger", &SPINNER_HAMBURGER),
275    ("hearts", &SPINNER_HEARTS),
276    ("line", &SPINNER_LINE),
277    ("monkey", &SPINNER_MONKEY),
278    ("moon", &SPINNER_MOON),
279    ("noise", &SPINNER_NOISE),
280    ("pong", &SPINNER_PONG),
281    ("runner", &SPINNER_RUNNER),
282    ("shark", &SPINNER_SHARK),
283    ("simpleDots", &SPINNER_SIMPLE_DOTS),
284    ("smiley", &SPINNER_SMILEY),
285    ("toggle", &SPINNER_TOGGLE),
286    ("triangle", &SPINNER_TRIANGLE),
287    ("verticalBars", &SPINNER_VERTICAL_BARS),
288];
289
290/// Get a spinner by name (case-insensitive).
291///
292/// Returns `None` if no spinner with the given name exists.
293///
294/// # Example
295///
296/// ```rust
297/// use rusty_rich::get_spinner;
298///
299/// let s = get_spinner("arc").unwrap();
300/// assert_eq!(s.frames.len(), 6);
301/// ```
302pub fn get_spinner(name: &str) -> Option<&'static SpinnerFrames> {
303    // First try direct lowercase match
304    for (key, spinner) in SPINNERS {
305        if key.eq_ignore_ascii_case(name) {
306            return Some(spinner);
307        }
308    }
309    // Fallback: try stripping spaces and hyphens, matching lowercase
310    let normalized: String = name.chars().filter(|c| !c.is_whitespace()).collect();
311    let normalized = normalized.to_lowercase();
312    for (key, spinner) in SPINNERS {
313        let key_normalized: String = key.chars().filter(|c| !c.is_whitespace()).collect();
314        let key_normalized = key_normalized.to_lowercase();
315        if key_normalized == normalized {
316            return Some(spinner);
317        }
318    }
319    None
320}
321
322// ---------------------------------------------------------------------------
323// Spinner
324// ---------------------------------------------------------------------------
325
326/// An animated spinner renderable.
327#[derive(Debug, Clone)]
328pub struct Spinner {
329    pub frames: &'static [&'static str],
330    pub interval: f64,
331    /// Text displayed alongside the spinner.
332    pub text: String,
333    /// Style for the spinner.
334    pub style: crate::style::Style,
335}
336
337impl Spinner {
338    /// Create a new spinner.
339    pub fn new(spinner: &'static SpinnerFrames) -> Self {
340        Self {
341            frames: spinner.frames,
342            interval: spinner.interval,
343            text: String::new(),
344            style: crate::style::Style::new(),
345        }
346    }
347
348    /// Builder: set the text.
349    pub fn text(mut self, text: impl Into<String>) -> Self {
350        self.text = text.into();
351        self
352    }
353
354    /// Builder: set the style.
355    pub fn style(mut self, style: crate::style::Style) -> Self {
356        self.style = style;
357        self
358    }
359
360    /// Get the frame at the given elapsed time.
361    pub fn frame_at(&self, elapsed: Duration) -> &'static str {
362        let idx = (elapsed.as_secs_f64() / self.interval) as usize % self.frames.len();
363        self.frames[idx]
364    }
365
366    /// Get the display string for the current time.
367    pub fn render(&self, elapsed: Duration) -> String {
368        let frame = self.frame_at(elapsed);
369        let style_ansi = self.style.to_ansi();
370        let reset = if style_ansi.is_empty() { "" } else { "\x1b[0m" };
371        if self.text.is_empty() {
372            format!("{style_ansi}{frame}{reset}")
373        } else {
374            format!("{style_ansi}{frame}{reset} {}", self.text)
375        }
376    }
377}
378
379impl Default for Spinner {
380    fn default() -> Self {
381        Self::new(&DEFAULT_SPINNER)
382    }
383}
384
385// ---------------------------------------------------------------------------
386// Tests
387// ---------------------------------------------------------------------------
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_spinner_frame_at() {
395        let s = Spinner::new(&SPINNER_LINE);
396        let f = s.frame_at(Duration::from_millis(200));
397        assert!(f == "-" || f == "\\" || f == "|" || f == "/");
398    }
399
400    #[test]
401    fn test_get_spinner_found() {
402        let s = get_spinner("dots").unwrap();
403        assert!(!s.frames.is_empty());
404
405        let s = get_spinner("DOTS").unwrap();
406        assert!(!s.frames.is_empty());
407
408        let s = get_spinner("arc").unwrap();
409        assert_eq!(s.frames.len(), 6);
410    }
411
412    #[test]
413    fn test_get_spinner_not_found() {
414        assert!(get_spinner("nonexistent").is_none());
415    }
416
417    #[test]
418    fn test_get_spinner_case_insensitive() {
419        let s1 = get_spinner("ARC").unwrap();
420        let s2 = get_spinner("arc").unwrap();
421        assert_eq!(s1.frames, s2.frames);
422    }
423
424    #[test]
425    fn test_get_spinner_camel_case() {
426        let s = get_spinner("bouncingBar").unwrap();
427        assert!(!s.frames.is_empty());
428
429        let s = get_spinner("BOUNCINGBAR").unwrap();
430        assert!(!s.frames.is_empty());
431    }
432
433    #[test]
434    fn test_spinners_list_all_accessible() {
435        for (name, frames) in SPINNERS {
436            let found = get_spinner(name).unwrap();
437            assert!(!frames.frames.is_empty(), "spinner '{}' has no frames", name);
438            // Compare frame content rather than raw pointers, since `const`
439            // values may be inlined at different addresses by the compiler.
440            assert_eq!(
441                frames.frames, found.frames,
442                "spinner '{}' points to different frames than expected",
443                name
444            );
445            assert!(
446                (frames.interval - found.interval).abs() < f64::EPSILON,
447                "spinner '{}' interval mismatch",
448                name
449            );
450        }
451    }
452
453    #[test]
454    fn test_spinner_arc_frames() {
455        assert_eq!(SPINNER_ARC.frames.len(), 6);
456        assert!(SPINNER_ARC.interval > 0.0);
457    }
458
459    #[test]
460    fn test_spinner_arrow_frames() {
461        assert_eq!(SPINNER_ARROW.frames.len(), 8);
462    }
463
464    #[test]
465    fn test_spinner_arrow2_frames() {
466        assert_eq!(SPINNER_ARROW2.frames.len(), 8);
467    }
468
469    #[test]
470    fn test_spinner_arrow3_frames() {
471        assert_eq!(SPINNER_ARROW3.frames.len(), 6);
472    }
473
474    #[test]
475    fn test_spinner_bouncing_bar() {
476        assert_eq!(SPINNER_BOUNCING_BAR.frames.len(), 8);
477    }
478
479    #[test]
480    fn test_spinner_bouncing_ball() {
481        assert_eq!(SPINNER_BOUNCING_BALL.frames.len(), 8);
482    }
483
484    #[test]
485    fn test_spinner_christmas() {
486        assert_eq!(SPINNER_CHRISTMAS.frames.len(), 2);
487    }
488
489    #[test]
490    fn test_spinner_circle() {
491        assert_eq!(SPINNER_CIRCLE.frames.len(), 4);
492    }
493
494    #[test]
495    fn test_spinner_clock() {
496        assert_eq!(SPINNER_CLOCK.frames.len(), 12);
497    }
498
499    #[test]
500    fn test_spinner_earth() {
501        assert_eq!(SPINNER_EARTH.frames.len(), 3);
502    }
503
504    #[test]
505    fn test_spinner_grenade() {
506        assert_eq!(SPINNER_GRENADE.frames.len(), 3);
507    }
508
509    #[test]
510    fn test_spinner_grow_horizontal() {
511        assert_eq!(SPINNER_GROW_HORIZONTAL.frames.len(), 14);
512    }
513
514    #[test]
515    fn test_spinner_grow_vertical() {
516        assert_eq!(SPINNER_GROW_VERTICAL.frames.len(), 14);
517    }
518
519    #[test]
520    fn test_spinner_hamburger() {
521        assert_eq!(SPINNER_HAMBURGER.frames.len(), 3);
522    }
523
524    #[test]
525    fn test_spinner_hearts() {
526        assert_eq!(SPINNER_HEARTS.frames.len(), 12);
527    }
528
529    #[test]
530    fn test_spinner_monkey() {
531        assert_eq!(SPINNER_MONKEY.frames.len(), 11);
532    }
533
534    #[test]
535    fn test_spinner_noise() {
536        assert_eq!(SPINNER_NOISE.frames.len(), 9);
537    }
538
539    #[test]
540    fn test_spinner_pong() {
541        assert_eq!(SPINNER_PONG.frames.len(), 30);
542    }
543
544    #[test]
545    fn test_spinner_runner() {
546        assert_eq!(SPINNER_RUNNER.frames.len(), 6);
547    }
548
549    #[test]
550    fn test_spinner_shark() {
551        assert_eq!(SPINNER_SHARK.frames.len(), 6);
552    }
553
554    #[test]
555    fn test_spinner_toggle() {
556        assert_eq!(SPINNER_TOGGLE.frames.len(), 2);
557    }
558
559    #[test]
560    fn test_spinner_triangle() {
561        assert_eq!(SPINNER_TRIANGLE.frames.len(), 4);
562    }
563
564    #[test]
565    fn test_spinner_vertical_bars() {
566        assert_eq!(SPINNER_VERTICAL_BARS.frames.len(), 15);
567    }
568
569    #[test]
570    fn test_spinner_interval_positive() {
571        for (name, frames) in SPINNERS {
572            assert!(
573                frames.interval > 0.0,
574                "spinner '{}' has non-positive interval",
575                name
576            );
577        }
578    }
579
580    #[test]
581    fn test_default_spinner_is_dots() {
582        assert_eq!(DEFAULT_SPINNER.frames, SPINNER_DOTS.frames);
583    }
584}