Skip to main content

rskit_cli/live/screen/
region.rs

1//! [`RegionScreen`] — the public seam the renderer talks to.
2//!
3//! It owns a [`vte::Parser`] and the applied [`Performer`] state and exposes the
4//! whole virtual terminal through three methods: [`feed`](RegionScreen::feed)
5//! raw bytes and get back the rows that scrolled off the top,
6//! [`render`](RegionScreen::render) the current tile, and
7//! [`drain`](RegionScreen::drain) the rows still on screen when the stream ends.
8//! The grid, cursor, and SGR internals stay private behind it.
9
10use super::perform::Performer;
11
12/// A bounded per-region virtual terminal.
13///
14/// Feed a child's raw output bytes with [`feed`](Self::feed); ANSI cursor,
15/// erase, scroll, and color sequences are applied to a fixed `rows × cols` cell
16/// grid rather than passed through, so a child that redraws in place renders
17/// correctly and can never move the host terminal's cursor. Content past the
18/// last column truncates (the region does not auto-wrap). Rows that scroll off
19/// the top are returned so the caller can retain a bounded tail for a failure
20/// replay; on success they are simply dropped.
21pub struct RegionScreen {
22    parser: vte::Parser,
23    performer: Performer,
24}
25
26impl std::fmt::Debug for RegionScreen {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        // `vte::Parser` is opaque and carries no state worth showing.
29        f.debug_struct("RegionScreen")
30            .field("performer", &self.performer)
31            .finish_non_exhaustive()
32    }
33}
34
35impl RegionScreen {
36    /// Create a screen over a fresh `rows × cols` grid (each clamped to ≥ 1).
37    #[must_use]
38    pub fn new(rows: usize, cols: usize) -> Self {
39        Self {
40            parser: vte::Parser::new(),
41            performer: Performer::new(rows.max(1), cols.max(1)),
42        }
43    }
44
45    /// Feed raw output bytes, returning the rows evicted by scrolling.
46    ///
47    /// Bytes are parsed as a VT stream and applied to the grid; multi-byte and
48    /// split UTF-8 sequences are reassembled across calls by the parser. Evicted
49    /// rows are returned oldest first, already styled, so the caller can retain
50    /// a bounded tail in order (for a failure replay) or drop them.
51    pub fn feed(&mut self, bytes: &[u8]) -> Vec<String> {
52        self.parser.advance(&mut self.performer, bytes);
53        self.performer.take_evicted()
54    }
55
56    /// The current grid contents as styled rows (exactly the grid height).
57    #[must_use]
58    pub fn render(&self) -> Vec<String> {
59        self.performer.render()
60    }
61
62    /// Consume the screen, returning its remaining rows with trailing blank
63    /// rows trimmed, so evicted rows plus this drain reconstruct the transcript.
64    #[must_use]
65    pub fn drain(self) -> Vec<String> {
66        let mut rows = self.performer.render();
67        while rows.last().is_some_and(String::is_empty) {
68            rows.pop();
69        }
70        rows
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::RegionScreen;
77
78    #[test]
79    fn carriage_return_progress_collapses() {
80        let mut screen = RegionScreen::new(1, 8);
81        assert!(screen.feed(b"50%\r100%").is_empty());
82        assert_eq!(screen.render(), vec!["100%".to_string()]);
83    }
84
85    #[test]
86    fn utf8_split_across_feeds_is_reassembled() {
87        let mut screen = RegionScreen::new(1, 4);
88        assert!(screen.feed(&[0xC3]).is_empty());
89        assert!(screen.feed(&[0xA9]).is_empty());
90        assert_eq!(screen.render(), vec!["é".to_string()]);
91    }
92
93    #[test]
94    fn in_place_multiline_redraw_updates_block_without_duplication() {
95        let mut screen = RegionScreen::new(3, 8);
96        screen.feed(b"a\r\nb\r\nc");
97        // Jump home and rewrite the whole block in place.
98        screen.feed(b"\x1b[H\x1b[2Jx\r\ny\r\nz");
99        assert_eq!(
100            screen.render(),
101            vec!["x".to_string(), "y".to_string(), "z".to_string()]
102        );
103    }
104
105    #[test]
106    fn regression_cargo_style_redraw_leaks_no_control_bytes() {
107        let mut screen = RegionScreen::new(6, 20);
108        // A curses-style frame: erase-line + cursor-up/down that would corrupt a
109        // pass-through line buffer. The grid absorbs it into plain content.
110        let frame = b"Compiling\r\n\x1b[7A\r\x1b[2K\x1b[1BChecking\r\nDone";
111        let evicted = screen.feed(frame);
112        let mut rendered = evicted;
113        rendered.extend(screen.render());
114        for line in &rendered {
115            assert!(!line.contains("\x1b[2K"));
116            assert!(!line.contains("\x1b[7A"));
117            assert!(!line.contains("\x1b[1B"));
118        }
119    }
120
121    #[test]
122    fn evicted_plus_drain_reconstruct_full_transcript() {
123        let mut screen = RegionScreen::new(2, 4);
124        let mut all = Vec::new();
125        all.extend(screen.feed(b"l1\r\nl2\r\nl3\r\nl4\r\n"));
126        all.extend(screen.drain());
127        assert_eq!(all, vec!["l1", "l2", "l3", "l4"]);
128    }
129
130    #[test]
131    fn drain_trims_trailing_blank_rows() {
132        let mut screen = RegionScreen::new(5, 8);
133        screen.feed(b"one\r\ntwo");
134        assert_eq!(screen.drain(), vec!["one".to_string(), "two".to_string()]);
135    }
136}