Skip to main content

rich/
spinner.rs

1//! Spinners.
2//!
3//! Port of upstream `rich/spinner.py` + a subset of `rich/_spinners.py`. A
4//! [`Spinner`] picks an animation frame for a given elapsed time. The animation
5//! itself is driven by a `Live` loop (not yet ported); [`Spinner::render`] gives
6//! the frame at a point in time and is the testable surface.
7//!
8//! Scope: all built-in spinners (vendored in `spinner_data.rs`), an optional
9//! trailing text and a frame [`Style`]. Live-loop animation is still deferred.
10
11use crate::console::{Console, ConsoleOptions};
12use crate::protocol::Renderable;
13use crate::segment::Segment;
14use crate::style::Style;
15use crate::text::Text;
16
17/// A named terminal spinner. Mirrors `rich.spinner.Spinner`.
18pub struct Spinner {
19    frames: &'static [&'static str],
20    /// Frame interval in milliseconds.
21    interval: f64,
22    text: String,
23    speed: f64,
24    style: Option<Style>,
25}
26
27impl Spinner {
28    /// Look up a built-in spinner by name (falls back to `dots`).
29    pub fn new(name: &str) -> Self {
30        let (interval, frames) = crate::spinner_data::spinner_data(name)
31            .or_else(|| crate::spinner_data::spinner_data("dots"))
32            .expect("dots spinner exists");
33        Spinner {
34            frames,
35            interval,
36            text: String::new(),
37            speed: 1.0,
38            style: None,
39        }
40    }
41
42    /// Add trailing text after the spinner frame.
43    pub fn text(mut self, text: impl Into<String>) -> Self {
44        self.text = text.into();
45        self
46    }
47
48    /// Set the animation speed multiplier (default 1.0).
49    pub fn speed(mut self, speed: f64) -> Self {
50        self.speed = speed;
51        self
52    }
53
54    /// Style applied to the spinner *frame* (not the trailing text).
55    pub fn style(mut self, style: Style) -> Self {
56        self.style = Some(style);
57        self
58    }
59
60    /// The frame index at `time` seconds (from an implicit start of 0).
61    fn frame_index(&self, time: f64) -> usize {
62        let interval_secs = self.interval / 1000.0;
63        ((time * self.speed / interval_secs) as usize) % self.frames.len()
64    }
65
66    /// Render the spinner as it appears at `time` seconds. Port of
67    /// `Spinner.render` (`Text.assemble(frame, " ", text)`): the frame carries
68    /// the spinner style, the trailing `" text"` stays plain.
69    pub fn render(&self, time: f64) -> Text {
70        let frame = self.frames[self.frame_index(time)];
71        let mut text = if self.text.is_empty() {
72            Text::new(frame)
73        } else {
74            Text::new(format!("{frame} {}", self.text))
75        };
76        if let Some(style) = &self.style {
77            text.stylize(style.clone(), 0, frame.len());
78        }
79        text
80    }
81}
82
83impl Renderable for Spinner {
84    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
85        // A bare print shows the first frame (t = 0); animation needs a Live loop.
86        self.render(0.0).rich_render(console, options)
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::color::ColorSystem;
94
95    fn frame_at(name: &str, time: f64) -> String {
96        let console = Console::builder()
97            .force_terminal(true)
98            .color_system(Some(ColorSystem::Truecolor))
99            .width(20)
100            .build();
101        console.render_to_string(&Spinner::new(name).render(time))
102    }
103
104    #[test]
105    fn dots_frames_match_upstream() {
106        // Captured from real rich 15.0.0 (start time 0).
107        assert_eq!(frame_at("dots", 0.0), "⠋");
108        assert_eq!(frame_at("dots", 0.1), "⠙");
109        assert_eq!(frame_at("dots", 0.25), "⠸");
110    }
111
112    #[test]
113    fn line_frames_match_upstream() {
114        assert_eq!(frame_at("line", 0.0), "-");
115        assert_eq!(frame_at("line", 0.1), "-");
116        assert_eq!(frame_at("line", 0.25), "\\");
117    }
118
119    #[test]
120    fn full_table_covers_more_spinners() {
121        // "moon"/"bounce" weren't in the original curated subset. moon: 80ms.
122        assert_eq!(frame_at("moon", 0.0), "\u{1f311} ");
123        assert_eq!(frame_at("moon", 0.08), "\u{1f312} ");
124        assert_eq!(frame_at("bounce", 0.0), "\u{2801}");
125    }
126
127    #[test]
128    fn arrow_and_dots2_match_upstream() {
129        // arrow: interval 100ms → frame advances each 0.1s.
130        assert_eq!(frame_at("arrow", 0.0), "←");
131        assert_eq!(frame_at("arrow", 0.1), "↖");
132        assert_eq!(frame_at("dots2", 0.0), "⣾");
133    }
134
135    #[test]
136    fn text_follows_frame() {
137        let console = Console::builder()
138            .force_terminal(true)
139            .color_system(Some(ColorSystem::Truecolor))
140            .width(20)
141            .build();
142        let out = console.render_to_string(&Spinner::new("dots").text("Working").render(0.0));
143        assert_eq!(out, "⠋ Working");
144    }
145
146    #[test]
147    fn styled_frame_only() {
148        // Captured from real rich 15.0.0: the frame is green, " Working" plain.
149        let console = Console::builder()
150            .force_terminal(true)
151            .color_system(Some(ColorSystem::Truecolor))
152            .width(30)
153            .no_color(false)
154            .build();
155        let spinner = Spinner::new("dots")
156            .text("Working")
157            .style(crate::style::Style::parse("green").unwrap());
158        assert_eq!(
159            console.render_to_string(&spinner.render(0.0)),
160            "\x1b[32m⠋\x1b[0m Working"
161        );
162    }
163}