| 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 |