Skip to main content

rich/
spinner.rs

1//! Spinners.
2//!
3//! Port of upstream `rich/spinner.py` and the full `rich/_spinners.py` table. A
4//! [`Spinner`] picks an animation frame for a point in time;
5//! [`Spinner::render`] is the testable surface. Like upstream, the first render
6//! fixes the start of the animation, and [`Spinner::update`] can change the
7//! text, style or speed mid-animation (a new speed takes effect from the next
8//! render, continuing from the current frame). Rendered as a renderable, a
9//! spinner shows the frame for the console's clock ([`Console::get_time`]), so
10//! redrawing it in a `Live` display animates it; `ProgressColumn::Spinner`
11//! animates one from the progress clock.
12//!
13//! Scope: all built-in spinners (vendored in `spinner_data.rs`), trailing text
14//! as console markup and a frame style. Upstream's non-text trailing
15//! renderables (a `Table.grid` of frame and renderable) are not ported.
16
17use std::cell::Cell;
18
19use crate::console::{Console, ConsoleOptions};
20use crate::measure::Measurement;
21use crate::protocol::Renderable;
22use crate::segment::Segment;
23use crate::style::StyleType;
24use crate::text::Text;
25
26/// A named terminal spinner. Mirrors `rich.spinner.Spinner`.
27pub struct Spinner {
28    frames: &'static [&'static str],
29    /// Frame interval in milliseconds.
30    interval: f64,
31    // Boxed so a spinner stays small inside `ProgressColumn`.
32    text: Option<Box<Text>>,
33    style: Option<StyleType>,
34    speed: Cell<f64>,
35    /// Upstream's `start_time`: set by the first render.
36    start_time: Cell<Option<f64>>,
37    /// Upstream's `frame_no_offset`, carried across a speed change.
38    frame_no_offset: Cell<f64>,
39    /// Upstream's `_update_speed`: a pending speed (0.0 = none).
40    update_speed: Cell<f64>,
41}
42
43/// Console markup as upstream's `Text.from_markup(text)`; malformed markup is
44/// kept literally rather than failing a spinner.
45fn markup(text: &str) -> Text {
46    Text::from_markup(text).unwrap_or_else(|_| Text::new(text))
47}
48
49impl Spinner {
50    /// Look up a built-in spinner by name (falls back to `dots`).
51    pub fn new(name: &str) -> Self {
52        let (interval, frames) = crate::spinner_data::spinner_data(name)
53            .or_else(|| crate::spinner_data::spinner_data("dots"))
54            .expect("dots spinner exists");
55        Spinner {
56            frames,
57            interval,
58            text: None,
59            style: None,
60            speed: Cell::new(1.0),
61            start_time: Cell::new(None),
62            frame_no_offset: Cell::new(0.0),
63            update_speed: Cell::new(0.0),
64        }
65    }
66
67    /// Trailing text after the frame, parsed as console markup (upstream
68    /// passes a `str` through `Text.from_markup`).
69    pub fn text(mut self, text: impl Into<String>) -> Self {
70        self.text = Some(Box::new(markup(&text.into())));
71        self
72    }
73
74    /// Set the animation speed multiplier (default 1.0).
75    pub fn speed(self, speed: f64) -> Self {
76        self.speed.set(speed);
77        self
78    }
79
80    /// Style applied to the spinner *frame* (not the trailing text): a style
81    /// or a theme name such as `"status.spinner"`.
82    pub fn style(mut self, style: impl Into<StyleType>) -> Self {
83        self.style = Some(style.into());
84        self
85    }
86
87    /// Port of `Spinner.update`: replace the text or style when given, and
88    /// schedule a speed change for the next render. Like upstream, empty text
89    /// and a zero speed mean "unchanged".
90    pub fn update(&mut self, text: Option<&str>, style: Option<StyleType>, speed: Option<f64>) {
91        if let Some(text) = text.filter(|t| !t.is_empty()) {
92            self.text = Some(Box::new(markup(text)));
93        }
94        if let Some(style) = style {
95            self.style = Some(style);
96        }
97        if let Some(speed) = speed.filter(|s| *s != 0.0) {
98            self.update_speed.set(speed);
99        }
100    }
101
102    /// Render the spinner as it appears at `time` seconds. Port of
103    /// `Spinner.render`: the first call fixes the start time, the frame carries
104    /// the spinner style and `Text.assemble(frame, " ", text)` adds the text.
105    pub fn render(&self, time: f64) -> Text {
106        let start = self.start_time.get().unwrap_or(time);
107        self.start_time.set(Some(start));
108        let frame_no = (time - start) * self.speed.get() / (self.interval / 1000.0)
109            + self.frame_no_offset.get();
110        // Python's `int()` truncates toward zero and `%` is non-negative.
111        let index = (frame_no.trunc() as i64).rem_euclid(self.frames.len() as i64) as usize;
112        let frame_str = self.frames[index];
113        let pending = self.update_speed.get();
114        if pending != 0.0 {
115            self.frame_no_offset.set(frame_no);
116            self.start_time.set(Some(time));
117            self.speed.set(pending);
118            self.update_speed.set(0.0);
119        }
120        match &self.text {
121            // `Text.assemble` turns the frame's style into a span over the
122            // frame alone, so the trailing text keeps its own styling.
123            Some(text) if !text.plain().is_empty() => {
124                let mut assembled = Text::new("");
125                assembled.append(frame_str, self.style.clone());
126                assembled.append(" ", None);
127                assembled.append_text(text)
128            }
129            _ => {
130                let mut frame = Text::new(frame_str);
131                if let Some(style) = &self.style {
132                    frame.set_base_style(style.clone());
133                }
134                frame
135            }
136        }
137    }
138}
139
140impl Renderable for Spinner {
141    /// Port of `Spinner.__rich_console__`: the frame for the console's clock
142    /// ([`Console::get_time`]), so each redraw inside a `Live` display moves
143    /// the animation on.
144    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
145        self.render(console.get_time())
146            .rich_render(console, options)
147    }
148
149    /// Port of `Spinner.__rich_measure__`, which measures `self.render(0)`.
150    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
151        self.render(0.0).measure(console, options)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::color::ColorSystem;
159
160    /// The frame at `time` of a spinner whose first render was at 0.
161    fn frame_at(name: &str, time: f64) -> String {
162        let console = Console::builder()
163            .force_terminal(true)
164            .color_system(Some(ColorSystem::Truecolor))
165            .width(20)
166            .build();
167        let spinner = Spinner::new(name);
168        spinner.render(0.0);
169        console.render_to_string(&spinner.render(time))
170    }
171
172    #[test]
173    fn dots_frames_match_upstream() {
174        // Captured from real rich 15.0.0 (start time 0).
175        assert_eq!(frame_at("dots", 0.0), "⠋");
176        assert_eq!(frame_at("dots", 0.1), "⠙");
177        assert_eq!(frame_at("dots", 0.25), "⠸");
178    }
179
180    #[test]
181    fn line_frames_match_upstream() {
182        assert_eq!(frame_at("line", 0.0), "-");
183        assert_eq!(frame_at("line", 0.1), "-");
184        assert_eq!(frame_at("line", 0.25), "\\");
185    }
186
187    #[test]
188    fn full_table_covers_more_spinners() {
189        // "moon"/"bounce" weren't in the original curated subset. moon: 80ms.
190        assert_eq!(frame_at("moon", 0.0), "\u{1f311} ");
191        assert_eq!(frame_at("moon", 0.08), "\u{1f312} ");
192        assert_eq!(frame_at("bounce", 0.0), "\u{2801}");
193    }
194
195    #[test]
196    fn arrow_and_dots2_match_upstream() {
197        // arrow: interval 100ms → frame advances each 0.1s.
198        assert_eq!(frame_at("arrow", 0.0), "←");
199        assert_eq!(frame_at("arrow", 0.1), "↖");
200        assert_eq!(frame_at("dots2", 0.0), "⣾");
201    }
202
203    #[test]
204    fn text_follows_frame() {
205        let console = Console::builder()
206            .force_terminal(true)
207            .color_system(Some(ColorSystem::Truecolor))
208            .width(20)
209            .build();
210        let out = console.render_to_string(&Spinner::new("dots").text("Working").render(0.0));
211        assert_eq!(out, "⠋ Working");
212    }
213
214    #[test]
215    fn styled_frame_only() {
216        // Captured from real rich 15.0.0: the frame is green, " Working" plain.
217        let console = Console::builder()
218            .force_terminal(true)
219            .color_system(Some(ColorSystem::Truecolor))
220            .width(30)
221            .no_color(false)
222            .build();
223        let spinner = Spinner::new("dots")
224            .text("Working")
225            .style(crate::style::Style::parse("green").unwrap());
226        assert_eq!(
227            console.render_to_string(&spinner.render(0.0)),
228            "\x1b[32m⠋\x1b[0m Working"
229        );
230    }
231}