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
49/// Every built-in spinner name, in upstream's `rich._spinners.SPINNERS`
50/// order. Look each up with [`spinner_frames`].
51pub fn spinner_names() -> &'static [&'static str] {
52    crate::spinner_data::SPINNER_NAMES
53}
54
55/// A built-in spinner's `(interval in milliseconds, frames)`, or `None` for
56/// an unknown name. Port of a `SPINNERS[name]` lookup (`interval`, `frames`).
57pub fn spinner_frames(name: &str) -> Option<(f64, &'static [&'static str])> {
58    crate::spinner_data::spinner_data(name)
59}
60
61impl Spinner {
62    /// Look up a built-in spinner by name (falls back to `dots`).
63    pub fn new(name: &str) -> Self {
64        let (interval, frames) = crate::spinner_data::spinner_data(name)
65            .or_else(|| crate::spinner_data::spinner_data("dots"))
66            .expect("dots spinner exists");
67        Spinner {
68            frames,
69            interval,
70            text: None,
71            style: None,
72            speed: Cell::new(1.0),
73            start_time: Cell::new(None),
74            frame_no_offset: Cell::new(0.0),
75            update_speed: Cell::new(0.0),
76        }
77    }
78
79    /// Trailing text after the frame, parsed as console markup (upstream
80    /// passes a `str` through `Text.from_markup`).
81    pub fn text(mut self, text: impl Into<String>) -> Self {
82        self.text = Some(Box::new(markup(&text.into())));
83        self
84    }
85
86    /// Set the animation speed multiplier (default 1.0).
87    pub fn speed(self, speed: f64) -> Self {
88        self.speed.set(speed);
89        self
90    }
91
92    /// Style applied to the spinner *frame* (not the trailing text): a style
93    /// or a theme name such as `"status.spinner"`.
94    pub fn style(mut self, style: impl Into<StyleType>) -> Self {
95        self.style = Some(style.into());
96        self
97    }
98
99    /// Port of `Spinner.update`: replace the text or style when given, and
100    /// schedule a speed change for the next render. Like upstream, empty text
101    /// and a zero speed mean "unchanged".
102    pub fn update(&mut self, text: Option<&str>, style: Option<StyleType>, speed: Option<f64>) {
103        if let Some(text) = text.filter(|t| !t.is_empty()) {
104            self.text = Some(Box::new(markup(text)));
105        }
106        if let Some(style) = style {
107            self.style = Some(style);
108        }
109        if let Some(speed) = speed.filter(|s| *s != 0.0) {
110            self.update_speed.set(speed);
111        }
112    }
113
114    /// Render the spinner as it appears at `time` seconds. Port of
115    /// `Spinner.render`: the first call fixes the start time, the frame carries
116    /// the spinner style and `Text.assemble(frame, " ", text)` adds the text.
117    pub fn render(&self, time: f64) -> Text {
118        let start = self.start_time.get().unwrap_or(time);
119        self.start_time.set(Some(start));
120        let frame_no = (time - start) * self.speed.get() / (self.interval / 1000.0)
121            + self.frame_no_offset.get();
122        // Python's `int()` truncates toward zero and `%` is non-negative.
123        let index = (frame_no.trunc() as i64).rem_euclid(self.frames.len() as i64) as usize;
124        let frame_str = self.frames[index];
125        let pending = self.update_speed.get();
126        if pending != 0.0 {
127            self.frame_no_offset.set(frame_no);
128            self.start_time.set(Some(time));
129            self.speed.set(pending);
130            self.update_speed.set(0.0);
131        }
132        match &self.text {
133            // `Text.assemble` turns the frame's style into a span over the
134            // frame alone, so the trailing text keeps its own styling.
135            Some(text) if !text.plain().is_empty() => {
136                let mut assembled = Text::new("");
137                assembled.append(frame_str, self.style.clone());
138                assembled.append(" ", None);
139                assembled.append_text(text)
140            }
141            _ => {
142                let mut frame = Text::new(frame_str);
143                if let Some(style) = &self.style {
144                    frame.set_base_style(style.clone());
145                }
146                frame
147            }
148        }
149    }
150}
151
152impl Renderable for Spinner {
153    /// Port of `Spinner.__rich_console__`: the frame for the console's clock
154    /// ([`Console::get_time`]), so each redraw inside a `Live` display moves
155    /// the animation on.
156    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
157        self.render(console.get_time())
158            .rich_render(console, options)
159    }
160
161    /// Port of `Spinner.__rich_measure__`, which measures `self.render(0)`.
162    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
163        self.render(0.0).measure(console, options)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn spinner_table_is_public_in_upstream_order() {
173        let names = spinner_names();
174        assert_eq!(names.len(), 73);
175        assert_eq!(names[0], "dots");
176        assert!(names.iter().all(|name| spinner_frames(name).is_some()));
177        assert!(spinner_frames("nope").is_none());
178    }
179    use crate::color::ColorSystem;
180
181    /// The frame at `time` of a spinner whose first render was at 0.
182    fn frame_at(name: &str, time: f64) -> String {
183        let console = Console::builder()
184            .force_terminal(true)
185            .color_system(Some(ColorSystem::Truecolor))
186            .width(20)
187            .build();
188        let spinner = Spinner::new(name);
189        spinner.render(0.0);
190        console.render_to_string(&spinner.render(time))
191    }
192
193    #[test]
194    fn dots_frames_match_upstream() {
195        // Captured from real rich 15.0.0 (start time 0).
196        assert_eq!(frame_at("dots", 0.0), "⠋");
197        assert_eq!(frame_at("dots", 0.1), "⠙");
198        assert_eq!(frame_at("dots", 0.25), "⠸");
199    }
200
201    #[test]
202    fn line_frames_match_upstream() {
203        assert_eq!(frame_at("line", 0.0), "-");
204        assert_eq!(frame_at("line", 0.1), "-");
205        assert_eq!(frame_at("line", 0.25), "\\");
206    }
207
208    #[test]
209    fn full_table_covers_more_spinners() {
210        // "moon"/"bounce" weren't in the original curated subset. moon: 80ms.
211        assert_eq!(frame_at("moon", 0.0), "\u{1f311} ");
212        assert_eq!(frame_at("moon", 0.08), "\u{1f312} ");
213        assert_eq!(frame_at("bounce", 0.0), "\u{2801}");
214    }
215
216    #[test]
217    fn arrow_and_dots2_match_upstream() {
218        // arrow: interval 100ms → frame advances each 0.1s.
219        assert_eq!(frame_at("arrow", 0.0), "←");
220        assert_eq!(frame_at("arrow", 0.1), "↖");
221        assert_eq!(frame_at("dots2", 0.0), "⣾");
222    }
223
224    #[test]
225    fn text_follows_frame() {
226        let console = Console::builder()
227            .force_terminal(true)
228            .color_system(Some(ColorSystem::Truecolor))
229            .width(20)
230            .build();
231        let out = console.render_to_string(&Spinner::new("dots").text("Working").render(0.0));
232        assert_eq!(out, "⠋ Working");
233    }
234
235    #[test]
236    fn styled_frame_only() {
237        // Captured from real rich 15.0.0: the frame is green, " Working" plain.
238        let console = Console::builder()
239            .force_terminal(true)
240            .color_system(Some(ColorSystem::Truecolor))
241            .width(30)
242            .no_color(false)
243            .build();
244        let spinner = Spinner::new("dots")
245            .text("Working")
246            .style(crate::style::Style::parse("green").unwrap());
247        assert_eq!(
248            console.render_to_string(&spinner.render(0.0)),
249            "\x1b[32m⠋\x1b[0m Working"
250        );
251    }
252}