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    /// Content rows shown per region tile — the height of its virtual terminal.
40    pub rows: usize,
41    /// Terminal columns: the tile width. A child's output is applied to a grid
42    /// sized to the visible content area (this width minus the content indent),
43    /// so a real width must be passed (unlike the old truncation width, `0` is
44    /// not "disable").
45    pub cols: usize,
46    /// How many rows that scroll off a tile are retained per region for a
47    /// failure replay. The live tile stays a bounded peek; on failure this many
48    /// of the most-recent scrolled-off rows (plus the final on-screen rows) are
49    /// flushed to scrollback as the failure block. `0` retains nothing, so a
50    /// failure replays only the rows still on screen.
51    pub scrollback: usize,
52}
53
54impl Default for LiveConfig {
55    fn default() -> Self {
56        Self {
57            rows: 5,
58            cols: 80,
59            scrollback: 200,
60        }
61    }
62}
63
64impl LiveConfig {
65    /// The inner virtual-terminal grid width: the tile width minus the content
66    /// indent rendered under each region header.
67    ///
68    /// A child whose output is fed to the tile must be told its terminal is this
69    /// wide (not the full tile width), so its own line wrapping matches the grid.
70    /// Otherwise a full-width in-place progress redraw wraps at the grid edge,
71    /// scrolls the short grid, and churns the retained failure tail with stale
72    /// half-frames on every tick — which then surface in the bounded replay when
73    /// a region fails.
74    #[must_use]
75    pub fn content_cols(&self) -> usize {
76        self.cols.saturating_sub(TILE_INDENT.len()).max(1)
77    }
78}
79
80/// One in-flight region: its virtual terminal, label, backing progress line,
81/// and a bounded ring of the rows that have scrolled off its tile — kept only
82/// so a failure can replay recent context.
83struct Region {
84    bar: ProgressBar,
85    screen: RegionScreen,
86    label: String,
87    retained: VecDeque<String>,
88}
89
90/// A live, multi-region terminal renderer.
91///
92/// Lifecycle per region: [`begin`](Self::begin) with a label,
93/// [`feed`](Self::feed) raw bytes as they arrive, then [`finish`](Self::finish)
94/// with a one-line verdict. [`set_header`](Self::set_header) updates a status
95/// line pinned above the tiles.
96pub struct LiveConsole {
97    multi: MultiProgress,
98    header: ProgressBar,
99    regions: HashMap<String, Region>,
100    config: LiveConfig,
101}
102
103impl LiveConsole {
104    /// Create a console that renders to stderr.
105    ///
106    /// stderr keeps the live UI off stdout, leaving any machine-readable stream
107    /// there uncorrupted.
108    #[must_use]
109    pub fn to_stderr(config: LiveConfig) -> Self {
110        Self::with_target(ProgressDrawTarget::stderr(), config)
111    }
112
113    /// Create a console whose output is discarded — for tests and non-terminal
114    /// runs where the live area must not render.
115    #[must_use]
116    pub fn hidden(config: LiveConfig) -> Self {
117        Self::with_target(ProgressDrawTarget::hidden(), config)
118    }
119
120    fn with_target(target: ProgressDrawTarget, mut config: LiveConfig) -> Self {
121        // Keep the grid and the renderer consistent: a tile always shows at
122        // least one content row and its virtual terminal needs a real width.
123        config.rows = config.rows.max(1);
124        config.cols = config.cols.max(1);
125        let multi = MultiProgress::with_draw_target(target);
126        let header = multi.add(ProgressBar::new(0));
127        header.set_style(message_style());
128        Self {
129            multi,
130            header,
131            regions: HashMap::new(),
132            config,
133        }
134    }
135
136    /// The virtual terminal grid width: the tile width minus the content
137    /// indent, so a child fills the visible content area without being chopped.
138    /// Children feeding a tile should be sized to this; see
139    /// [`LiveConfig::content_cols`].
140    #[must_use]
141    pub fn content_cols(&self) -> usize {
142        self.config.content_cols()
143    }
144
145    /// Set the status line shown above the tiles.
146    pub fn set_header(&self, text: impl Into<String>) {
147        self.header.set_message(text.into());
148    }
149
150    /// Start a new region tile labeled `label`, keyed by `id`.
151    ///
152    /// A duplicate `id` discards the existing region first — dropping its tile
153    /// and retained tail — so a re-used key neither leaks a stale tile nor
154    /// double-reports.
155    pub fn begin(&mut self, id: impl Into<String>, label: impl Into<String>) {
156        let id = id.into();
157        let label = label.into();
158        if let Some(old) = self.regions.remove(&id) {
159            old.bar.finish_and_clear();
160            self.multi.remove(&old.bar);
161        }
162        let bar = self.multi.add(ProgressBar::new(0));
163        bar.set_style(message_style());
164        let region = Region {
165            bar,
166            screen: RegionScreen::new(self.config.rows, self.content_cols()),
167            label,
168            retained: VecDeque::new(),
169        };
170        region.bar.set_message(render_tile(
171            &region.label,
172            &[] as &[&str],
173            self.config.cols,
174            self.config.rows,
175        ));
176        self.regions.insert(id, region);
177    }
178
179    /// Feed raw output bytes to region `id`, updating its tile.
180    ///
181    /// Rows evicted from the tile are dropped from the live view but appended to
182    /// the region's bounded retention ring, so a later failure can replay recent
183    /// context. A feed for an unknown `id` is ignored, so late output after a
184    /// finish cannot panic.
185    pub fn feed(&mut self, id: &str, bytes: &[u8]) {
186        let scrollback = self.config.scrollback;
187        let Some(region) = self.regions.get_mut(id) else {
188            return;
189        };
190        for line in region.screen.feed(bytes) {
191            region.retained.push_back(line);
192            while region.retained.len() > scrollback {
193                region.retained.pop_front();
194            }
195        }
196        let visible = region.screen.render();
197        region.bar.set_message(render_tile(
198            &region.label,
199            &visible,
200            self.config.cols,
201            self.config.rows,
202        ));
203    }
204
205    /// Finish region `id`, removing its tile and printing `verdict` as the only
206    /// scrollback line — the collapsed signal for a succeeding unit.
207    ///
208    /// The region's peeked output is discarded, not flushed: on success the
209    /// verdict (which the caller may enrich with a run summary) is the whole
210    /// story. A finish for an unknown `id` prints only the verdict. Returns any
211    /// I/O error from the verdict write.
212    pub fn finish(&mut self, id: &str, verdict: impl AsRef<str>) -> std::io::Result<()> {
213        if let Some(region) = self.regions.remove(id) {
214            region.bar.finish_and_clear();
215            self.multi.remove(&region.bar);
216        }
217        self.multi.println(verdict.as_ref())
218    }
219
220    /// Finish a failed region `id`, replaying its retained tail to scrollback as
221    /// one contiguous, label-prefixed block, then printing `verdict`.
222    ///
223    /// The replay is the region's retention ring (rows that scrolled off the
224    /// tile, oldest first) followed by the rows still on screen — a bounded,
225    /// un-interleaved failure transcript. A finish for an unknown `id` prints
226    /// only the verdict. Returns any I/O error from the flush or verdict write.
227    pub fn finish_with_replay(
228        &mut self,
229        id: &str,
230        verdict: impl AsRef<str>,
231    ) -> std::io::Result<()> {
232        if let Some(region) = self.regions.remove(id) {
233            let Region {
234                bar,
235                screen,
236                label,
237                retained,
238            } = region;
239            for line in replay_body(&retained, screen.drain()) {
240                let prefixed = scrollback_line(&label, &line);
241                self.multi.println(truncate(&prefixed, self.config.cols))?;
242            }
243            bar.finish_and_clear();
244            self.multi.remove(&bar);
245        }
246        self.multi.println(verdict.as_ref())
247    }
248
249    /// Print `line` to scrollback above the live area, without touching any tile.
250    ///
251    /// For output that is not tied to a live region — a header banner, or a
252    /// completed unit's buffered block on the rare path where a unit is not
253    /// live-tailed. Returns any I/O error from the write.
254    pub fn note(&self, line: impl AsRef<str>) -> std::io::Result<()> {
255        self.multi.println(line.as_ref())
256    }
257
258    /// Drop every remaining region, then clear the live area and blank the
259    /// header, leaving the console reusable.
260    ///
261    /// Remaining regions are units that never finished; their peeked output is
262    /// discarded (durable signal is emitted at finish time). Returns any I/O
263    /// error from the terminal clear.
264    pub fn clear(&mut self) -> std::io::Result<()> {
265        for (_, region) in self.regions.drain() {
266            region.bar.finish_and_clear();
267            self.multi.remove(&region.bar);
268        }
269        self.header.set_message("");
270        self.multi.clear()
271    }
272
273    /// The rows a region has retained for a failure replay (oldest first).
274    #[cfg(test)]
275    fn retained(&self, id: &str) -> Option<Vec<String>> {
276        self.regions
277            .get(id)
278            .map(|region| region.retained.iter().cloned().collect())
279    }
280}
281
282/// A bar style that renders only its message (no bar, timer, or spinner).
283fn message_style() -> ProgressStyle {
284    ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_spinner())
285}
286
287/// Render one tile as a labeled header line plus exactly `rows` indented
288/// content lines (padded with blanks), each truncated to `cols` display
289/// columns. As with [`truncate`], `cols == 0` disables truncation (used only by
290/// tests; the live console always resolves `cols` to at least 1). The fixed
291/// line count keeps every tile a constant height so the live area does not
292/// reflow as streams emit output.
293fn render_tile<S: AsRef<str>>(label: &str, lines: &[S], cols: usize, rows: usize) -> String {
294    let header = format!("{}", console::style(format!("• {label}")).bold());
295    let mut out = truncate(&header, cols);
296    for index in 0..rows {
297        out.push('\n');
298        let line = lines.get(index).map_or("", AsRef::as_ref);
299        out.push_str(&truncate(&format!("{TILE_INDENT}{line}"), cols));
300    }
301    out
302}
303
304/// Prefix a scrolled-out line with its region label for scrollback attribution.
305fn scrollback_line(label: &str, line: &str) -> String {
306    format!("{} {line}", console::style(format!("{label} │")).dim())
307}
308
309/// Build a failed region's replay body: its retained scrolled-off rows (oldest
310/// first) followed by the rows still on screen, with trailing blank rows
311/// trimmed. The caller label-prefixes each line for scrollback attribution.
312fn replay_body(retained: &VecDeque<String>, on_screen: Vec<String>) -> Vec<String> {
313    let mut lines: Vec<String> = retained.iter().cloned().collect();
314    lines.extend(on_screen);
315    while lines.last().is_some_and(String::is_empty) {
316        lines.pop();
317    }
318    lines
319}
320
321/// Truncate `line` to `width` display columns (ANSI- and width-aware), leaving
322/// it untouched when `width` is `0`.
323fn truncate(line: &str, width: usize) -> String {
324    if width == 0 {
325        return line.to_string();
326    }
327    console::truncate_str(line, width, "…").into_owned()
328}
329
330#[cfg(test)]
331mod tests {
332    use std::collections::VecDeque;
333
334    use super::{LiveConfig, LiveConsole, render_tile, replay_body, scrollback_line, truncate};
335
336    fn config(rows: usize, cols: usize) -> LiveConfig {
337        LiveConfig {
338            rows,
339            cols,
340            ..LiveConfig::default()
341        }
342    }
343
344    #[test]
345    fn content_cols_reserves_the_indent_and_never_underflows() {
346        assert_eq!(config(6, 80).content_cols(), 78);
347        // Degenerate widths clamp to a usable grid rather than underflowing.
348        assert_eq!(config(6, 1).content_cols(), 1);
349        assert_eq!(config(6, 0).content_cols(), 1);
350    }
351
352    #[test]
353    fn drives_full_region_lifecycle_without_panicking() -> std::io::Result<()> {
354        let mut console = LiveConsole::hidden(config(2, 40));
355        console.set_header("wave 1/2 · running 1");
356        console.begin("u1", "rust:core#test");
357        console.feed("u1", b"compiling\r\n");
358        console.feed("u1", b"running 3 tests\r\nok\r\nok\r\n");
359        console.finish("u1", "ok rust:core#test")?;
360        console.clear()
361    }
362
363    #[test]
364    fn reused_id_replaces_region_without_leaking() -> std::io::Result<()> {
365        let mut console = LiveConsole::hidden(LiveConfig::default());
366        console.begin("u1", "first");
367        console.feed("u1", b"old\n");
368        console.begin("u1", "second");
369        console.feed("u1", b"new\n");
370        console.finish("u1", "ok")
371    }
372
373    #[test]
374    fn console_is_reusable_after_clear() -> std::io::Result<()> {
375        let mut console = LiveConsole::hidden(LiveConfig::default());
376        console.set_header("first pass");
377        console.begin("u1", "task");
378        console.feed("u1", b"partial output\n");
379        console.clear()?;
380        console.set_header("second pass");
381        console.begin("u1", "task");
382        console.feed("u1", b"more\n");
383        console.finish("u1", "ok")
384    }
385
386    #[test]
387    fn zero_rows_still_renders_content() -> std::io::Result<()> {
388        let mut console = LiveConsole::hidden(config(0, 40));
389        console.begin("u1", "task");
390        console.feed("u1", b"visible line\r\n");
391        console.finish("u1", "ok")
392    }
393
394    #[test]
395    fn feed_and_finish_for_unknown_region_are_ignored() -> std::io::Result<()> {
396        let mut console = LiveConsole::hidden(LiveConfig::default());
397        console.feed("ghost", b"noise\n");
398        console.finish("ghost", "done")
399    }
400
401    #[test]
402    fn note_prints_to_scrollback_without_a_region() -> std::io::Result<()> {
403        let console = LiveConsole::hidden(LiveConfig::default());
404        console.note("standalone line")
405    }
406
407    #[test]
408    fn scrolled_rows_are_retained_for_replay_not_lost() {
409        let mut console = LiveConsole::hidden(LiveConfig {
410            rows: 2,
411            cols: 20,
412            scrollback: 200,
413        });
414        console.begin("u1", "task");
415        console.feed("u1", b"l1\nl2\nl3\nl4\n");
416        // Only the last row stays on screen; the earlier rows scrolled off but
417        // are retained for a possible failure replay.
418        assert_eq!(
419            console.retained("u1"),
420            Some(vec!["l1".into(), "l2".into(), "l3".into()])
421        );
422    }
423
424    #[test]
425    fn retention_ring_is_bounded_under_a_long_feed() {
426        let mut console = LiveConsole::hidden(LiveConfig {
427            rows: 2,
428            cols: 20,
429            scrollback: 3,
430        });
431        console.begin("u1", "task");
432        for _ in 0..50 {
433            console.feed("u1", b"line\n");
434        }
435        assert!(console.retained("u1").is_some_and(|rows| rows.len() <= 3));
436    }
437
438    #[test]
439    fn replay_body_orders_retained_then_on_screen_and_trims_blanks() {
440        let retained = VecDeque::from(vec!["a".to_string(), "b".to_string()]);
441        let on_screen = vec!["c".to_string(), "d".to_string(), String::new()];
442        assert_eq!(replay_body(&retained, on_screen), vec!["a", "b", "c", "d"]);
443    }
444
445    #[test]
446    fn failure_replay_and_success_finish_both_drop_the_region() -> std::io::Result<()> {
447        let mut console = LiveConsole::hidden(config(2, 20));
448        console.begin("ok", "task");
449        console.feed("ok", b"noise\n");
450        console.finish("ok", "ok task")?;
451        assert!(console.retained("ok").is_none());
452
453        console.begin("bad", "task");
454        console.feed("bad", b"panic!\n");
455        console.finish_with_replay("bad", "failed task")?;
456        assert!(console.retained("bad").is_none());
457        Ok(())
458    }
459
460    #[test]
461    fn render_tile_labels_and_indents_lines() {
462        let tile = render_tile("core", &["a", "b"], 0, 2);
463        let stripped = console::strip_ansi_codes(&tile);
464        assert_eq!(stripped, "• core\n  a\n  b");
465    }
466
467    #[test]
468    fn render_tile_pads_to_fixed_height() {
469        let tile = render_tile("core", &["only"], 0, 3);
470        let stripped = console::strip_ansi_codes(&tile);
471        assert_eq!(stripped, "• core\n  only\n  \n  ");
472    }
473
474    #[test]
475    fn render_tile_truncates_header_and_content_to_width() {
476        let tile = render_tile("a-very-long-label", &["a-very-long-content-line"], 8, 1);
477        for line in console::strip_ansi_codes(&tile).lines() {
478            assert!(console::measure_text_width(line) <= 8);
479        }
480    }
481
482    #[test]
483    fn scrollback_line_prefixes_with_label() {
484        let line = scrollback_line("core", "hello");
485        let stripped = console::strip_ansi_codes(&line);
486        assert_eq!(stripped, "core │ hello");
487    }
488
489    #[test]
490    fn replay_line_clamped_to_width_never_wraps() {
491        // A content row is sized to the tile's content area, but the scrollback
492        // replay prefixes it with the full unit label — the composed line must be
493        // clamped to the console width so it never wraps into an orphan fragment.
494        let content = "x".repeat(200);
495        let composed = scrollback_line("rust@rust:core#test", &content);
496        let clamped = truncate(&composed, 140);
497        assert!(console::measure_text_width(&clamped) <= 140);
498    }
499
500    #[test]
501    fn truncate_respects_width_and_passthrough() {
502        assert_eq!(truncate("hello world", 0), "hello world");
503        let cut = truncate("hello world", 5);
504        assert!(console::measure_text_width(&cut) <= 5);
505    }
506}