Skip to main content

rich_rs/
live.rs

1//! Live: auto-updating display for terminal renderables.
2//!
3//! This is a Rust port of Python Rich's `Live` and `LiveRender`:
4//! - `rich/live.py`
5//! - `rich/live_render.py`
6//!
7//! The primary use case is to power the Progress system (Phase 5.1).
8
9use std::io;
10use std::io::Stdout;
11// Only used by the `#[cfg(unix)]` stream-redirect locks below; unused on Windows.
12#[cfg(unix)]
13use std::sync::OnceLock;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, Mutex};
16use std::thread;
17use std::time::Duration;
18
19use crossterm::terminal;
20
21use crate::Control;
22use crate::console::OverflowMethod;
23use crate::style::Style;
24use crate::text::Text;
25use crate::{Console, JustifyMethod, Renderable};
26
27#[cfg(unix)]
28use std::fs::File;
29#[cfg(unix)]
30use std::io::{BufRead, BufReader};
31#[cfg(unix)]
32use std::os::fd::{FromRawFd, RawFd};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum VerticalOverflowMethod {
36    Crop,
37    Ellipsis,
38    Visible,
39}
40
41impl Default for VerticalOverflowMethod {
42    fn default() -> Self {
43        Self::Ellipsis
44    }
45}
46
47#[derive(Debug, Clone)]
48pub struct LiveOptions {
49    pub screen: bool,
50    pub auto_refresh: bool,
51    pub refresh_per_second: f64,
52    pub transient: bool,
53    pub vertical_overflow: VerticalOverflowMethod,
54    /// When true, capture writes to stdout and route them through the Console output.
55    pub redirect_stdout: bool,
56    /// When true, capture writes to stderr and route them through the Console output.
57    pub redirect_stderr: bool,
58}
59
60impl Default for LiveOptions {
61    fn default() -> Self {
62        Self {
63            screen: false,
64            auto_refresh: true,
65            refresh_per_second: 4.0,
66            transient: false,
67            vertical_overflow: VerticalOverflowMethod::Ellipsis,
68            redirect_stdout: false,
69            redirect_stderr: false,
70        }
71    }
72}
73
74struct LiveState {
75    options: LiveOptions,
76    started: bool,
77    live_id: Option<usize>,
78    is_root: bool,
79    alt_screen: bool,
80    pending_renderable: Option<Box<dyn Renderable + Send + Sync>>,
81}
82
83/// A live-updating view of a renderable.
84///
85/// This owns a Console and drives updates by moving the cursor to re-render
86/// in-place. When `auto_refresh` is enabled, a background thread calls `refresh()`
87/// at `refresh_per_second`.
88pub struct Live {
89    console: Arc<Mutex<Console<Stdout>>>,
90    state: Arc<Mutex<LiveState>>,
91    stop_flag: Arc<AtomicBool>,
92    started_flag: Arc<AtomicBool>,
93    refresh_thread: Option<thread::JoinHandle<()>>,
94    #[cfg(unix)]
95    redirects: Vec<StreamRedirect>,
96    /// Optional callback to get the current renderable on each refresh tick.
97    /// When set, this is called instead of requiring manual `update()` calls.
98    get_renderable: Option<Arc<dyn Fn() -> Box<dyn Renderable + Send + Sync> + Send + Sync>>,
99}
100
101#[cfg(unix)]
102struct StreamRedirect {
103    target_fd: RawFd,
104    original_fd: RawFd,
105    pipe_write_fd: RawFd,
106    worker: thread::JoinHandle<()>,
107}
108
109#[cfg(unix)]
110unsafe extern "C" {
111    fn dup(oldfd: i32) -> i32;
112    fn dup2(oldfd: i32, newfd: i32) -> i32;
113    fn pipe(fds: *mut i32) -> i32;
114    fn close(fd: i32) -> i32;
115}
116
117#[cfg(unix)]
118fn stream_redirect_lock() -> &'static Mutex<()> {
119    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
120    LOCK.get_or_init(|| Mutex::new(()))
121}
122
123impl Live {
124    pub fn new(renderable: Box<dyn Renderable + Send + Sync>) -> Self {
125        Self::with_console(renderable, Console::new(), LiveOptions::default())
126    }
127
128    pub fn with_options(
129        renderable: Box<dyn Renderable + Send + Sync>,
130        options: LiveOptions,
131    ) -> Self {
132        Self::with_console(renderable, Console::new(), options)
133    }
134
135    pub fn with_console(
136        renderable: Box<dyn Renderable + Send + Sync>,
137        console: Console<Stdout>,
138        options: LiveOptions,
139    ) -> Self {
140        assert!(
141            options.refresh_per_second > 0.0,
142            "refresh_per_second must be > 0"
143        );
144
145        let transient = if options.screen {
146            true
147        } else {
148            options.transient
149        };
150        let options = LiveOptions {
151            transient,
152            ..options
153        };
154        let state = LiveState {
155            options,
156            started: false,
157            live_id: None,
158            is_root: false,
159            alt_screen: false,
160            pending_renderable: Some(renderable),
161        };
162
163        Live {
164            console: Arc::new(Mutex::new(console)),
165            state: Arc::new(Mutex::new(state)),
166            stop_flag: Arc::new(AtomicBool::new(false)),
167            started_flag: Arc::new(AtomicBool::new(false)),
168            refresh_thread: None,
169            #[cfg(unix)]
170            redirects: Vec::new(),
171            get_renderable: None,
172        }
173    }
174
175    pub fn is_started(&self) -> bool {
176        self.started_flag.load(Ordering::SeqCst)
177    }
178
179    /// Set a callback that provides the renderable on each refresh tick.
180    ///
181    /// When set, this function is called on each refresh to get the latest
182    /// renderable to display, instead of requiring manual `update()` calls.
183    pub fn with_get_renderable(
184        mut self,
185        f: impl Fn() -> Box<dyn Renderable + Send + Sync> + Send + Sync + 'static,
186    ) -> Self {
187        self.get_renderable = Some(Arc::new(f));
188        self
189    }
190
191    pub(crate) fn started_flag(&self) -> Arc<AtomicBool> {
192        self.started_flag.clone()
193    }
194
195    pub(crate) fn refresh_per_second(&self) -> f64 {
196        self.state
197            .lock()
198            .expect("live state mutex poisoned")
199            .options
200            .refresh_per_second
201    }
202
203    pub fn start(&mut self, refresh: bool) -> io::Result<()> {
204        let mut state = self.state.lock().expect("live state mutex poisoned");
205        if state.started {
206            return Ok(());
207        }
208
209        let mut console = self.console.lock().expect("console mutex poisoned");
210        sync_terminal_size(&mut console);
211
212        let interactive = console.is_terminal() && !console.is_dumb_terminal();
213        if !interactive {
214            // Degrade gracefully in non-interactive or dumb terminals: don't attempt
215            // cursor control, and render final output once on stop (if non-transient).
216            state.started = true;
217            state.live_id = None;
218            state.is_root = false;
219            state.alt_screen = false;
220            self.started_flag.store(false, Ordering::SeqCst);
221            return Ok(());
222        }
223
224        let renderable = state
225            .pending_renderable
226            .take()
227            .unwrap_or_else(|| Box::new(Text::plain("")));
228
229        let live_options = state.options.clone();
230        let (id, is_root) = console.live_start(renderable, live_options.vertical_overflow);
231        state.live_id = Some(id);
232        state.is_root = is_root;
233        state.started = true;
234        self.started_flag.store(true, Ordering::SeqCst);
235
236        if is_root {
237            if live_options.screen {
238                state.alt_screen = console.set_alt_screen(true)?;
239            }
240            let _ = console.show_cursor(false)?;
241        }
242
243        drop(console);
244        let auto_refresh = live_options.auto_refresh;
245        let is_root = state.is_root;
246        drop(state);
247
248        if is_root {
249            self.start_redirects(&live_options)?;
250        }
251
252        if refresh {
253            self.refresh()?;
254        }
255
256        if auto_refresh && is_root {
257            self.spawn_refresh_thread();
258        }
259
260        Ok(())
261    }
262
263    pub fn stop(&mut self) -> io::Result<()> {
264        self.stop_flag.store(true, Ordering::SeqCst);
265        if let Some(handle) = self.refresh_thread.take() {
266            let _ = handle.join();
267        }
268        self.stop_redirects();
269        self.started_flag.store(false, Ordering::SeqCst);
270
271        let mut state = self.state.lock().expect("live state mutex poisoned");
272        if !state.started {
273            return Ok(());
274        }
275        state.started = false;
276
277        let id = state.live_id.take();
278        let is_root = state.is_root;
279        state.is_root = false;
280        let alt_screen = state.alt_screen;
281        state.alt_screen = false;
282        let options = state.options.clone();
283
284        let Some(id) = id else {
285            // Non-interactive / dumb terminal path: render final output once if non-transient.
286            if !options.transient {
287                let renderable = state
288                    .pending_renderable
289                    .take()
290                    .unwrap_or_else(|| Box::new(Text::plain("")));
291                drop(state);
292                let mut console = self.console.lock().expect("console mutex poisoned");
293                let _ = console.print(renderable.as_ref(), None, None, None, false, "\n");
294            }
295            return Ok(());
296        };
297
298        let mut console = self.console.lock().expect("console mutex poisoned");
299
300        // Nested Live stop behavior (Rich): remove from stack, optionally print final renderable.
301        if !is_root {
302            let renderable = console.live_stop(id);
303            if !options.transient {
304                if let Some(renderable) = renderable {
305                    let _ = console.print(renderable.as_ref(), None, None, None, false, "\n");
306                }
307            } else if console.is_terminal() && !console.is_dumb_terminal() {
308                // Ensure the nested entry disappears immediately.
309                let _ = console.print(&Control::new(), None, None, None, false, "");
310            }
311            return Ok(());
312        }
313
314        // Best-effort final refresh (matches Rich's stop behavior for terminal output).
315        if is_root && console.is_terminal() && !console.is_dumb_terminal() && !alt_screen {
316            console.live_set_vertical_overflow(id, VerticalOverflowMethod::Visible);
317            let _ = console.print(&Control::new(), None, None, None, false, "");
318        }
319
320        // Capture transient restore controls before clearing live state. Rich applies
321        // restore after printing a newline, so the cursor starts below the live region.
322        let restore_controls = if is_root
323            && console.is_terminal()
324            && !console.is_dumb_terminal()
325            && options.transient
326            && !alt_screen
327        {
328            console.live_restore_cursor()
329        } else {
330            crate::Segments::new()
331        };
332
333        // Unregister this live instance (root clears the full stack, like Rich).
334        console.live_clear();
335
336        // Root cleanup (cursor / screen / final newline).
337        if is_root {
338            if console.is_terminal() && !alt_screen {
339                let _ = console.print(&Text::plain(""), None, None, None, false, "\n");
340            }
341
342            let _ = console.show_cursor(true);
343            if alt_screen {
344                let _ = console.set_alt_screen(false);
345            }
346        }
347
348        if !restore_controls.is_empty() {
349            let _ = console.print_segments(&restore_controls);
350        }
351
352        Ok(())
353    }
354
355    pub fn update(
356        &self,
357        renderable: Box<dyn Renderable + Send + Sync>,
358        refresh: bool,
359    ) -> io::Result<()> {
360        let (id, started) = {
361            let mut state = self.state.lock().expect("live state mutex poisoned");
362            if !state.started {
363                state.pending_renderable = Some(renderable);
364                return Ok(());
365            }
366            if state.live_id.is_none() {
367                // Non-interactive / dumb terminal path: just keep the latest renderable.
368                state.pending_renderable = Some(renderable);
369                return Ok(());
370            }
371            (state.live_id, state.started)
372        };
373
374        if started {
375            if let Some(id) = id {
376                let mut console = self.console.lock().expect("console mutex poisoned");
377                console.live_update(id, renderable);
378            }
379        }
380        if refresh {
381            self.refresh()?;
382        }
383        Ok(())
384    }
385
386    pub fn refresh(&self) -> io::Result<()> {
387        let state = self.state.lock().expect("live state mutex poisoned");
388        if !state.started {
389            return Ok(());
390        }
391        if state.live_id.is_none() {
392            // Non-interactive / dumb terminal path: don't attempt cursor control.
393            return Ok(());
394        }
395        drop(state);
396        let mut console = self.console.lock().expect("console mutex poisoned");
397        sync_terminal_size(&mut console);
398        console.print(&Control::new(), None, None, None, false, "")
399    }
400
401    pub fn print<R: Renderable + ?Sized>(
402        &self,
403        renderable: &R,
404        style: Option<Style>,
405        justify: Option<JustifyMethod>,
406        overflow: Option<OverflowMethod>,
407        no_wrap: bool,
408        end: &str,
409    ) -> io::Result<()> {
410        let mut console = self.console.lock().expect("console mutex poisoned");
411        console.print(renderable, style, justify, overflow, no_wrap, end)
412    }
413
414    pub fn log<R: Renderable + ?Sized>(
415        &self,
416        renderable: &R,
417        file: Option<&str>,
418        line: Option<u32>,
419    ) -> io::Result<()> {
420        let mut console = self.console.lock().expect("console mutex poisoned");
421        console.log(renderable, file, line)
422    }
423
424    fn spawn_refresh_thread(&mut self) {
425        if self.refresh_thread.is_some() {
426            return;
427        }
428
429        self.stop_flag.store(false, Ordering::SeqCst);
430        let stop_flag = self.stop_flag.clone();
431        let console = self.console.clone();
432        let state = self.state.clone();
433        let get_renderable = self.get_renderable.clone();
434        let refresh_per_second = state
435            .lock()
436            .expect("live state mutex poisoned")
437            .options
438            .refresh_per_second;
439
440        let handle = thread::spawn(move || {
441            let sleep = Duration::from_secs_f64(1.0 / refresh_per_second.max(0.001));
442            while !stop_flag.load(Ordering::SeqCst) {
443                thread::sleep(sleep);
444                if stop_flag.load(Ordering::SeqCst) {
445                    break;
446                }
447                let state_guard = match state.lock() {
448                    Ok(g) => g,
449                    Err(_) => break,
450                };
451                if !state_guard.started {
452                    continue;
453                }
454                let live_id = state_guard.live_id;
455                drop(state_guard);
456
457                // If get_renderable is set, call it and update the live display.
458                if let Some(ref get_renderable) = get_renderable {
459                    if let Some(id) = live_id {
460                        let renderable = get_renderable();
461                        let mut console_guard = match console.lock() {
462                            Ok(g) => g,
463                            Err(_) => break,
464                        };
465                        console_guard.live_update(id, renderable);
466                        sync_terminal_size(&mut console_guard);
467                        let _ = console_guard.print(&Control::new(), None, None, None, false, "");
468                        continue;
469                    }
470                }
471
472                let mut console_guard = match console.lock() {
473                    Ok(g) => g,
474                    Err(_) => break,
475                };
476                sync_terminal_size(&mut console_guard);
477                let _ = console_guard.print(&Control::new(), None, None, None, false, "");
478            }
479        });
480
481        self.refresh_thread = Some(handle);
482    }
483
484    #[cfg(not(unix))]
485    fn start_redirects(&mut self, _options: &LiveOptions) -> io::Result<()> {
486        Ok(())
487    }
488
489    #[cfg(unix)]
490    fn start_redirects(&mut self, options: &LiveOptions) -> io::Result<()> {
491        if options.redirect_stdout {
492            self.start_redirect_stream(1)?;
493        }
494        if options.redirect_stderr {
495            self.start_redirect_stream(2)?;
496        }
497        Ok(())
498    }
499
500    #[cfg(not(unix))]
501    fn stop_redirects(&mut self) {}
502
503    #[cfg(unix)]
504    fn stop_redirects(&mut self) {
505        for redirect in self.redirects.drain(..) {
506            let _guard = stream_redirect_lock()
507                .lock()
508                .expect("redirect lock mutex poisoned");
509            let _ = unsafe { dup2(redirect.original_fd, redirect.target_fd) };
510            let _ = unsafe { close(redirect.pipe_write_fd) };
511            let _ = unsafe { close(redirect.original_fd) };
512            drop(_guard);
513            let _ = redirect.worker.join();
514        }
515    }
516
517    #[cfg(unix)]
518    fn start_redirect_stream(&mut self, target_fd: RawFd) -> io::Result<()> {
519        let mut fds = [0_i32; 2];
520        if unsafe { pipe(fds.as_mut_ptr()) } == -1 {
521            return Err(io::Error::last_os_error());
522        }
523        let read_fd = fds[0];
524        let write_fd = fds[1];
525
526        let original_fd = unsafe { dup(target_fd) };
527        if original_fd == -1 {
528            let _ = unsafe { close(read_fd) };
529            let _ = unsafe { close(write_fd) };
530            return Err(io::Error::last_os_error());
531        }
532
533        if unsafe { dup2(write_fd, target_fd) } == -1 {
534            let _ = unsafe { close(read_fd) };
535            let _ = unsafe { close(write_fd) };
536            let _ = unsafe { close(original_fd) };
537            return Err(io::Error::last_os_error());
538        }
539
540        let console = self.console.clone();
541        let worker = thread::spawn(move || {
542            let file = unsafe { File::from_raw_fd(read_fd) };
543            let mut reader = BufReader::new(file);
544            let mut buf = Vec::<u8>::new();
545
546            loop {
547                buf.clear();
548                let bytes = match reader.read_until(b'\n', &mut buf) {
549                    Ok(n) => n,
550                    Err(_) => break,
551                };
552                if bytes == 0 {
553                    break;
554                }
555
556                let has_newline = buf.last().copied() == Some(b'\n');
557                let text_slice = if has_newline {
558                    &buf[..buf.len().saturating_sub(1)]
559                } else {
560                    &buf[..]
561                };
562                if text_slice.is_empty() && has_newline {
563                    continue;
564                }
565                let text = String::from_utf8_lossy(text_slice).to_string();
566                let end = if has_newline { "\n" } else { "" };
567
568                let _guard = stream_redirect_lock()
569                    .lock()
570                    .expect("redirect lock mutex poisoned");
571                if unsafe { dup2(original_fd, target_fd) } == -1 {
572                    break;
573                }
574                {
575                    let mut guard = match console.lock() {
576                        Ok(g) => g,
577                        Err(_) => break,
578                    };
579                    let _ = guard.print(&Text::plain(text), None, None, None, false, end);
580                }
581                if unsafe { dup2(write_fd, target_fd) } == -1 {
582                    break;
583                }
584            }
585        });
586
587        self.redirects.push(StreamRedirect {
588            target_fd,
589            original_fd,
590            pipe_write_fd: write_fd,
591            worker,
592        });
593        Ok(())
594    }
595}
596
597impl Drop for Live {
598    fn drop(&mut self) {
599        // Best effort cleanup; ignore IO errors.
600        let _ = self.stop();
601    }
602}
603
604fn sync_terminal_size(console: &mut Console<Stdout>) {
605    if !console.is_terminal() {
606        return;
607    }
608    if let Ok((w, h)) = terminal::size() {
609        let w = w as usize;
610        let h = h as usize;
611        let opts = console.options_mut();
612        opts.size = (w, h);
613        opts.max_width = w.max(1);
614        opts.max_height = h;
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_refresh_per_second_accessor() {
624        let live = Live::with_options(
625            Box::new(Text::plain("x")),
626            LiveOptions {
627                refresh_per_second: 7.5,
628                ..Default::default()
629            },
630        );
631        assert_eq!(live.refresh_per_second(), 7.5);
632    }
633
634    #[cfg(unix)]
635    fn redirect_test_lock() -> &'static Mutex<()> {
636        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
637        LOCK.get_or_init(|| Mutex::new(()))
638    }
639
640    #[cfg(unix)]
641    #[test]
642    fn test_redirect_stdout_lifecycle() {
643        let _guard = redirect_test_lock()
644            .lock()
645            .expect("redirect test lock poisoned");
646        let mut live = Live::with_options(
647            Box::new(Text::plain("x")),
648            LiveOptions {
649                redirect_stdout: true,
650                ..Default::default()
651            },
652        );
653        let options = LiveOptions {
654            redirect_stdout: true,
655            ..Default::default()
656        };
657        live.start_redirects(&options).unwrap();
658        assert_eq!(live.redirects.len(), 1);
659        live.stop_redirects();
660        assert!(live.redirects.is_empty());
661    }
662}