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 ([`Spinner::text`]), as a literal [`Text`]
15//! ([`Spinner::with_text`]) or as any renderable ([`Spinner::renderable`],
16//! upstream's `Table.grid` of frame and renderable), and a frame style.
17
18use std::cell::Cell;
19use std::sync::{Arc, Mutex};
20
21use crate::console::{Console, ConsoleOptions};
22use crate::measure::Measurement;
23use crate::protocol::Renderable;
24use crate::segment::Segment;
25use crate::style::StyleType;
26use crate::text::Text;
27
28/// A named terminal spinner. Mirrors `rich.spinner.Spinner`.
29pub struct Spinner {
30    frames: &'static [&'static str],
31    /// Frame interval in milliseconds.
32    interval: f64,
33    // Boxed so a spinner stays small inside `ProgressColumn`.
34    text: Option<Box<Text>>,
35    /// Upstream's `text` when it is neither a `str` nor a `Text`: shown in a
36    /// grid beside the frame. Shared so a table cell can render it.
37    renderable: Option<Arc<Mutex<Box<dyn Renderable + Send>>>>,
38    style: Option<StyleType>,
39    speed: Cell<f64>,
40    /// Upstream's `start_time`: set by the first render.
41    start_time: Cell<Option<f64>>,
42    /// Upstream's `frame_no_offset`, carried across a speed change.
43    frame_no_offset: Cell<f64>,
44    /// Upstream's `_update_speed`: a pending speed (0.0 = none).
45    update_speed: Cell<f64>,
46}
47
48/// Console markup as upstream's `Text.from_markup(text)`; malformed markup is
49/// kept literally rather than failing a spinner.
50fn markup(text: &str) -> Text {
51    Text::from_markup(text).unwrap_or_else(|_| Text::new(text))
52}
53
54/// Every built-in spinner name, in upstream's `rich._spinners.SPINNERS`
55/// order. Look each up with [`spinner_frames`].
56pub fn spinner_names() -> &'static [&'static str] {
57    crate::spinner_data::SPINNER_NAMES
58}
59
60/// A built-in spinner's `(interval in milliseconds, frames)`, or `None` for
61/// an unknown name. Port of a `SPINNERS[name]` lookup (`interval`, `frames`).
62pub fn spinner_frames(name: &str) -> Option<(f64, &'static [&'static str])> {
63    crate::spinner_data::spinner_data(name)
64}
65
66impl Spinner {
67    /// Look up a built-in spinner by name (falls back to `dots`).
68    pub fn new(name: &str) -> Self {
69        let (interval, frames) = crate::spinner_data::spinner_data(name)
70            .or_else(|| crate::spinner_data::spinner_data("dots"))
71            .expect("dots spinner exists");
72        Spinner {
73            frames,
74            interval,
75            text: None,
76            renderable: None,
77            style: None,
78            speed: Cell::new(1.0),
79            start_time: Cell::new(None),
80            frame_no_offset: Cell::new(0.0),
81            update_speed: Cell::new(0.0),
82        }
83    }
84
85    /// Look up a built-in spinner by name, or `None` where upstream's
86    /// constructor raises `KeyError("no spinner called 'name'")`.
87    /// [`new`](Self::new) falls back to `dots` instead.
88    pub fn try_new(name: &str) -> Option<Self> {
89        crate::spinner_data::spinner_data(name).map(|_| Spinner::new(name))
90    }
91
92    /// Trailing text after the frame, parsed as console markup (upstream
93    /// passes a `str` through `Text.from_markup`).
94    pub fn text(mut self, text: impl Into<String>) -> Self {
95        self.text = Some(Box::new(markup(&text.into())));
96        self.renderable = None;
97        self
98    }
99
100    /// Trailing text after the frame, used as it is (upstream's `text=`
101    /// given a `Text`).
102    pub fn with_text(mut self, text: Text) -> Self {
103        self.text = Some(Box::new(text));
104        self.renderable = None;
105        self
106    }
107
108    /// Any renderable after the frame (upstream's `text=` given another
109    /// renderable): the frame and the renderable are laid out in a
110    /// `Table.grid(padding=1)`.
111    pub fn renderable(self, renderable: Box<dyn Renderable + Send>) -> Self {
112        self.content(&SpinnerText::Renderable(Arc::new(Mutex::new(renderable))))
113    }
114
115    /// Set the trailing content, as given to upstream's `text=`.
116    pub(crate) fn content(mut self, content: &SpinnerText) -> Self {
117        self.set_content(content);
118        self
119    }
120
121    /// Replace the trailing content when it is truthy in upstream's sense
122    /// (`if text:` in `Spinner.update`): a non-empty string or `Text`, or
123    /// any other renderable.
124    pub(crate) fn update_content(&mut self, content: &SpinnerText) {
125        let truthy = match content {
126            SpinnerText::Markup(markup) => !markup.is_empty(),
127            SpinnerText::Text(text) => !text.plain().is_empty(),
128            SpinnerText::Renderable(_) => true,
129        };
130        if truthy {
131            self.set_content(content);
132        }
133    }
134
135    fn set_content(&mut self, content: &SpinnerText) {
136        match content {
137            SpinnerText::Markup(source) => {
138                self.text = Some(Box::new(markup(source)));
139                self.renderable = None;
140            }
141            SpinnerText::Text(text) => {
142                self.text = Some(Box::new(text.clone()));
143                self.renderable = None;
144            }
145            SpinnerText::Renderable(renderable) => {
146                self.renderable = Some(renderable.clone());
147                self.text = None;
148            }
149        }
150    }
151
152    /// Set the animation speed multiplier (default 1.0).
153    pub fn speed(self, speed: f64) -> Self {
154        self.speed.set(speed);
155        self
156    }
157
158    /// Style applied to the spinner *frame* (not the trailing text): a style
159    /// or a theme name such as `"status.spinner"`.
160    pub fn style(mut self, style: impl Into<StyleType>) -> Self {
161        self.style = Some(style.into());
162        self
163    }
164
165    /// Port of `Spinner.update`: replace the text or style when given, and
166    /// schedule a speed change for the next render. Like upstream, empty text
167    /// and a zero speed mean "unchanged".
168    pub fn update(&mut self, text: Option<&str>, style: Option<StyleType>, speed: Option<f64>) {
169        if let Some(text) = text.filter(|t| !t.is_empty()) {
170            self.text = Some(Box::new(markup(text)));
171        }
172        if let Some(style) = style {
173            self.style = Some(style);
174        }
175        if let Some(speed) = speed.filter(|s| *s != 0.0) {
176            self.update_speed.set(speed);
177        }
178    }
179
180    /// Render the spinner as it appears at `time` seconds. Port of
181    /// `Spinner.render`: the first call fixes the start time, the frame carries
182    /// the spinner style and `Text.assemble(frame, " ", text)` adds the text.
183    pub fn render(&self, time: f64) -> Text {
184        let frame = self.frame(time);
185        match &self.text {
186            // `Text.assemble` turns the frame's style into a span over the
187            // frame alone, so the trailing text keeps its own styling.
188            Some(text) if !text.plain().is_empty() => {
189                let mut assembled = Text::new("");
190                assembled.append(frame.plain(), self.style.clone());
191                assembled.append(" ", None);
192                assembled.append_text(text)
193            }
194            _ => frame,
195        }
196    }
197
198    /// [`render`](Self::render) for any trailing renderable: upstream's
199    /// `render` returns a `Table.grid(padding=1)` of the frame and the
200    /// renderable set with [`renderable`](Self::renderable), and the
201    /// assembled text otherwise.
202    pub fn render_renderable(&self, time: f64) -> Box<dyn Renderable> {
203        let Some(renderable) = &self.renderable else {
204            return Box::new(self.render(time));
205        };
206        let frame = self.frame(time);
207        let mut table = crate::table::Table::grid().padding(1, 1, 1, 1);
208        table.add_row_cells(vec![
209            crate::table::Cell::Text(frame),
210            crate::table::Cell::Renderable(Arc::new(Shared(renderable.clone()))),
211        ]);
212        Box::new(table)
213    }
214
215    /// The styled frame at `time`, moving the animation on as upstream's
216    /// `render` does.
217    fn frame(&self, time: f64) -> Text {
218        let start = self.start_time.get().unwrap_or(time);
219        self.start_time.set(Some(start));
220        let frame_no = (time - start) * self.speed.get() / (self.interval / 1000.0)
221            + self.frame_no_offset.get();
222        // Python's `int()` truncates toward zero and `%` is non-negative.
223        let index = (frame_no.trunc() as i64).rem_euclid(self.frames.len() as i64) as usize;
224        let frame_str = self.frames[index];
225        let pending = self.update_speed.get();
226        if pending != 0.0 {
227            self.frame_no_offset.set(frame_no);
228            self.start_time.set(Some(time));
229            self.speed.set(pending);
230            self.update_speed.set(0.0);
231        }
232        let mut frame = Text::new(frame_str);
233        if let Some(style) = &self.style {
234            frame.set_base_style(style.clone());
235        }
236        frame
237    }
238}
239
240/// What upstream's `text=` holds: a `str` (console markup), a `Text`, or
241/// another renderable (shared, so a status can hand it to a new spinner).
242#[derive(Clone)]
243pub(crate) enum SpinnerText {
244    Markup(String),
245    Text(Text),
246    Renderable(Arc<Mutex<Box<dyn Renderable + Send>>>),
247}
248
249/// A spinner's trailing renderable as a table cell.
250struct Shared(Arc<Mutex<Box<dyn Renderable + Send>>>);
251
252impl Renderable for Shared {
253    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
254        self.0
255            .lock()
256            .unwrap_or_else(|poisoned| poisoned.into_inner())
257            .rich_render(console, options)
258    }
259
260    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
261        self.0
262            .lock()
263            .unwrap_or_else(|poisoned| poisoned.into_inner())
264            .measure(console, options)
265    }
266}
267
268impl Renderable for Spinner {
269    /// Port of `Spinner.__rich_console__`: the frame for the console's clock
270    /// ([`Console::get_time`]), so each redraw inside a `Live` display moves
271    /// the animation on.
272    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
273        if self.renderable.is_some() {
274            return self
275                .render_renderable(console.get_time())
276                .rich_render(console, options);
277        }
278        self.render(console.get_time())
279            .rich_render(console, options)
280    }
281
282    /// Port of `Spinner.__rich_measure__`, which measures `self.render(0)`.
283    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
284        if self.renderable.is_some() {
285            return self.render_renderable(0.0).measure(console, options);
286        }
287        self.render(0.0).measure(console, options)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn spinner_table_is_public_in_upstream_order() {
297        let names = spinner_names();
298        assert_eq!(names.len(), 73);
299        assert_eq!(names[0], "dots");
300        assert!(names.iter().all(|name| spinner_frames(name).is_some()));
301        assert!(spinner_frames("nope").is_none());
302    }
303
304    #[test]
305    fn an_unknown_name_is_refused_by_try_new() {
306        // Upstream raises `KeyError("no spinner called 'nope'")`.
307        assert!(Spinner::try_new("nope").is_none());
308        assert!(Spinner::try_new("line").is_some());
309    }
310    use crate::color::ColorSystem;
311
312    /// The frame at `time` of a spinner whose first render was at 0.
313    fn frame_at(name: &str, time: f64) -> String {
314        let console = Console::builder()
315            .force_terminal(true)
316            .color_system(Some(ColorSystem::Truecolor))
317            .width(20)
318            .build();
319        let spinner = Spinner::new(name);
320        spinner.render(0.0);
321        console.render_to_string(&spinner.render(time))
322    }
323
324    #[test]
325    fn dots_frames_match_upstream() {
326        // Captured from real rich 15.0.0 (start time 0).
327        assert_eq!(frame_at("dots", 0.0), "⠋");
328        assert_eq!(frame_at("dots", 0.1), "⠙");
329        assert_eq!(frame_at("dots", 0.25), "⠸");
330    }
331
332    #[test]
333    fn line_frames_match_upstream() {
334        assert_eq!(frame_at("line", 0.0), "-");
335        assert_eq!(frame_at("line", 0.1), "-");
336        assert_eq!(frame_at("line", 0.25), "\\");
337    }
338
339    #[test]
340    fn full_table_covers_more_spinners() {
341        // "moon"/"bounce" weren't in the original curated subset. moon: 80ms.
342        assert_eq!(frame_at("moon", 0.0), "\u{1f311} ");
343        assert_eq!(frame_at("moon", 0.08), "\u{1f312} ");
344        assert_eq!(frame_at("bounce", 0.0), "\u{2801}");
345    }
346
347    #[test]
348    fn arrow_and_dots2_match_upstream() {
349        // arrow: interval 100ms → frame advances each 0.1s.
350        assert_eq!(frame_at("arrow", 0.0), "←");
351        assert_eq!(frame_at("arrow", 0.1), "↖");
352        assert_eq!(frame_at("dots2", 0.0), "⣾");
353    }
354
355    #[test]
356    fn text_follows_frame() {
357        let console = Console::builder()
358            .force_terminal(true)
359            .color_system(Some(ColorSystem::Truecolor))
360            .width(20)
361            .build();
362        let out = console.render_to_string(&Spinner::new("dots").text("Working").render(0.0));
363        assert_eq!(out, "⠋ Working");
364    }
365
366    #[test]
367    fn styled_frame_only() {
368        // Captured from real rich 15.0.0: the frame is green, " Working" plain.
369        let console = Console::builder()
370            .force_terminal(true)
371            .color_system(Some(ColorSystem::Truecolor))
372            .width(30)
373            .no_color(false)
374            .build();
375        let spinner = Spinner::new("dots")
376            .text("Working")
377            .style(crate::style::Style::parse("green").unwrap());
378        assert_eq!(
379            console.render_to_string(&spinner.render(0.0)),
380            "\x1b[32m⠋\x1b[0m Working"
381        );
382    }
383}