Skip to main content

rusty_rich/
live.rs

1//! Live — auto-updating display. Equivalent to Rich's `live.py`.
2//!
3//! [`Live`] manages a terminal region that updates in-place. Each refresh
4//! overwrites the previous output, creating an auto-updating display.
5//!
6//! # Quick Example
7//!
8//! ```rust,no_run
9//! use rusty_rich::{Live, panel::Panel};
10//! use std::thread;
11//! use std::time::Duration;
12//!
13//! let mut live = Live::new(Panel::new("Loading...").title("Progress"));
14//! live.start().unwrap();
15//!
16//! for i in 0..=100 {
17//!     live.update(Panel::new(format!("{}%", i)).title("Progress")).unwrap();
18//!     thread::sleep(Duration::from_millis(50));
19//! }
20//!
21//! live.stop().unwrap();
22//! ```
23//!
24//! # LiveWriter
25//!
26//! [`LiveWriter`] captures `write!` output and displays it within the live
27//! region. Use [`Live::create_writer`] to create one, then write to it while
28//! the live display is active:
29//!
30//! ```rust,no_run
31//! use rusty_rich::{Live, panel::Panel};
32//! use std::io::Write;
33//!
34//! let mut live = Live::new(Panel::new("Status").title("App"));
35//! let mut writer = Live::create_writer();
36//! live.start().unwrap();
37//!
38//! writeln!(writer, "Processing item 1...").unwrap();
39//! writeln!(writer, "Done!").unwrap();
40//!
41//! live.stop().unwrap();
42//! ```
43//!
44//! # Transient Mode
45//!
46//! Call [`Live::transient`] to erase the live region on stop — the output
47//! disappears as if it was never there. Useful for "loading…" overlays.
48
49use std::io::{self, Write};
50use std::time::Instant;
51
52use crate::console::{ConsoleOptions, DynRenderable, Renderable};
53use crate::segment::Segment;
54
55/// A writer that captures output for live display.
56pub struct LiveWriter {
57    buffer: Vec<u8>,
58}
59
60impl LiveWriter {
61    /// Create a new `LiveWriter` with an empty capture buffer.
62    pub fn new() -> Self {
63        Self { buffer: Vec::new() }
64    }
65
66    /// Return a reference to the captured output bytes.
67    pub fn capture(&self) -> &[u8] {
68        &self.buffer
69    }
70
71    /// Clear the captured output buffer.
72    pub fn clear(&mut self) {
73        self.buffer.clear();
74    }
75}
76
77impl Write for LiveWriter {
78    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
79        self.buffer.extend_from_slice(buf);
80        Ok(buf.len())
81    }
82
83    fn flush(&mut self) -> io::Result<()> {
84        Ok(())
85    }
86}
87
88/// A hook that transforms render output.
89///
90/// [`RenderHook`] provides a way to intercept and modify the rendered
91/// segment lines before they are written to the terminal. Multiple hooks
92/// can be registered on a [`Live`] display and are applied in order.
93///
94/// # Example
95///
96/// ```rust,no_run
97/// use rusty_rich::live::RenderHook;
98/// use rusty_rich::Segment;
99///
100/// let hook = RenderHook::new(|lines| {
101///     // Reverse the order of displayed lines
102///     let mut reversed = lines.to_vec();
103///     reversed.reverse();
104///     reversed
105/// });
106/// ```
107pub struct RenderHook {
108    hook: Box<dyn Fn(&[Vec<Segment>]) -> Vec<Vec<Segment>> + Send>,
109}
110
111impl RenderHook {
112    /// Create a new [`RenderHook`] with the given transformation function.
113    ///
114    /// The function receives the current rendered lines of segments and
115    /// returns the modified lines.
116    pub fn new<F>(hook: F) -> Self
117    where
118        F: Fn(&[Vec<Segment>]) -> Vec<Vec<Segment>> + Send + 'static,
119    {
120        Self {
121            hook: Box::new(hook),
122        }
123    }
124
125    /// Apply this hook to the given segments, returning the transformed segments.
126    pub fn apply(&self, segments: &[Vec<Segment>]) -> Vec<Vec<Segment>> {
127        (self.hook)(segments)
128    }
129}
130
131/// Manages a live-updating region of the terminal.
132pub struct Live {
133    renderable: Option<DynRenderable>,
134    screen: bool,
135    auto_refresh: bool,
136    refresh_per_second: f64,
137    transient: bool,
138    started: bool,
139    started_at: Option<Instant>,
140    previous_line_count: usize,
141    redirect_stdout: bool,
142    redirect_stderr: bool,
143    writers: Vec<LiveWriter>,
144    render_hooks: Vec<RenderHook>,
145}
146
147impl std::fmt::Debug for Live {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct("Live")
150            .field("screen", &self.screen)
151            .field("started", &self.started)
152            .finish()
153    }
154}
155
156impl Live {
157    /// Create a new `Live` display wrapping the given [`Renderable`].
158    pub fn new(renderable: impl Renderable + Send + Sync + 'static) -> Self {
159        Self {
160            renderable: Some(DynRenderable::new(renderable)),
161            screen: false,
162            auto_refresh: true,
163            refresh_per_second: 4.0,
164            transient: false,
165            started: false,
166            started_at: None,
167            previous_line_count: 0,
168            redirect_stdout: true,
169            redirect_stderr: true,
170            writers: Vec::new(),
171            render_hooks: Vec::new(),
172        }
173    }
174
175    /// Builder: use the alternate screen buffer for full-screen display.
176    pub fn screen(mut self) -> Self { self.screen = true; self }
177    /// Builder: disable automatic periodic refresh.
178    pub fn no_auto_refresh(mut self) -> Self { self.auto_refresh = false; self }
179    /// Builder: set the refresh rate in Hz (default 4.0).
180    pub fn refresh_per_second(mut self, rate: f64) -> Self { self.refresh_per_second = rate; self }
181    /// Builder: enable transient mode (live display disappears on stop).
182    pub fn transient(mut self) -> Self { self.transient = true; self }
183    /// Builder: redirect stdout writes into the live display.
184    pub fn redirect_stdout(mut self, redirect: bool) -> Self { self.redirect_stdout = redirect; self }
185    /// Builder: redirect stderr writes into the live display.
186    pub fn redirect_stderr(mut self, redirect: bool) -> Self { self.redirect_stderr = redirect; self }
187
188    /// Register a writer whose captured content will be rendered during refresh.
189    pub fn add_writer(&mut self, writer: LiveWriter) { self.writers.push(writer); }
190
191    /// Create a LiveWriter that captures output while Live is active.
192    pub fn create_writer() -> LiveWriter {
193        LiveWriter::new()
194    }
195
196    /// Start the live display: enter alternate screen (if configured) and hide cursor.
197    pub fn start(&mut self) -> io::Result<()> {
198        self.started = true;
199        self.started_at = Some(Instant::now());
200        if self.screen {
201            write!(io::stdout(), "\x1b[?1049h")?;
202        }
203        write!(io::stdout(), "\x1b[?25l")?;
204        self.refresh()
205    }
206
207    /// Stop the live display: restore cursor, exit alternate screen, and clean up.
208    pub fn stop(&mut self) -> io::Result<()> {
209        if self.transient {
210            for _ in 0..self.previous_line_count {
211                write!(io::stdout(), "\x1b[1A\x1b[2K")?;
212            }
213        }
214        if self.screen {
215            write!(io::stdout(), "\x1b[?1049l")?;
216        }
217        write!(io::stdout(), "\x1b[?25h")?;
218        io::stdout().flush()?;
219        self.started = false;
220        self.started_at = None;
221        Ok(())
222    }
223
224    /// Replace the displayed content and refresh immediately.
225    pub fn update(&mut self, renderable: impl Renderable + Send + Sync + 'static) -> io::Result<()> {
226        self.renderable = Some(DynRenderable::new(renderable));
227        self.refresh()
228    }
229
230    /// Re-render the current content in place (cursor is moved back to overwrite previous output).
231    ///
232    /// If any [`RenderHook`]s are registered, they are applied to the rendered
233    /// segment lines before the output is written to the terminal.
234    pub fn refresh(&mut self) -> io::Result<()> {
235        if let Some(ref renderable) = self.renderable {
236            let opts = ConsoleOptions::default();
237            let result = renderable.render(&opts);
238
239            if self.previous_line_count > 0 {
240                write!(io::stdout(), "\x1b[{}F", self.previous_line_count)?;
241            }
242
243            // Apply render hooks to transform segment lines before output
244            let (ansi, line_count) = if !self.render_hooks.is_empty() {
245                let mut lines = result.lines.clone();
246                for hook in &self.render_hooks {
247                    lines = hook.apply(&lines);
248                }
249                let mut out = String::new();
250                for line in &lines {
251                    for seg in line {
252                        out.push_str(&seg.to_ansi());
253                    }
254                }
255                (out, lines.len())
256            } else {
257                let s = result.to_ansi();
258                let c = s.lines().count();
259                (s, c)
260            };
261
262            write!(io::stdout(), "{ansi}")?;
263            if line_count < self.previous_line_count {
264                for _ in line_count..self.previous_line_count {
265                    write!(io::stdout(), "\x1b[2K\n")?;
266                }
267            }
268
269            self.previous_line_count = line_count;
270
271            // Write captured writer content
272            for writer in &self.writers {
273                let captured = String::from_utf8_lossy(writer.capture());
274                if !captured.is_empty() {
275                    write!(io::stdout(), "{}", captured)?;
276                }
277            }
278
279            io::stdout().flush()?;
280        }
281        Ok(())
282    }
283
284    /// Check if the live display is currently running.
285    pub fn is_started(&self) -> bool {
286        self.started
287    }
288
289    /// Get a reference to the current renderable.
290    ///
291    /// # Panics
292    ///
293    /// Panics if no renderable has been set (this should not happen with
294    /// normal usage, as `Live::new` always creates one).
295    pub fn get_renderable(&self) -> &dyn Renderable {
296        self.renderable.as_ref().unwrap() as &dyn Renderable
297    }
298
299    /// Get the current renderable being displayed.
300    ///
301    /// # Panics
302    ///
303    /// Panics if no renderable has been set (this should not happen with
304    /// normal usage, as `Live::new` always creates one).
305    pub fn renderable(&self) -> &dyn Renderable {
306        self.renderable.as_ref().unwrap() as &dyn Renderable
307    }
308
309    /// Process multiple renderables through the Live display pipeline.
310    ///
311    /// Each renderable is rendered with the given options, and the resulting
312    /// segment lines are collected into a single vector.
313    pub fn process_renderables(
314        &self,
315        renderables: &[Box<dyn Renderable>],
316        options: &ConsoleOptions,
317    ) -> Vec<Vec<Segment>> {
318        let mut all_lines = Vec::new();
319        for renderable in renderables {
320            let result = renderable.render(options);
321            all_lines.extend(result.lines);
322        }
323        all_lines
324    }
325
326    /// Add a render hook to the live display.
327    ///
328    /// Hooks are applied in registration order during each refresh, allowing
329    /// transformation of the rendered segment lines before they are output.
330    pub fn add_render_hook(&mut self, hook: RenderHook) {
331        self.render_hooks.push(hook);
332    }
333}
334
335impl Drop for Live {
336    fn drop(&mut self) {
337        let _ = self.stop();
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::text::Text;
345
346    #[test]
347    fn test_is_started() {
348        let mut live = Live::new(Text::new("test"));
349        assert!(!live.is_started());
350        live.start().unwrap();
351        assert!(live.is_started());
352        live.stop().unwrap();
353        assert!(!live.is_started());
354    }
355
356    #[test]
357    fn test_renderable_accessor() {
358        let live = Live::new(Text::new("hello"));
359        let r = live.get_renderable();
360        // Verify we get a valid reference by rendering it
361        let opts = ConsoleOptions::default();
362        let result = r.render(&opts);
363        assert!(!result.to_ansi().is_empty());
364    }
365
366    #[test]
367    fn test_render_hook_basic() {
368        let hook = RenderHook::new(|segments| segments.to_vec());
369        let input = vec![vec![Segment::new("test")]];
370        let output = hook.apply(&input);
371        assert_eq!(output.len(), 1);
372        assert_eq!(output[0][0].text, "test");
373    }
374
375    #[test]
376    fn test_render_hook_transform() {
377        let hook = RenderHook::new(|segments| {
378            let mut transformed = segments.to_vec();
379            transformed.push(vec![Segment::new("appended")]);
380            transformed
381        });
382        let input = vec![vec![Segment::new("original")]];
383        let output = hook.apply(&input);
384        assert_eq!(output.len(), 2);
385        assert_eq!(output[1][0].text, "appended");
386    }
387
388    #[test]
389    fn test_process_renderables() {
390        let live = Live::new(Text::new("dummy"));
391        let opts = ConsoleOptions::default();
392        let renderables: Vec<Box<dyn Renderable>> = vec![
393            Box::new(Text::new("first")),
394            Box::new(Text::new("second")),
395        ];
396        let lines = live.process_renderables(&renderables, &opts);
397        assert!(!lines.is_empty());
398    }
399
400    #[test]
401    fn test_start_stop_cycle() {
402        let mut live = Live::new(Text::new("test"));
403        assert!(!live.is_started());
404        live.start().unwrap();
405        assert!(live.is_started());
406        live.stop().unwrap();
407        assert!(!live.is_started());
408    }
409
410    #[test]
411    fn test_add_render_hook() {
412        let mut live = Live::new(Text::new("test"));
413        let hook = RenderHook::new(|segments| segments.to_vec());
414        live.add_render_hook(hook);
415        assert_eq!(live.render_hooks.len(), 1);
416    }
417}