Skip to main content

rich/
status.rs

1//! A status indicator with a spinner.
2//!
3//! Port of `rich/status.py`. A [`Status`] shows a spinner animation followed
4//! by a status message, parsed as console markup. It is a renderable whose
5//! spinner reads the console's clock ([`Console::get_time`]), and
6//! [`Status::start`] runs it in its own transient live display, as
7//! upstream's `with Status(...)` does ([`LiveStatus`]).
8//! [`Status::update`] follows upstream: a new spinner name replaces the
9//! spinner (restarting its animation) and redraws, anything else updates it
10//! in place for the next refresh.
11
12use std::io::Write;
13use std::sync::{Arc, Mutex};
14
15use crate::console::{Console, ConsoleOptions};
16use crate::file_proxy::{FileProxy, ProxyStream};
17use crate::live::{AutoLive, Live, LiveHandle, LiveOptions};
18use crate::measure::Measurement;
19use crate::protocol::Renderable;
20use crate::segment::Segment;
21use crate::spinner::{Spinner, SpinnerText};
22use crate::style::StyleType;
23use crate::text::Text;
24
25/// A spinner + message status indicator. Mirrors `rich.status.Status`.
26pub struct Status {
27    status: SpinnerText,
28    spinner_style: StyleType,
29    speed: f64,
30    spinner: Spinner,
31    refresh_per_second: f64,
32}
33
34impl Status {
35    /// A status showing `message` with the default `dots` spinner, styled
36    /// `status.spinner` (green in the default theme).
37    pub fn new(message: impl Into<String>) -> Self {
38        Status::from_content(SpinnerText::Markup(message.into()))
39    }
40
41    /// A status showing `message` as it is (upstream's `Status(Text(...))`).
42    pub fn from_text(message: Text) -> Self {
43        Status::from_content(SpinnerText::Text(message))
44    }
45
46    /// A status showing any renderable beside the spinner (upstream's
47    /// `Status(renderable)`), in a `Table.grid` as [`Spinner::renderable`].
48    pub fn from_renderable(renderable: Box<dyn Renderable + Send>) -> Self {
49        Status::from_content(SpinnerText::Renderable(Arc::new(Mutex::new(renderable))))
50    }
51
52    fn from_content(status: SpinnerText) -> Self {
53        let spinner_style = StyleType::Name("status.spinner".to_string());
54        Status {
55            spinner: Spinner::new("dots")
56                .content(&status)
57                .style(spinner_style.clone()),
58            status,
59            spinner_style,
60            speed: 1.0,
61            refresh_per_second: 12.5,
62        }
63    }
64
65    /// How often [`start`](Self::start)'s display redraws (upstream
66    /// `refresh_per_second`, default 12.5).
67    pub fn refresh_per_second(mut self, refresh_per_second: f64) -> Self {
68        self.refresh_per_second = refresh_per_second;
69        self
70    }
71
72    fn rebuild(mut self, name: &str) -> Self {
73        self.spinner = Spinner::new(name)
74            .content(&self.status)
75            .style(self.spinner_style.clone())
76            .speed(self.speed);
77        self
78    }
79
80    /// Choose the spinner animation by name (default `dots`).
81    pub fn spinner(self, name: &str) -> Self {
82        self.rebuild(name)
83    }
84
85    /// Style applied to the spinner frame (default `status.spinner`).
86    pub fn spinner_style(mut self, style: impl Into<StyleType>) -> Self {
87        self.spinner_style = style.into();
88        self.spinner = self.spinner.style(self.spinner_style.clone());
89        self
90    }
91
92    /// Set the spinner animation speed multiplier (default 1.0).
93    pub fn speed(mut self, speed: f64) -> Self {
94        self.speed = speed;
95        self.spinner = self.spinner.speed(speed);
96        self
97    }
98
99    /// Port of `Status.update`. `None` (and, as upstream, a zero speed) leaves
100    /// a field unchanged. A new spinner name builds a fresh spinner; otherwise
101    /// the current one is updated in place, a speed change continuing from its
102    /// current frame.
103    pub fn update(
104        &mut self,
105        status: Option<&str>,
106        spinner: Option<&str>,
107        spinner_style: Option<StyleType>,
108        speed: Option<f64>,
109    ) {
110        self.update_content(
111            status.map(|status| SpinnerText::Markup(status.to_string())),
112            spinner,
113            spinner_style,
114            speed,
115        );
116    }
117
118    /// [`update`](Self::update) with the status as a [`Text`], used as it is.
119    pub fn update_text(&mut self, status: Text) {
120        self.update_content(Some(SpinnerText::Text(status)), None, None, None);
121    }
122
123    /// [`update`](Self::update) with the status as any renderable.
124    pub fn update_renderable(&mut self, status: Box<dyn Renderable + Send>) {
125        let status = SpinnerText::Renderable(Arc::new(Mutex::new(status)));
126        self.update_content(Some(status), None, None, None);
127    }
128
129    fn update_content(
130        &mut self,
131        status: Option<SpinnerText>,
132        spinner: Option<&str>,
133        spinner_style: Option<StyleType>,
134        speed: Option<f64>,
135    ) {
136        if let Some(status) = status {
137            self.status = status;
138        }
139        if let Some(style) = spinner_style {
140            self.spinner_style = style;
141        }
142        if let Some(speed) = speed.filter(|s| *s != 0.0) {
143            self.speed = speed;
144        }
145        if let Some(name) = spinner {
146            self.spinner = Spinner::new(name)
147                .content(&self.status)
148                .style(self.spinner_style.clone())
149                .speed(self.speed);
150        } else {
151            self.spinner.update_content(&self.status);
152            self.spinner
153                .update(None, Some(self.spinner_style.clone()), Some(self.speed));
154        }
155    }
156
157    /// The underlying spinner. Mirrors upstream's `Status.renderable`.
158    pub fn renderable(&self) -> &Spinner {
159        &self.spinner
160    }
161}
162
163/// A [`Status`] shared with its display's refresh thread.
164struct StatusView(Arc<Mutex<Status>>);
165
166impl Renderable for StatusView {
167    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
168        lock(&self.0).rich_render(console, options)
169    }
170
171    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
172        lock(&self.0).measure(console, options)
173    }
174}
175
176fn lock(status: &Mutex<Status>) -> std::sync::MutexGuard<'_, Status> {
177    status
178        .lock()
179        .unwrap_or_else(|poisoned| poisoned.into_inner())
180}
181
182impl Status {
183    /// Start the status animation in a transient live display, redrawn
184    /// [`refresh_per_second`](Self::refresh_per_second) times a second on a
185    /// background thread. Port of `Status.start` (upstream's
186    /// `Live(transient=True)`, started without a first refresh, so the first
187    /// frame comes with the first tick); stop it with [`LiveStatus::stop`].
188    pub fn start<W: Write + Send + 'static>(self, console: Console, writer: W) -> LiveStatus<W> {
189        let refresh_per_second = self.refresh_per_second;
190        let shared = Arc::new(Mutex::new(self));
191        let live = Live::spawn_options(
192            Box::new(StatusView(shared.clone())),
193            console,
194            writer,
195            refresh_per_second,
196            LiveOptions {
197                transient: true,
198                refresh_on_start: false,
199                ..LiveOptions::default()
200            },
201        );
202        LiveStatus {
203            status: shared,
204            live,
205        }
206    }
207}
208
209/// A running [`Status`] display, returned by [`Status::start`].
210pub struct LiveStatus<W: Write + Send + 'static> {
211    status: Arc<Mutex<Status>>,
212    live: AutoLive<W>,
213}
214
215impl<W: Write + Send + 'static> LiveStatus<W> {
216    /// [`Status::update`] on the running status. As upstream, a new spinner
217    /// redraws at once (and returns once drawn); other changes show at the
218    /// next refresh.
219    pub fn update(
220        &self,
221        status: Option<&str>,
222        spinner: Option<&str>,
223        spinner_style: Option<StyleType>,
224        speed: Option<f64>,
225    ) {
226        lock(&self.status).update(status, spinner, spinner_style, speed);
227        if spinner.is_some() {
228            self.live.refresh_wait();
229        }
230    }
231
232    /// Run `f` on the running status (for [`Status::update_text`] and
233    /// [`Status::update_renderable`]); the change shows at the next refresh.
234    pub fn with<R>(&self, f: impl FnOnce(&mut Status) -> R) -> R {
235        f(&mut lock(&self.status))
236    }
237
238    /// Redraw now, returning once the frame is written (upstream's
239    /// `status._live.refresh()`).
240    pub fn refresh(&self) {
241        self.live.refresh_wait();
242    }
243
244    /// Print `renderable` above the status (upstream's `status.console.print`).
245    pub fn print(&self, renderable: Box<dyn Renderable + Send>) {
246        self.live.print(renderable);
247    }
248
249    /// [`print`](Self::print) a string as console markup.
250    pub fn print_str(&self, content: impl Into<String>) {
251        self.live.print_str(content);
252    }
253
254    /// A writer standing in for stdout while the status runs; see
255    /// [`AutoLive::stdout`].
256    pub fn stdout(&self) -> FileProxy<LiveHandle<W>, std::io::Stdout> {
257        self.live.stdout()
258    }
259
260    /// [`stdout`](Self::stdout) for stderr.
261    pub fn stderr(&self) -> FileProxy<LiveHandle<W>, std::io::Stderr> {
262        self.live.stderr()
263    }
264
265    /// A proxy for `stream` over any `file`.
266    pub fn file_proxy<F: Write>(
267        &self,
268        stream: ProxyStream,
269        file: F,
270    ) -> FileProxy<LiveHandle<W>, F> {
271        self.live.file_proxy(stream, file)
272    }
273
274    /// Stop the animation, erase the status, and return the output sink.
275    /// Port of `Status.stop`.
276    pub fn stop(self) -> W {
277        self.live.stop()
278    }
279}
280
281/// Upstream's `Status.__rich__` returns the spinner, so a status renders (and
282/// measures) exactly as its spinner: the frame for the console's clock.
283impl Renderable for Status {
284    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
285        self.spinner.rich_render(console, options)
286    }
287
288    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
289        self.spinner.measure(console, options)
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::color::ColorSystem;
297
298    fn render(status: &Status) -> String {
299        Console::builder()
300            .force_terminal(true)
301            .color_system(Some(ColorSystem::Truecolor))
302            .width(30)
303            .no_color(false)
304            .build()
305            .render_to_string(status)
306    }
307
308    #[test]
309    fn default_status_frame() {
310        // Captured from real rich 15.0.0 (dots spinner, status.spinner=green, t=0).
311        assert_eq!(
312            render(&Status::new("Loading data")),
313            "\x1b[32m⠋\x1b[0m Loading data"
314        );
315    }
316
317    #[test]
318    fn custom_spinner() {
319        assert_eq!(
320            render(&Status::new("Building").spinner("line")),
321            "\x1b[32m-\x1b[0m Building"
322        );
323    }
324}