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}