rskit-cli 0.2.0-alpha.2

CLI framework: progress bars, structured output, signal handling
Documentation
//! [`RegionScreen`] — the public seam the renderer talks to.
//!
//! It owns a [`vte::Parser`] and the applied [`Performer`] state and exposes the
//! whole virtual terminal through three methods: [`feed`](RegionScreen::feed)
//! raw bytes and get back the rows that scrolled off the top,
//! [`render`](RegionScreen::render) the current tile, and
//! [`drain`](RegionScreen::drain) the rows still on screen when the stream ends.
//! The grid, cursor, and SGR internals stay private behind it.

use super::perform::Performer;

/// A bounded per-region virtual terminal.
///
/// Feed a child's raw output bytes with [`feed`](Self::feed); ANSI cursor,
/// erase, scroll, and color sequences are applied to a fixed `rows × cols` cell
/// grid rather than passed through, so a child that redraws in place renders
/// correctly and can never move the host terminal's cursor. Content past the
/// last column truncates (the region does not auto-wrap). Rows that scroll off
/// the top are returned so the caller can retain a bounded tail for a failure
/// replay; on success they are simply dropped.
pub struct RegionScreen {
    parser: vte::Parser,
    performer: Performer,
}

impl std::fmt::Debug for RegionScreen {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // `vte::Parser` is opaque and carries no state worth showing.
        f.debug_struct("RegionScreen")
            .field("performer", &self.performer)
            .finish_non_exhaustive()
    }
}

impl RegionScreen {
    /// Create a screen over a fresh `rows × cols` grid (each clamped to ≥ 1).
    #[must_use]
    pub fn new(rows: usize, cols: usize) -> Self {
        Self {
            parser: vte::Parser::new(),
            performer: Performer::new(rows.max(1), cols.max(1)),
        }
    }

    /// Feed raw output bytes, returning the rows evicted by scrolling.
    ///
    /// Bytes are parsed as a VT stream and applied to the grid; multi-byte and
    /// split UTF-8 sequences are reassembled across calls by the parser. Evicted
    /// rows are returned oldest first, already styled, so the caller can retain
    /// a bounded tail in order (for a failure replay) or drop them.
    pub fn feed(&mut self, bytes: &[u8]) -> Vec<String> {
        self.parser.advance(&mut self.performer, bytes);
        self.performer.take_evicted()
    }

    /// The current grid contents as styled rows (exactly the grid height).
    #[must_use]
    pub fn render(&self) -> Vec<String> {
        self.performer.render()
    }

    /// Consume the screen, returning its remaining rows with trailing blank
    /// rows trimmed, so evicted rows plus this drain reconstruct the transcript.
    #[must_use]
    pub fn drain(self) -> Vec<String> {
        let mut rows = self.performer.render();
        while rows.last().is_some_and(String::is_empty) {
            rows.pop();
        }
        rows
    }
}

#[cfg(test)]
mod tests {
    use super::RegionScreen;

    #[test]
    fn carriage_return_progress_collapses() {
        let mut screen = RegionScreen::new(1, 8);
        assert!(screen.feed(b"50%\r100%").is_empty());
        assert_eq!(screen.render(), vec!["100%".to_string()]);
    }

    #[test]
    fn utf8_split_across_feeds_is_reassembled() {
        let mut screen = RegionScreen::new(1, 4);
        assert!(screen.feed(&[0xC3]).is_empty());
        assert!(screen.feed(&[0xA9]).is_empty());
        assert_eq!(screen.render(), vec!["é".to_string()]);
    }

    #[test]
    fn in_place_multiline_redraw_updates_block_without_duplication() {
        let mut screen = RegionScreen::new(3, 8);
        screen.feed(b"a\r\nb\r\nc");
        // Jump home and rewrite the whole block in place.
        screen.feed(b"\x1b[H\x1b[2Jx\r\ny\r\nz");
        assert_eq!(
            screen.render(),
            vec!["x".to_string(), "y".to_string(), "z".to_string()]
        );
    }

    #[test]
    fn regression_cargo_style_redraw_leaks_no_control_bytes() {
        let mut screen = RegionScreen::new(6, 20);
        // A curses-style frame: erase-line + cursor-up/down that would corrupt a
        // pass-through line buffer. The grid absorbs it into plain content.
        let frame = b"Compiling\r\n\x1b[7A\r\x1b[2K\x1b[1BChecking\r\nDone";
        let evicted = screen.feed(frame);
        let mut rendered = evicted;
        rendered.extend(screen.render());
        for line in &rendered {
            assert!(!line.contains("\x1b[2K"));
            assert!(!line.contains("\x1b[7A"));
            assert!(!line.contains("\x1b[1B"));
        }
    }

    #[test]
    fn evicted_plus_drain_reconstruct_full_transcript() {
        let mut screen = RegionScreen::new(2, 4);
        let mut all = Vec::new();
        all.extend(screen.feed(b"l1\r\nl2\r\nl3\r\nl4\r\n"));
        all.extend(screen.drain());
        assert_eq!(all, vec!["l1", "l2", "l3", "l4"]);
    }

    #[test]
    fn drain_trims_trailing_blank_rows() {
        let mut screen = RegionScreen::new(5, 8);
        screen.feed(b"one\r\ntwo");
        assert_eq!(screen.drain(), vec!["one".to_string(), "two".to_string()]);
    }
}