Skip to main content

aft/
pty_render.rs

1use std::panic::{catch_unwind, AssertUnwindSafe};
2
3const FALLBACK_TAIL_BYTES: usize = 16 * 1024;
4
5pub fn render_screen(raw: &[u8], rows: u16, cols: u16) -> String {
6    catch_unwind(AssertUnwindSafe(|| render_screen_inner(raw, rows, cols)))
7        .unwrap_or_else(|_| render_raw_fallback(raw))
8}
9
10fn render_screen_inner(raw: &[u8], rows: u16, cols: u16) -> String {
11    let mut parser = vt100::Parser::new(rows, cols, 0);
12    parser.process(raw);
13    let screen = parser.screen();
14    let mut lines: Vec<String> = Vec::new();
15    for y in 0..rows {
16        let mut text = String::new();
17        for x in 0..cols {
18            let c = screen
19                .cell(y, x)
20                .map(|cell| cell.contents())
21                .unwrap_or_default();
22            if c.is_empty() {
23                text.push(' ');
24            } else {
25                text.push_str(c);
26            }
27        }
28        lines.push(text.trim_end().to_string());
29    }
30    while lines.last().is_some_and(|line| line.is_empty()) {
31        lines.pop();
32    }
33    lines.join("\n")
34}
35
36pub fn render_raw_fallback(raw: &[u8]) -> String {
37    let tail = if raw.len() > FALLBACK_TAIL_BYTES {
38        &raw[raw.len() - FALLBACK_TAIL_BYTES..]
39    } else {
40        raw
41    };
42    let note = if raw.len() > FALLBACK_TAIL_BYTES {
43        format!("[PTY screen render panicked; showing last {FALLBACK_TAIL_BYTES} raw bytes]\n")
44    } else {
45        "[PTY screen render panicked; showing raw PTY bytes]\n".to_string()
46    };
47    format!("{note}{}", String::from_utf8_lossy(tail))
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    const CASES: &[(&str, &[u8], &str)] = &[
55        (
56            "cap1_ls",
57            include_bytes!("../tests/fixtures/pty_render/cap1_ls.raw"),
58            include_str!("../tests/fixtures/pty_render/cap1_ls.xterm.txt"),
59        ),
60        (
61            "cap2_sgr",
62            include_bytes!("../tests/fixtures/pty_render/cap2_sgr.raw"),
63            include_str!("../tests/fixtures/pty_render/cap2_sgr.xterm.txt"),
64        ),
65        (
66            "cap3_box",
67            include_bytes!("../tests/fixtures/pty_render/cap3_box.raw"),
68            include_str!("../tests/fixtures/pty_render/cap3_box.xterm.txt"),
69        ),
70        (
71            "cap4_cr",
72            include_bytes!("../tests/fixtures/pty_render/cap4_cr.raw"),
73            include_str!("../tests/fixtures/pty_render/cap4_cr.xterm.txt"),
74        ),
75        (
76            "cap5_alt",
77            include_bytes!("../tests/fixtures/pty_render/cap5_alt.raw"),
78            include_str!("../tests/fixtures/pty_render/cap5_alt.xterm.txt"),
79        ),
80        (
81            "cap6_vim",
82            include_bytes!("../tests/fixtures/pty_render/cap6_vim.raw"),
83            include_str!("../tests/fixtures/pty_render/cap6_vim.xterm.txt"),
84        ),
85    ];
86
87    #[test]
88    fn matches_xterm_headless_golden_corpus() {
89        for (name, raw, expected) in CASES {
90            assert_eq!(render_screen(raw, 24, 80), *expected, "{name}");
91        }
92    }
93
94    #[test]
95    fn panic_fallback_returns_raw_tail() {
96        let raw = b"before panic";
97        let rendered = catch_unwind(AssertUnwindSafe(|| {
98            render_screen_catching(raw, || panic!("forced vt100 panic"))
99        }))
100        .expect("render_screen must catch renderer panics");
101        assert!(rendered.starts_with("[PTY screen render panicked; showing raw PTY bytes]\n"));
102        assert!(rendered.ends_with("before panic"));
103    }
104
105    #[test]
106    fn fallback_trims_large_raw_payload_to_tail() {
107        let raw = vec![b'x'; FALLBACK_TAIL_BYTES + 10];
108        let rendered = render_raw_fallback(&raw);
109        assert!(rendered.starts_with(&format!(
110            "[PTY screen render panicked; showing last {FALLBACK_TAIL_BYTES} raw bytes]\n"
111        )));
112        assert_eq!(
113            rendered.len(),
114            format!("[PTY screen render panicked; showing last {FALLBACK_TAIL_BYTES} raw bytes]\n")
115                .len()
116                + FALLBACK_TAIL_BYTES
117        );
118    }
119
120    fn render_screen_catching(raw: &[u8], render: impl FnOnce() -> String) -> String {
121        catch_unwind(AssertUnwindSafe(render)).unwrap_or_else(|_| render_raw_fallback(raw))
122    }
123}