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