Skip to main content

rskit_cli/live/
renderer.rs

1//! [`LiveConsole`] — a multi-region live terminal renderer.
2//!
3//! Renders several concurrent output streams as fixed-height "tiles" stacked in
4//! a live area at the bottom of the terminal, each showing a bounded virtual
5//! terminal of one stream (via [`RegionScreen`]). The tiles are an ephemeral
6//! progress peek: rows that scroll off the top are dropped from the live view,
7//! not flushed to scrollback, so a chatty stream does not flood the transcript.
8//! Durable signal is emitted only at [`finish`](LiveConsole::finish) time — a
9//! one-line verdict — and, for a failed region, a bounded replay of its retained
10//! tail via [`finish_with_replay`](LiveConsole::finish_with_replay).
11//!
12//! The terminal mechanics (cursor positioning, redraw rate-limiting, width
13//! handling, resize) are delegated to `indicatif`'s multi-progress engine, so
14//! this type only owns the tile layout, the bounded failure ring, and the
15//! verdict flush. It is generic: callers feed labeled byte streams and get
16//! terminal rendering out, with no domain types involved.
17
18use std::collections::HashMap;
19use std::collections::VecDeque;
20
21use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
22
23use super::screen::RegionScreen;
24
25/// The string each tile indents its content by, beneath the region header. The
26/// virtual terminal grid is sized to the tile width minus this indent's width
27/// so a child's output fills the visible content area exactly, without being
28/// chopped.
29const TILE_INDENT: &str = "  ";
30
31/// How the live console lays out and truncates tiles.
32///
33/// The console does not auto-detect the terminal width or react to resizes:
34/// `cols` is a fixed tile width the caller supplies once. Passing a value that
35/// does not match the real terminal only over- or under-truncates the tiles; it
36/// never corrupts output, since every tile line is clamped to `cols`.
37#[derive(Debug, Clone, Copy)]
38pub struct LiveConfig {
39    /// Maximum content rows a region tile may occupy — the height of its virtual
40    /// terminal and the cap a tile grows to. A tile does not reserve this height:
41    /// it starts at just its header and grows with its content up to this many
42    /// rows (see [`LiveConsole::feed`]), so a silent or short-lived region stays
43    /// small instead of padding out a tall empty block.
44    pub rows: usize,
45    /// Terminal columns: the tile width. A child's output is applied to a grid
46    /// sized to the visible content area (this width minus the content indent),
47    /// so a real width must be passed (unlike the old truncation width, `0` is
48    /// not "disable").
49    pub cols: usize,
50    /// How many rows that scroll off a tile are retained per region for a
51    /// failure replay. The live tile stays a bounded peek; on failure this many
52    /// of the most-recent scrolled-off rows (plus the final on-screen rows) are
53    /// flushed to scrollback as the failure block. `0` retains nothing, so a
54    /// failure replays only the rows still on screen.
55    pub scrollback: usize,
56}
57
58impl Default for LiveConfig {
59    fn default() -> Self {
60        Self {
61            rows: 5,
62            cols: 80,
63            scrollback: 200,
64        }
65    }
66}
67
68impl LiveConfig {
69    /// The inner virtual-terminal grid width: the tile width minus the content
70    /// indent rendered under each region header.
71    ///
72    /// A child whose output is fed to the tile must be told its terminal is this
73    /// wide (not the full tile width), so its own line wrapping matches the grid.
74    /// Otherwise a full-width in-place progress redraw wraps at the grid edge,
75    /// scrolls the short grid, and churns the retained failure tail with stale
76    /// half-frames on every tick — which then surface in the bounded replay when
77    /// a region fails.
78    #[must_use]
79    pub fn content_cols(&self) -> usize {
80        self.cols.saturating_sub(TILE_INDENT.len()).max(1)
81    }
82}
83
84/// One in-flight region: its virtual terminal, label, backing progress line,
85/// and a bounded ring of the rows that have scrolled off its tile — kept only
86/// so a failure can replay recent context.
87struct Region {
88    bar: ProgressBar,
89    screen: RegionScreen,
90    label: String,
91    retained: VecDeque<String>,
92    /// The tallest content the tile has shown so far, capped at the grid height.
93    /// The tile is rendered to this many rows so it grows with output but never
94    /// shrinks mid-run — output that clears (an in-place progress redraw) leaves
95    /// the tile at its high-water height rather than collapsing and reflowing.
96    high_water: usize,
97}
98
99/// A live, multi-region terminal renderer.
100///
101/// Lifecycle per region: [`begin`](Self::begin) with a label,
102/// [`feed`](Self::feed) raw bytes as they arrive, then [`finish`](Self::finish)
103/// with a one-line verdict. [`set_header`](Self::set_header) updates a status
104/// line pinned above the tiles.
105pub struct LiveConsole {
106    multi: MultiProgress,
107    header: ProgressBar,
108    regions: HashMap<String, Region>,
109    config: LiveConfig,
110}
111
112impl LiveConsole {
113    /// Create a console that renders to stderr.
114    ///
115    /// stderr keeps the live UI off stdout, leaving any machine-readable stream
116    /// there uncorrupted.
117    #[must_use]
118    pub fn to_stderr(config: LiveConfig) -> Self {
119        Self::with_target(ProgressDrawTarget::stderr(), config)
120    }
121
122    /// Create a console whose output is discarded — for tests and non-terminal
123    /// runs where the live area must not render.
124    #[must_use]
125    pub fn hidden(config: LiveConfig) -> Self {
126        Self::with_target(ProgressDrawTarget::hidden(), config)
127    }
128
129    fn with_target(target: ProgressDrawTarget, mut config: LiveConfig) -> Self {
130        // Keep the grid and the renderer consistent: a tile always shows at
131        // least one content row and its virtual terminal needs a real width.
132        config.rows = config.rows.max(1);
133        config.cols = config.cols.max(1);
134        let multi = MultiProgress::with_draw_target(target);
135        let header = multi.add(ProgressBar::new(0));
136        header.set_style(message_style());
137        Self {
138            multi,
139            header,
140            regions: HashMap::new(),
141            config,
142        }
143    }
144
145    /// The virtual terminal grid width: the tile width minus the content
146    /// indent, so a child fills the visible content area without being chopped.
147    /// Children feeding a tile should be sized to this; see
148    /// [`LiveConfig::content_cols`].
149    #[must_use]
150    pub fn content_cols(&self) -> usize {
151        self.config.content_cols()
152    }
153
154    /// Set the status line shown above the tiles.
155    pub fn set_header(&self, text: impl Into<String>) {
156        self.header.set_message(text.into());
157    }
158
159    /// Start a new region tile labeled `label`, keyed by `id`.
160    ///
161    /// A duplicate `id` discards the existing region first — dropping its tile
162    /// and retained tail — so a re-used key neither leaks a stale tile nor
163    /// double-reports.
164    pub fn begin(&mut self, id: impl Into<String>, label: impl Into<String>) {
165        let id = id.into();
166        let label = label.into();
167        if let Some(old) = self.regions.remove(&id) {
168            old.bar.finish_and_clear();
169            self.multi.remove(&old.bar);
170        }
171        let bar = self.multi.add(ProgressBar::new(0));
172        bar.set_style(message_style());
173        let region = Region {
174            bar,
175            screen: RegionScreen::new(self.config.rows, self.content_cols()),
176            label,
177            retained: VecDeque::new(),
178            high_water: 0,
179        };
180        // A fresh region shows only its header: it grows to fit content as bytes
181        // arrive, so a silent or instantly-finishing unit never paints a tall
182        // block of blank rows.
183        region.bar.set_message(render_tile(
184            &region.label,
185            &[] as &[&str],
186            self.config.cols,
187            0,
188        ));
189        self.regions.insert(id, region);
190    }
191
192    /// Feed raw output bytes to region `id`, updating its tile.
193    ///
194    /// Rows evicted from the tile are dropped from the live view but appended to
195    /// the region's bounded retention ring, so a later failure can replay recent
196    /// context. A feed for an unknown `id` is ignored, so late output after a
197    /// finish cannot panic.
198    pub fn feed(&mut self, id: &str, bytes: &[u8]) {
199        let scrollback = self.config.scrollback;
200        let Some(region) = self.regions.get_mut(id) else {
201            return;
202        };
203        for line in region.screen.feed(bytes) {
204            region.retained.push_back(line);
205            while region.retained.len() > scrollback {
206                region.retained.pop_front();
207            }
208        }
209        let visible = region.screen.render();
210        // Grow the tile to the tallest content it has shown, capped at the grid
211        // height, and render exactly that many rows. `high_water` only rises, so
212        // an in-place redraw that momentarily clears rows does not shrink the
213        // tile and reflow the live area.
214        let filled = content_height(&visible);
215        region.high_water = region.high_water.max(filled).min(self.config.rows);
216        region.bar.set_message(render_tile(
217            &region.label,
218            &visible,
219            self.config.cols,
220            region.high_water,
221        ));
222    }
223
224    /// Finish region `id`, removing its tile and printing `verdict` as the only
225    /// scrollback line — the collapsed signal for a succeeding unit.
226    ///
227    /// The region's peeked output is discarded, not flushed: on success the
228    /// verdict (which the caller may enrich with a run summary) is the whole
229    /// story. A finish for an unknown `id` prints only the verdict. Returns any
230    /// I/O error from the verdict write.
231    pub fn finish(&mut self, id: &str, verdict: impl AsRef<str>) -> std::io::Result<()> {
232        if let Some(region) = self.regions.remove(id) {
233            region.bar.finish_and_clear();
234            self.multi.remove(&region.bar);
235        }
236        self.multi.println(verdict.as_ref())
237    }
238
239    /// Finish a failed region `id`, replaying its retained tail to scrollback as
240    /// one contiguous, label-prefixed block, then printing `verdict`.
241    ///
242    /// The replay is the region's retention ring (rows that scrolled off the
243    /// tile, oldest first) followed by the rows still on screen — a bounded,
244    /// un-interleaved failure transcript. It is returned (un-prefixed, in order)
245    /// so the caller can also retain it for an end-of-run failure epilogue. A
246    /// finish for an unknown `id` prints only the verdict and returns an empty
247    /// body. Returns any I/O error from the flush or verdict write.
248    pub fn finish_with_replay(
249        &mut self,
250        id: &str,
251        verdict: impl AsRef<str>,
252    ) -> std::io::Result<Vec<String>> {
253        let mut body = Vec::new();
254        if let Some(region) = self.regions.remove(id) {
255            let Region {
256                bar,
257                screen,
258                label,
259                retained,
260                high_water: _,
261            } = region;
262            for line in replay_body(&retained, screen.drain()) {
263                let prefixed = scrollback_line(&label, &line);
264                self.multi.println(truncate(&prefixed, self.config.cols))?;
265                body.push(line);
266            }
267            bar.finish_and_clear();
268            self.multi.remove(&bar);
269        }
270        self.multi.println(verdict.as_ref())?;
271        Ok(body)
272    }
273
274    /// Print `line` to scrollback above the live area, without touching any tile.
275    ///
276    /// For output that is not tied to a live region — a header banner, or a
277    /// completed unit's buffered block on the rare path where a unit is not
278    /// live-tailed. Returns any I/O error from the write.
279    pub fn note(&self, line: impl AsRef<str>) -> std::io::Result<()> {
280        self.multi.println(line.as_ref())
281    }
282
283    /// Drop every remaining region, then clear the live area and blank the
284    /// header, leaving the console reusable.
285    ///
286    /// Remaining regions are units that never finished; their peeked output is
287    /// discarded (durable signal is emitted at finish time). Returns any I/O
288    /// error from the terminal clear.
289    pub fn clear(&mut self) -> std::io::Result<()> {
290        for (_, region) in self.regions.drain() {
291            region.bar.finish_and_clear();
292            self.multi.remove(&region.bar);
293        }
294        self.header.set_message("");
295        self.multi.clear()
296    }
297
298    /// The rows a region has retained for a failure replay (oldest first).
299    #[cfg(test)]
300    fn retained(&self, id: &str) -> Option<Vec<String>> {
301        self.regions
302            .get(id)
303            .map(|region| region.retained.iter().cloned().collect())
304    }
305
306    /// The rendered height of a region's tile in lines: its header plus the
307    /// current high-water content rows.
308    #[cfg(test)]
309    fn tile_height(&self, id: &str) -> Option<usize> {
310        self.regions.get(id).map(|region| region.high_water + 1)
311    }
312}
313
314/// A bar style that renders only its message (no bar, timer, or spinner).
315fn message_style() -> ProgressStyle {
316    ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_spinner())
317}
318
319/// The number of rows up to and including the last non-blank one — the height a
320/// tile must render to show all its current content. Trailing blank grid rows
321/// are excluded so a tile grows only to the content it actually holds.
322fn content_height<S: AsRef<str>>(lines: &[S]) -> usize {
323    lines
324        .iter()
325        .rposition(|line| !line.as_ref().is_empty())
326        .map_or(0, |index| index + 1)
327}
328
329/// Render one tile as a labeled header line plus exactly `rows` indented
330/// content lines (padded with blanks), each truncated to `cols` display
331/// columns. As with [`truncate`], `cols == 0` disables truncation (used only by
332/// tests; the live console always resolves `cols` to at least 1). `rows` is the
333/// caller's chosen tile height (its content high-water mark), so the tile is
334/// exactly as tall as the content it currently holds rather than a fixed block.
335fn render_tile<S: AsRef<str>>(label: &str, lines: &[S], cols: usize, rows: usize) -> String {
336    let header = format!("{}", console::style(format!("• {label}")).bold());
337    let mut out = truncate(&header, cols);
338    for index in 0..rows {
339        out.push('\n');
340        let line = lines.get(index).map_or("", AsRef::as_ref);
341        out.push_str(&truncate(&format!("{TILE_INDENT}{line}"), cols));
342    }
343    out
344}
345
346/// Prefix a scrolled-out line with its region label for scrollback attribution.
347fn scrollback_line(label: &str, line: &str) -> String {
348    format!("{} {line}", console::style(format!("{label} │")).dim())
349}
350
351/// Build a failed region's replay body: its retained scrolled-off rows (oldest
352/// first) followed by the rows still on screen, with trailing blank rows
353/// trimmed. The caller label-prefixes each line for scrollback attribution.
354fn replay_body(retained: &VecDeque<String>, on_screen: Vec<String>) -> Vec<String> {
355    let mut lines: Vec<String> = retained.iter().cloned().collect();
356    lines.extend(on_screen);
357    while lines.last().is_some_and(String::is_empty) {
358        lines.pop();
359    }
360    lines
361}
362
363/// Truncate `line` to `width` display columns (ANSI- and width-aware), leaving
364/// it untouched when `width` is `0`.
365fn truncate(line: &str, width: usize) -> String {
366    if width == 0 {
367        return line.to_string();
368    }
369    console::truncate_str(line, width, "…").into_owned()
370}
371
372#[cfg(test)]
373mod tests {
374    use std::collections::VecDeque;
375
376    use super::{LiveConfig, LiveConsole, render_tile, replay_body, scrollback_line, truncate};
377
378    fn config(rows: usize, cols: usize) -> LiveConfig {
379        LiveConfig {
380            rows,
381            cols,
382            ..LiveConfig::default()
383        }
384    }
385
386    #[test]
387    fn content_cols_reserves_the_indent_and_never_underflows() {
388        assert_eq!(config(6, 80).content_cols(), 78);
389        // Degenerate widths clamp to a usable grid rather than underflowing.
390        assert_eq!(config(6, 1).content_cols(), 1);
391        assert_eq!(config(6, 0).content_cols(), 1);
392    }
393
394    #[test]
395    fn drives_full_region_lifecycle_without_panicking() -> std::io::Result<()> {
396        let mut console = LiveConsole::hidden(config(2, 40));
397        console.set_header("wave 1/2 · running 1");
398        console.begin("u1", "rust:core#test");
399        console.feed("u1", b"compiling\r\n");
400        console.feed("u1", b"running 3 tests\r\nok\r\nok\r\n");
401        console.finish("u1", "ok rust:core#test")?;
402        console.clear()
403    }
404
405    #[test]
406    fn reused_id_replaces_region_without_leaking() -> std::io::Result<()> {
407        let mut console = LiveConsole::hidden(LiveConfig::default());
408        console.begin("u1", "first");
409        console.feed("u1", b"old\n");
410        console.begin("u1", "second");
411        console.feed("u1", b"new\n");
412        console.finish("u1", "ok")
413    }
414
415    #[test]
416    fn console_is_reusable_after_clear() -> std::io::Result<()> {
417        let mut console = LiveConsole::hidden(LiveConfig::default());
418        console.set_header("first pass");
419        console.begin("u1", "task");
420        console.feed("u1", b"partial output\n");
421        console.clear()?;
422        console.set_header("second pass");
423        console.begin("u1", "task");
424        console.feed("u1", b"more\n");
425        console.finish("u1", "ok")
426    }
427
428    #[test]
429    fn zero_rows_still_renders_content() -> std::io::Result<()> {
430        let mut console = LiveConsole::hidden(config(0, 40));
431        console.begin("u1", "task");
432        console.feed("u1", b"visible line\r\n");
433        console.finish("u1", "ok")
434    }
435
436    #[test]
437    fn feed_and_finish_for_unknown_region_are_ignored() -> std::io::Result<()> {
438        let mut console = LiveConsole::hidden(LiveConfig::default());
439        console.feed("ghost", b"noise\n");
440        console.finish("ghost", "done")
441    }
442
443    #[test]
444    fn note_prints_to_scrollback_without_a_region() -> std::io::Result<()> {
445        let console = LiveConsole::hidden(LiveConfig::default());
446        console.note("standalone line")
447    }
448
449    #[test]
450    fn scrolled_rows_are_retained_for_replay_not_lost() {
451        let mut console = LiveConsole::hidden(LiveConfig {
452            rows: 2,
453            cols: 20,
454            scrollback: 200,
455        });
456        console.begin("u1", "task");
457        console.feed("u1", b"l1\nl2\nl3\nl4\n");
458        // Only the last row stays on screen; the earlier rows scrolled off but
459        // are retained for a possible failure replay.
460        assert_eq!(
461            console.retained("u1"),
462            Some(vec!["l1".into(), "l2".into(), "l3".into()])
463        );
464    }
465
466    #[test]
467    fn retention_ring_is_bounded_under_a_long_feed() {
468        let mut console = LiveConsole::hidden(LiveConfig {
469            rows: 2,
470            cols: 20,
471            scrollback: 3,
472        });
473        console.begin("u1", "task");
474        for _ in 0..50 {
475            console.feed("u1", b"line\n");
476        }
477        assert!(console.retained("u1").is_some_and(|rows| rows.len() <= 3));
478    }
479
480    #[test]
481    fn replay_body_orders_retained_then_on_screen_and_trims_blanks() {
482        let retained = VecDeque::from(vec!["a".to_string(), "b".to_string()]);
483        let on_screen = vec!["c".to_string(), "d".to_string(), String::new()];
484        assert_eq!(replay_body(&retained, on_screen), vec!["a", "b", "c", "d"]);
485    }
486
487    #[test]
488    fn failure_replay_and_success_finish_both_drop_the_region() -> std::io::Result<()> {
489        let mut console = LiveConsole::hidden(config(2, 20));
490        console.begin("ok", "task");
491        console.feed("ok", b"noise\n");
492        console.finish("ok", "ok task")?;
493        assert!(console.retained("ok").is_none());
494
495        console.begin("bad", "task");
496        console.feed("bad", b"panic!\n");
497        console.finish_with_replay("bad", "failed task")?;
498        assert!(console.retained("bad").is_none());
499        Ok(())
500    }
501
502    #[test]
503    fn finish_with_replay_returns_the_transcript_for_an_end_of_run_epilogue() -> std::io::Result<()>
504    {
505        let mut console = LiveConsole::hidden(config(2, 20));
506        console.begin("bad", "task");
507        console.feed("bad", b"line one\nline two\n");
508        // The replayed body is returned un-prefixed and in order so a caller can
509        // retain it and re-surface all failures together after the run.
510        let body = console.finish_with_replay("bad", "failed task")?;
511        assert_eq!(body, vec!["line one".to_string(), "line two".to_string()]);
512        Ok(())
513    }
514
515    #[test]
516    fn finish_with_replay_for_an_unknown_region_returns_an_empty_body() -> std::io::Result<()> {
517        let mut console = LiveConsole::hidden(config(2, 20));
518        let body = console.finish_with_replay("ghost", "failed")?;
519        assert!(body.is_empty());
520        Ok(())
521    }
522
523    #[test]
524    fn render_tile_labels_and_indents_lines() {
525        let tile = render_tile("core", &["a", "b"], 0, 2);
526        let stripped = console::strip_ansi_codes(&tile);
527        assert_eq!(stripped, "• core\n  a\n  b");
528    }
529
530    #[test]
531    fn content_height_counts_up_to_the_last_non_blank_row() {
532        assert_eq!(super::content_height::<&str>(&[]), 0);
533        assert_eq!(super::content_height(&["", "", ""]), 0);
534        assert_eq!(super::content_height(&["a", "", ""]), 1);
535        assert_eq!(super::content_height(&["a", "", "b", ""]), 3);
536    }
537
538    #[test]
539    fn a_silent_region_renders_only_its_header() {
540        let mut console = LiveConsole::hidden(config(12, 40));
541        console.begin("u1", "rust:core#test");
542        // Nothing fed yet: the tile is a single header line, not a block of
543        // reserved blank rows.
544        assert_eq!(console.tile_height("u1"), Some(1));
545    }
546
547    #[test]
548    fn a_tile_grows_with_content_up_to_the_cap() {
549        let mut console = LiveConsole::hidden(config(3, 40));
550        console.begin("u1", "task");
551        console.feed("u1", b"one\n");
552        // header + one content row.
553        assert_eq!(console.tile_height("u1"), Some(2));
554        console.feed("u1", b"two\nthree\nfour\nfive");
555        // Capped at the grid height (3 content rows) + header.
556        assert_eq!(console.tile_height("u1"), Some(4));
557    }
558
559    #[test]
560    fn a_grown_tile_does_not_shrink_when_output_clears() {
561        let mut console = LiveConsole::hidden(config(3, 40));
562        console.begin("u1", "task");
563        console.feed("u1", b"a\r\nb\r\nc");
564        assert_eq!(console.tile_height("u1"), Some(4));
565        // An in-place redraw that collapses to a single line keeps the tile at
566        // its high-water height instead of reflowing the live area.
567        console.feed("u1", b"\x1b[H\x1b[2Jshort");
568        assert_eq!(console.tile_height("u1"), Some(4));
569    }
570
571    #[test]
572    fn render_tile_pads_to_fixed_height() {
573        let tile = render_tile("core", &["only"], 0, 3);
574        let stripped = console::strip_ansi_codes(&tile);
575        assert_eq!(stripped, "• core\n  only\n  \n  ");
576    }
577
578    #[test]
579    fn render_tile_truncates_header_and_content_to_width() {
580        let tile = render_tile("a-very-long-label", &["a-very-long-content-line"], 8, 1);
581        for line in console::strip_ansi_codes(&tile).lines() {
582            assert!(console::measure_text_width(line) <= 8);
583        }
584    }
585
586    #[test]
587    fn scrollback_line_prefixes_with_label() {
588        let line = scrollback_line("core", "hello");
589        let stripped = console::strip_ansi_codes(&line);
590        assert_eq!(stripped, "core │ hello");
591    }
592
593    #[test]
594    fn replay_line_clamped_to_width_never_wraps() {
595        // A content row is sized to the tile's content area, but the scrollback
596        // replay prefixes it with the full unit label — the composed line must be
597        // clamped to the console width so it never wraps into an orphan fragment.
598        let content = "x".repeat(200);
599        let composed = scrollback_line("rust@rust:core#test", &content);
600        let clamped = truncate(&composed, 140);
601        assert!(console::measure_text_width(&clamped) <= 140);
602    }
603
604    #[test]
605    fn truncate_respects_width_and_passthrough() {
606        assert_eq!(truncate("hello world", 0), "hello world");
607        let cut = truncate("hello world", 5);
608        assert!(console::measure_text_width(&cut) <= 5);
609    }
610}