返回 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.style_set.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.style_set.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 /// Whether any painted cell carries a 24-bit color. The palette adapter
187 /// downgrades every truecolor before it reaches crossterm on terminals
188 /// that only advertise 256 or 16 colors, so this is the parsed-ANSI proof
189 /// that the capability tier was honored.
190 pub fn any_truecolor_cell(&self) -> bool {
191 let rows = self.term.visible_rows();
192 let styles = self.term.grid.style_set.styles();
193 let cols = usize::from(self.cols());
194 for grid_row in &rows {
195 for col in 0..cols {
196 let (fg, bg) = square_colors(grid_row[Column(col)], styles);
197 if matches!(fg, Color::Rgb(..)) || matches!(bg, Color::Rgb(..)) {
198 return true;
199 }
200 }
201 }
202 false
203 }
204
205 /// Every distinct character painted on the screen.
206 pub fn painted_chars(&self) -> std::collections::BTreeSet<char> {
207 self.text().chars().filter(|c| !c.is_whitespace()).collect()
208 }
209
210 /// Widest parsed row. rio-vt clips at the right margin, so an overflowing
211 /// renderer shows up as wrapped content rather than a long row.
212 pub fn max_row_width(&self) -> usize {
213 (0..self.rows())
214 .map(|y| self.row(y).chars().count())
215 .max()
216 .unwrap_or(0)
217 }
218
219 /// Whether any row of the screen has non-blank content.
220 pub fn any_visible_text(&self) -> bool {
221 self.text().chars().any(|c| !c.is_whitespace())
222 }
223
224 /// Cursor position as (row, col).
225 pub fn cursor(&self) -> (u16, u16) {
226 let pos = self.term.cursor().pos;
227 (
228 u16::try_from(pos.row.0.max(0)).unwrap_or(u16::MAX),
229 u16::try_from(pos.col.0).unwrap_or(u16::MAX),
230 )
231 }
232
233 /// Render the screen to a string for diagnostic dumps when an
234 /// assertion fails.
235 pub fn debug_dump(&self) -> String {
236 let (rows, cols) = (self.rows(), self.cols());
237 let mut out = String::new();
238 out.push_str(&format!(
239 "== frame {rows}x{cols} cursor={:?} ==\n",
240 self.cursor()
241 ));
242 for y in 0..rows {
243 out.push_str(&format!("{y:>3} | {}\n", self.row(y).trim_end()));
244 }
245 out
246 }
247 }
248
249 fn grid_size(rows: u16, cols: u16) -> CrosswordsSize {
250 CrosswordsSize::new(usize::from(cols.max(1)), usize::from(rows.max(1)))
251 }
252
253 fn square_contents(square: Square) -> String {
254 if matches!(square.wide(), Wide::Spacer) {
255 return String::new();
256 }
257 match square.c() {
258 ' ' | '\u{0}' => String::new(),
259 ch => ch.to_string(),
260 }
261 }
262
263 fn square_colors(square: Square, styles: &[Style]) -> (Color, Color) {
264 match square.content_tag() {
265 ContentTag::Codepoint => {
266 let style = styles
267 .get(square.style_id() as usize)
268 .copied()
269 .unwrap_or_default();
270 (map_color(style.fg), map_color(style.bg))
271 }
272 ContentTag::BgPalette => (Color::Default, Color::Idx(square.bg_palette_index())),
273 ContentTag::BgRgb => {
274 let (r, g, b) = square.bg_rgb();
275 (Color::Default, Color::Rgb(r, g, b))
276 }
277 }
278 }
279
280 fn map_color(color: AnsiColor) -> Color {
281 match color {
282 AnsiColor::Named(NamedColor::Foreground | NamedColor::Background) => Color::Default,
283 AnsiColor::Named(named) => {
284 let index = named as u32;
285 if index < 16 {
286 Color::Idx(index as u8)
287 } else {
288 Color::Default
289 }
290 }
291 AnsiColor::Indexed(index) => Color::Idx(index),
292 AnsiColor::Spec(rgb) => Color::Rgb(rgb.r, rgb.g, rgb.b),
293 }
294 }
295
296 #[cfg(test)]
297 mod tests {
298 use super::Frame;
299
300 #[test]
301 fn row_preserves_unpainted_interior_terminal_columns() {
302 let mut frame = Frame::new(1, 12);
303 frame.feed(b"read\x1b[6Grunning");
304
305 assert_eq!(frame.row(0), "read running");
306 }
307
308 #[test]
309 fn row_does_not_expand_wide_glyph_continuation_cells() {
310 let mut frame = Frame::new(1, 12);
311 frame.feed("界 read".as_bytes());
312
313 assert_eq!(frame.row(0), "界 read");
314 }
315 }
316
316 lines RUST