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