返回 CodeWhale
frame.rs
根目录 / crates / tui / tests / support / qa_harness / frame.rs
1 //! Terminal frame snapshot built from the PTY output stream.
2 //!
3 //! Wraps `rio-vt` so tests can feed bytes incrementally and ask
4 //! questions about the current screen contents (visible text, individual rows,
5 //! does-it-contain-this).
6
7 use std::time::Instant;
8
9 use rio_vt::ansi::CursorShape;
10 use rio_vt::config::colors::{AnsiColor, NamedColor};
11 use rio_vt::crosswords::formatter::FormatOptions;
12 use rio_vt::crosswords::pos::Column;
13 use rio_vt::crosswords::square::{ContentTag, Square, Wide};
14 use rio_vt::crosswords::style::Style;
15 use rio_vt::crosswords::{Crosswords, CrosswordsSize};
16 use rio_vt::event::{VoidListener, WindowId};
17 use rio_vt::performer::handler::Processor;
18
19 /// Terminal cell color, matching the three cases theme QA asserts on.
20 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
21 pub enum Color {
22 Default,
23 Idx(u8),
24 Rgb(u8, u8, u8),
25 }
26
27 pub struct Frame {
28 term: Crosswords<VoidListener>,
29 parser: Processor,
30 captured_at: Option<Instant>,
31 }
32
33 impl Frame {
34 pub fn new(rows: u16, cols: u16) -> Self {
35 Self {
36 term: Crosswords::new(
37 grid_size(rows, cols),
38 CursorShape::Block,
39 VoidListener,
40 WindowId::from(0),
41 0,
42 0,
43 ),
44 parser: Processor::default(),
45 captured_at: None,
46 }
47 }
48
49 pub fn feed(&mut self, bytes: &[u8]) {
50 if bytes.is_empty() {
51 return;
52 }
53 self.parser.advance(&mut self.term, bytes);
54 self.captured_at = Some(Instant::now());
55 }
56
57 pub fn resize(&mut self, rows: u16, cols: u16) {
58 self.term.resize(grid_size(rows, cols));
59 }
60
61 pub fn rows(&self) -> u16 {
62 self.term.screen_lines() as u16
63 }
64
65 pub fn cols(&self) -> u16 {
66 self.term.columns() as u16
67 }
68
69 /// Full visible screen as a single string with a `\n` between rows.
70 pub fn text(&self) -> String {
71 self.term.format(FormatOptions::plain())
72 }
73
74 /// Single row of the screen, 0-indexed from the top, trimmed at the
75 /// right edge. Returns the empty string for out-of-range rows.
76 ///
77 /// Blank cells still occupy a real terminal column between painted cells,
78 /// so they are emitted as spaces; the hidden continuation cell of a wide
79 /// glyph is skipped so `界 read` does not become `界 read`.
80 pub fn row(&self, y: u16) -> String {
81 if y >= self.rows() {
82 return String::new();
83 }
84 let rows = self.term.visible_rows();
85 let Some(row) = rows.get(usize::from(y)) else {
86 return String::new();
87 };
88 let cols = usize::from(self.cols());
89 let mut out = String::with_capacity(cols);
90 for col in 0..cols {
91 let square = row[Column(col)];
92 if matches!(square.wide(), Wide::Spacer) {
93 continue;
94 }
95 let ch = square.c();
96 out.push(if ch == '\u{0}' { ' ' } else { ch });
97 }
98 out.trim_end().to_string()
99 }
100
101 pub fn contains(&self, needle: &str) -> bool {
102 self.text().contains(needle)
103 }
104
105 /// First visible coordinate of `needle`, using terminal display columns.
106 pub fn find_text(&self, needle: &str) -> Option<(u16, u16)> {
107 for row in 0..self.rows() {
108 if let Some(col) = self.find_text_in_row(row, needle) {
109 return Some((row, col));
110 }
111 }
112 None
113 }
114
115 /// Locate text on one parsed terminal row without collapsing blank cells.
116 pub fn find_text_in_row(&self, row: u16, needle: &str) -> Option<u16> {
117 if row >= self.rows() || needle.is_empty() {
118 return None;
119 }
120 let rows = self.term.visible_rows();
121 let grid_row = rows.get(usize::from(row))?;
122 let cols = self.cols();
123 for start in 0..cols {
124 let mut col = start;
125 let mut matched = true;
126 for ch in needle.chars() {
127 if col >= cols {
128 matched = false;
129 break;
130 }
131 let contents = square_contents(grid_row[Column(usize::from(col))]);
132 let mut encoded = [0_u8; 4];
133 let expected: &str = ch.encode_utf8(&mut encoded);
134 if if ch == ' ' {
135 !contents.is_empty() && contents.as_str() != " "
136 } else {
137 contents.as_str() != expected
138 } {
139 matched = false;
140 break;
141 }
142 let width = unicode_width::UnicodeWidthChar::width(ch)
143 .unwrap_or(0)
144 .max(1);
145 let Ok(width) = u16::try_from(width) else {
146 return None;
147 };
148 col = col.saturating_add(width);
149 }
150 if matched {
151 return Some(start);
152 }
153 }
154 None
155 }
156
157 /// Foreground/background colors for one terminal cell. Theme QA uses the
158 /// parsed ANSI result rather than trusting a screenshot renderer's own
159 /// palette or accessibility environment.
160 pub fn colors_at(&self, row: u16, col: u16) -> Option<(Color, Color)> {
161 let rows = self.term.visible_rows();
162 let grid_row = rows.get(usize::from(row))?;
163 if usize::from(col) >= usize::from(self.cols()) {
164 return None;
165 }
166 let styles = self.term.grid.styles();
167 Some(square_colors(grid_row[Column(usize::from(col))], styles))
168 }
169
170 /// Colors on the first cell whose terminal contents equal `symbol`.
171 pub fn first_symbol_colors(&self, symbol: &str) -> Option<(Color, Color)> {
172 let rows = self.term.visible_rows();
173 let styles = self.term.grid.styles();
174 let cols = usize::from(self.cols());
175 for grid_row in &rows {
176 for col in 0..cols {
177 let square = grid_row[Column(col)];
178 if square_contents(square).as_str() == symbol {
179 return Some(square_colors(square, styles));
180 }
181 }
182 }
183 None
184 }
185
186 /// Actual terminal cells for opt-in visual evidence, including the
187 /// otherwise invisible trailing selection fill and SGR attributes.
188 pub fn capture_cells(&self) -> serde_json::Value {
189 let styles = self.term.grid.styles();
190 let color = |c| match c {
191 Color::Default => serde_json::Value::Null,
192 Color::Idx(i) => serde_json::json!(i),
193 Color::Rgb(r, g, b) => serde_json::json!([r, g, b]),
194 };
195 let cells: Vec<_> = self
196 .term
197 .visible_rows()
198 .iter()
199 .map(|row| {
200 (0..self.cols())
201 .map(|col| {
202 let square = row[Column(usize::from(col))];
203 let (fg, bg) = square_colors(square, styles);
204 let flags = if square.content_tag() == ContentTag::Codepoint {
205 styles
206 .get(square.style_id() as usize)
207 .map_or(0, |s| s.flags.bits())
208 } else {
209 0
210 };
211 serde_json::json!({"x":col, "text":square_contents(square),
212 "fg":color(fg), "bg":color(bg), "flags":flags})
213 })
214 .collect::<Vec<_>>()
215 })
216 .collect();
217 serde_json::json!({"rows":self.rows(), "cols":self.cols(), "cells":cells})
218 }
219
220 /// Whether any painted cell carries a 24-bit color. The palette adapter
221 /// downgrades every truecolor before it reaches crossterm on terminals
222 /// that only advertise 256 or 16 colors, so this is the parsed-ANSI proof
223 /// that the capability tier was honored.
224 pub fn any_truecolor_cell(&self) -> bool {
225 let rows = self.term.visible_rows();
226 let styles = self.term.grid.styles();
227 let cols = usize::from(self.cols());
228 for grid_row in &rows {
229 for col in 0..cols {
230 let (fg, bg) = square_colors(grid_row[Column(col)], styles);
231 if matches!(fg, Color::Rgb(..)) || matches!(bg, Color::Rgb(..)) {
232 return true;
233 }
234 }
235 }
236 false
237 }
238
239 /// Every distinct character painted on the screen.
240 pub fn painted_chars(&self) -> std::collections::BTreeSet<char> {
241 self.text().chars().filter(|c| !c.is_whitespace()).collect()
242 }
243
244 /// Widest parsed row. rio-vt clips at the right margin, so an overflowing
245 /// renderer shows up as wrapped content rather than a long row.
246 pub fn max_row_width(&self) -> usize {
247 (0..self.rows())
248 .map(|y| self.row(y).chars().count())
249 .max()
250 .unwrap_or(0)
251 }
252
253 /// Whether any row of the screen has non-blank content.
254 pub fn any_visible_text(&self) -> bool {
255 self.text().chars().any(|c| !c.is_whitespace())
256 }
257
258 /// Cursor position as (row, col).
259 pub fn cursor(&self) -> (u16, u16) {
260 let pos = self.term.cursor().pos;
261 (
262 u16::try_from(pos.row.0.max(0)).unwrap_or(u16::MAX),
263 u16::try_from(pos.col.0).unwrap_or(u16::MAX),
264 )
265 }
266
267 /// Render the screen to a string for diagnostic dumps when an
268 /// assertion fails.
269 pub fn debug_dump(&self) -> String {
270 let (rows, cols) = (self.rows(), self.cols());
271 let mut out = String::new();
272 out.push_str(&format!(
273 "== frame {rows}x{cols} cursor={:?} ==\n",
274 self.cursor()
275 ));
276 for y in 0..rows {
277 out.push_str(&format!("{y:>3} | {}\n", self.row(y).trim_end()));
278 }
279 out
280 }
281 }
282
283 fn grid_size(rows: u16, cols: u16) -> CrosswordsSize {
284 CrosswordsSize::new(usize::from(cols.max(1)), usize::from(rows.max(1)))
285 }
286
287 fn square_contents(square: Square) -> String {
288 if matches!(square.wide(), Wide::Spacer) {
289 return String::new();
290 }
291 match square.c() {
292 ' ' | '\u{0}' => String::new(),
293 ch => ch.to_string(),
294 }
295 }
296
297 fn square_colors(square: Square, styles: &[Style]) -> (Color, Color) {
298 match square.content_tag() {
299 ContentTag::Codepoint => {
300 let style = styles
301 .get(square.style_id() as usize)
302 .copied()
303 .unwrap_or_default();
304 (map_color(style.fg), map_color(style.bg))
305 }
306 ContentTag::BgPalette => (Color::Default, Color::Idx(square.bg_palette_index())),
307 ContentTag::BgRgb => {
308 let (r, g, b) = square.bg_rgb();
309 (Color::Default, Color::Rgb(r, g, b))
310 }
311 }
312 }
313
314 fn map_color(color: AnsiColor) -> Color {
315 match color {
316 AnsiColor::Named(NamedColor::Foreground | NamedColor::Background) => Color::Default,
317 AnsiColor::Named(named) => {
318 let index = named as u32;
319 if index < 16 {
320 Color::Idx(index as u8)
321 } else {
322 Color::Default
323 }
324 }
325 AnsiColor::Indexed(index) => Color::Idx(index),
326 AnsiColor::Spec(rgb) => Color::Rgb(rgb.r, rgb.g, rgb.b),
327 }
328 }
329
330 #[cfg(test)]
331 mod tests {
332 use super::Frame;
333
334 #[test]
335 fn row_preserves_unpainted_interior_terminal_columns() {
336 let mut frame = Frame::new(1, 12);
337 frame.feed(b"read\x1b[6Grunning");
338
339 assert_eq!(frame.row(0), "read running");
340 }
341
342 #[test]
343 fn row_does_not_expand_wide_glyph_continuation_cells() {
344 let mut frame = Frame::new(1, 12);
345 frame.feed("界 read".as_bytes());
346
347 assert_eq!(frame.row(0), "界 read");
348 }
349 }
350
350 lines RUST