ratatui_testlib/screen.rs
1//! Terminal screen state management using vtparse with Sixel support.
2//!
3//! This module provides the core terminal emulation layer that tracks screen contents,
4//! cursor position, and Sixel graphics regions. It uses the [`vtparse`] crate to parse
5//! VT100/ANSI escape sequences.
6//!
7//! # Key Types
8//!
9//! - [`ScreenState`]: The main screen state tracking type
10//! - [`SixelRegion`]: Represents a Sixel graphics region with position and dimension info
11//!
12//! # Example
13//!
14//! ```rust
15//! use ratatui_testlib::ScreenState;
16//!
17//! let mut screen = ScreenState::new(80, 24);
18//!
19//! // Feed terminal output
20//! screen.feed(b"Hello, World!");
21//!
22//! // Query screen contents
23//! assert!(screen.contains("Hello"));
24//! assert_eq!(screen.cursor_position(), (0, 13));
25//!
26//! // Check specific position
27//! assert_eq!(screen.text_at(0, 0), Some('H'));
28//! ```
29
30use vtparse::{VTActor, VTParser, CsiParam};
31
32/// Represents a single terminal cell with character and attributes.
33///
34/// This struct tracks the complete state of a terminal cell including:
35/// - The character being displayed
36/// - Foreground color (ANSI color code, 0-255, or None for default)
37/// - Background color (ANSI color code, 0-255, or None for default)
38/// - Text attributes (bold, italic, underline, etc.)
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct Cell {
41 /// The character displayed in this cell
42 pub c: char,
43 /// Foreground color (None = default, Some(0-255) = ANSI color)
44 pub fg: Option<u8>,
45 /// Background color (None = default, Some(0-255) = ANSI color)
46 pub bg: Option<u8>,
47 /// Bold attribute
48 pub bold: bool,
49 /// Italic attribute
50 pub italic: bool,
51 /// Underline attribute
52 pub underline: bool,
53}
54
55impl Default for Cell {
56 fn default() -> Self {
57 Self {
58 c: ' ',
59 fg: None,
60 bg: None,
61 bold: false,
62 italic: false,
63 underline: false,
64 }
65 }
66}
67
68/// Represents a Sixel graphics region in the terminal.
69///
70/// Sixel is a bitmap graphics format used by terminals to display images.
71/// This struct tracks the position and dimensions of Sixel graphics rendered
72/// on the screen, which is essential for verifying that graphics appear in
73/// the correct locations (e.g., within preview areas).
74///
75/// # Fields
76///
77/// - `start_row`: The row where the Sixel begins (0-indexed)
78/// - `start_col`: The column where the Sixel begins (0-indexed)
79/// - `width`: Width of the Sixel image in pixels
80/// - `height`: Height of the Sixel image in pixels
81/// - `data`: The raw Sixel escape sequence data
82///
83/// # Example
84///
85/// ```rust
86/// # use ratatui_testlib::ScreenState;
87/// let mut screen = ScreenState::new(80, 24);
88///
89/// // After rendering a Sixel image...
90/// let regions = screen.sixel_regions();
91/// for region in regions {
92/// println!("Sixel at ({}, {}), size {}x{}",
93/// region.start_row, region.start_col,
94/// region.width, region.height);
95/// }
96/// ```
97#[derive(Debug, Clone)]
98pub struct SixelRegion {
99 /// Starting row (0-indexed).
100 pub start_row: u16,
101 /// Starting column (0-indexed).
102 pub start_col: u16,
103 /// Width in pixels.
104 pub width: u32,
105 /// Height in pixels.
106 pub height: u32,
107 /// Raw Sixel escape sequence data.
108 pub data: Vec<u8>,
109}
110
111/// Terminal state tracking for vtparse parser.
112///
113/// Implements VTActor to handle escape sequences including DCS for Sixel.
114struct TerminalState {
115 cursor_pos: (u16, u16),
116 sixel_regions: Vec<SixelRegion>,
117 current_sixel_data: Vec<u8>,
118 current_sixel_params: Vec<i64>,
119 in_sixel_mode: bool,
120 width: u16,
121 height: u16,
122 cells: Vec<Vec<Cell>>,
123 /// Current text attributes (for SGR sequences)
124 current_fg: Option<u8>,
125 current_bg: Option<u8>,
126 current_bold: bool,
127 current_italic: bool,
128 current_underline: bool,
129}
130
131impl TerminalState {
132 fn new(width: u16, height: u16) -> Self {
133 let cells = vec![vec![Cell::default(); width as usize]; height as usize];
134
135 Self {
136 cursor_pos: (0, 0),
137 sixel_regions: Vec::new(),
138 current_sixel_data: Vec::new(),
139 current_sixel_params: Vec::new(),
140 in_sixel_mode: false,
141 width,
142 height,
143 cells,
144 current_fg: None,
145 current_bg: None,
146 current_bold: false,
147 current_italic: false,
148 current_underline: false,
149 }
150 }
151
152 fn put_char(&mut self, ch: char) {
153 let (row, col) = self.cursor_pos;
154 if row < self.height && col < self.width {
155 self.cells[row as usize][col as usize] = Cell {
156 c: ch,
157 fg: self.current_fg,
158 bg: self.current_bg,
159 bold: self.current_bold,
160 italic: self.current_italic,
161 underline: self.current_underline,
162 };
163 // Move cursor forward, but don't wrap automatically
164 if col + 1 < self.width {
165 self.cursor_pos.1 = col + 1;
166 }
167 }
168 }
169
170 fn move_cursor(&mut self, row: u16, col: u16) {
171 self.cursor_pos = (row.min(self.height - 1), col.min(self.width - 1));
172 }
173
174 /// Parse raster attributes from sixel data.
175 ///
176 /// Sixel raster attributes follow the format: "Pan;Pad;Ph;Pv
177 /// Where:
178 /// - Pan: Pixel aspect ratio numerator (typically 1)
179 /// - Pad: Pixel aspect ratio denominator (typically 1)
180 /// - Ph: Horizontal pixel dimension (width)
181 /// - Pv: Vertical pixel dimension (height)
182 ///
183 /// # Arguments
184 ///
185 /// * `data` - Raw sixel data bytes containing raster attributes
186 ///
187 /// # Returns
188 ///
189 /// `Some((width, height))` in pixels if raster attributes are found and valid,
190 /// `None` otherwise.
191 ///
192 /// # Examples
193 ///
194 /// - "1;1;100;50" → Some((100, 50))
195 /// - "100;50" → Some((100, 50)) (missing aspect ratio parameters)
196 /// - "" → None (no raster attributes)
197 fn parse_raster_attributes(&self, data: &[u8]) -> Option<(u32, u32)> {
198 let data_str = std::str::from_utf8(data).ok()?;
199
200 // Find the raster attributes command starting with '"'
201 let raster_start = data_str.find('"')?;
202 let after_quote = &data_str[raster_start + 1..];
203
204 // Find where the raster attributes end (terminated by non-digit, non-semicolon)
205 let end_pos = after_quote
206 .find(|c: char| !c.is_ascii_digit() && c != ';')
207 .unwrap_or(after_quote.len());
208
209 let raster_part = &after_quote[..end_pos];
210
211 // Parse semicolon-separated numeric parameters
212 // Format: Pa;Pb;Ph;Pv where we need Ph (index 2) and Pv (index 3)
213 let parts: Vec<&str> = raster_part
214 .split(';')
215 .filter(|s| !s.is_empty())
216 .collect();
217
218 // Handle different parameter counts:
219 // - 4 params: Pan;Pad;Ph;Pv (full format)
220 // - 2 params: Ph;Pv (abbreviated format, aspect ratio omitted)
221 match parts.len() {
222 4 => {
223 // Full format: Pan;Pad;Ph;Pv
224 let width = parts[2].parse::<u32>().ok()?;
225 let height = parts[3].parse::<u32>().ok()?;
226 if width > 0 && height > 0 {
227 Some((width, height))
228 } else {
229 None
230 }
231 }
232 2 => {
233 // Abbreviated format: Ph;Pv
234 let width = parts[0].parse::<u32>().ok()?;
235 let height = parts[1].parse::<u32>().ok()?;
236 if width > 0 && height > 0 {
237 Some((width, height))
238 } else {
239 None
240 }
241 }
242 _ => None,
243 }
244 }
245
246 /// Converts pixel dimensions to terminal cell dimensions.
247 ///
248 /// Uses standard Sixel-to-terminal conversion ratios:
249 /// - 8 pixels per column (horizontal)
250 /// - 6 pixels per row (vertical - based on Sixel sixel height)
251 ///
252 /// These ratios are typical for Sixel graphics in VT340-compatible terminals.
253 /// Each Sixel band is 6 pixels tall, and character cells are typically 8 pixels wide.
254 ///
255 /// # Arguments
256 ///
257 /// * `width_px` - Width in pixels
258 /// * `height_px` - Height in pixels
259 ///
260 /// # Returns
261 ///
262 /// A tuple of (columns, rows) in terminal cells, with fractional cells rounded up.
263 ///
264 /// # Examples
265 ///
266 /// - (80, 60) pixels → (10, 10) cells
267 /// - (100, 50) pixels → (13, 9) cells (rounded up)
268 /// - (0, 0) pixels → (0, 0) cells
269 fn pixels_to_cells(width_px: u32, height_px: u32) -> (u16, u16) {
270 // Standard Sixel pixel-to-cell ratios
271 const PIXELS_PER_COL: u32 = 8;
272 const PIXELS_PER_ROW: u32 = 6;
273
274 let cols = if width_px > 0 {
275 ((width_px + PIXELS_PER_COL - 1) / PIXELS_PER_COL) as u16
276 } else {
277 0
278 };
279
280 let rows = if height_px > 0 {
281 ((height_px + PIXELS_PER_ROW - 1) / PIXELS_PER_ROW) as u16
282 } else {
283 0
284 };
285
286 (cols, rows)
287 }
288}
289
290impl VTActor for TerminalState {
291 fn print(&mut self, ch: char) {
292 self.put_char(ch);
293 }
294
295 fn execute_c0_or_c1(&mut self, control: u8) {
296 match control {
297 b'\r' => {
298 // Carriage return
299 self.cursor_pos.1 = 0;
300 }
301 b'\n' => {
302 // Line feed
303 if self.cursor_pos.0 + 1 < self.height {
304 self.cursor_pos.0 += 1;
305 }
306 }
307 b'\t' => {
308 // Tab - advance to next tab stop (every 8 columns)
309 let next_tab = ((self.cursor_pos.1 / 8) + 1) * 8;
310 self.cursor_pos.1 = next_tab.min(self.width - 1);
311 }
312 _ => {}
313 }
314 }
315
316 fn dcs_hook(
317 &mut self,
318 mode: u8,
319 params: &[i64],
320 _intermediates: &[u8],
321 _ignored_excess_intermediates: bool,
322 ) {
323 // Sixel sequences are identified by mode byte 'q' (0x71)
324 if mode == b'q' {
325 self.in_sixel_mode = true;
326 self.current_sixel_data.clear();
327 self.current_sixel_params = params.to_vec();
328 }
329 }
330
331 fn dcs_put(&mut self, byte: u8) {
332 if self.in_sixel_mode {
333 self.current_sixel_data.push(byte);
334 }
335 }
336
337 fn dcs_unhook(&mut self) {
338 if self.in_sixel_mode {
339 // Parse dimensions from raster attributes if present
340 let (width, height) = self
341 .parse_raster_attributes(&self.current_sixel_data)
342 .unwrap_or((0, 0));
343
344 let region = SixelRegion {
345 start_row: self.cursor_pos.0,
346 start_col: self.cursor_pos.1,
347 width,
348 height,
349 data: self.current_sixel_data.clone(),
350 };
351 self.sixel_regions.push(region);
352
353 self.in_sixel_mode = false;
354 self.current_sixel_data.clear();
355 self.current_sixel_params.clear();
356 }
357 }
358
359 fn csi_dispatch(&mut self, params: &[CsiParam], _truncated: bool, byte: u8) {
360 match byte {
361 b'H' | b'f' => {
362 // CUP - Cursor Position ESC [ row ; col H
363 // CSI uses 1-based indexing, convert to 0-based
364 // Filter out P variants (separators) and collect only integers
365 let integers: Vec<i64> = params
366 .iter()
367 .filter_map(|p| p.as_integer())
368 .collect();
369
370 let row = integers
371 .get(0)
372 .copied()
373 .unwrap_or(1)
374 .saturating_sub(1) as u16;
375 let col = integers
376 .get(1)
377 .copied()
378 .unwrap_or(1)
379 .saturating_sub(1) as u16;
380
381 self.move_cursor(row, col);
382 }
383 b'A' => {
384 // CUU - Cursor Up
385 let n = params
386 .iter()
387 .find_map(|p| p.as_integer())
388 .unwrap_or(1) as u16;
389 self.cursor_pos.0 = self.cursor_pos.0.saturating_sub(n);
390 }
391 b'B' => {
392 // CUD - Cursor Down
393 let n = params
394 .iter()
395 .find_map(|p| p.as_integer())
396 .unwrap_or(1) as u16;
397 self.cursor_pos.0 = (self.cursor_pos.0 + n).min(self.height - 1);
398 }
399 b'C' => {
400 // CUF - Cursor Forward
401 let n = params
402 .iter()
403 .find_map(|p| p.as_integer())
404 .unwrap_or(1) as u16;
405 self.cursor_pos.1 = (self.cursor_pos.1 + n).min(self.width - 1);
406 }
407 b'D' => {
408 // CUB - Cursor Back
409 let n = params
410 .iter()
411 .find_map(|p| p.as_integer())
412 .unwrap_or(1) as u16;
413 self.cursor_pos.1 = self.cursor_pos.1.saturating_sub(n);
414 }
415 b'm' => {
416 // SGR - Select Graphic Rendition (colors and attributes)
417 let integers: Vec<i64> = params
418 .iter()
419 .filter_map(|p| p.as_integer())
420 .collect();
421
422 // Handle empty params (reset)
423 if integers.is_empty() {
424 self.current_fg = None;
425 self.current_bg = None;
426 self.current_bold = false;
427 self.current_italic = false;
428 self.current_underline = false;
429 return;
430 }
431
432 let mut i = 0;
433 while i < integers.len() {
434 match integers[i] {
435 0 => {
436 // Reset all attributes
437 self.current_fg = None;
438 self.current_bg = None;
439 self.current_bold = false;
440 self.current_italic = false;
441 self.current_underline = false;
442 }
443 1 => self.current_bold = true,
444 3 => self.current_italic = true,
445 4 => self.current_underline = true,
446 22 => self.current_bold = false,
447 23 => self.current_italic = false,
448 24 => self.current_underline = false,
449 // Foreground colors (30-37: standard, 90-97: bright)
450 30..=37 => self.current_fg = Some((integers[i] - 30) as u8),
451 90..=97 => self.current_fg = Some((integers[i] - 90 + 8) as u8),
452 39 => self.current_fg = None, // Default foreground
453 // Background colors (40-47: standard, 100-107: bright)
454 40..=47 => self.current_bg = Some((integers[i] - 40) as u8),
455 100..=107 => self.current_bg = Some((integers[i] - 100 + 8) as u8),
456 49 => self.current_bg = None, // Default background
457 // 256-color mode: ESC[38;5;N or ESC[48;5;N
458 38 | 48 => {
459 if i + 2 < integers.len() && integers[i + 1] == 5 {
460 let color = integers[i + 2] as u8;
461 if integers[i] == 38 {
462 self.current_fg = Some(color);
463 } else {
464 self.current_bg = Some(color);
465 }
466 i += 2; // Skip the '5' and color value
467 }
468 }
469 _ => {} // Ignore unknown SGR codes
470 }
471 i += 1;
472 }
473 }
474 _ => {}
475 }
476 }
477
478 fn esc_dispatch(
479 &mut self,
480 _params: &[i64],
481 _intermediates: &[u8],
482 _ignored_excess_intermediates: bool,
483 byte: u8,
484 ) {
485 match byte {
486 b'D' => {
487 // IND - Index (move cursor down)
488 if self.cursor_pos.0 + 1 < self.height {
489 self.cursor_pos.0 += 1;
490 }
491 }
492 b'E' => {
493 // NEL - Next Line
494 if self.cursor_pos.0 + 1 < self.height {
495 self.cursor_pos.0 += 1;
496 }
497 self.cursor_pos.1 = 0;
498 }
499 _ => {}
500 }
501 }
502
503 fn osc_dispatch(&mut self, _params: &[&[u8]]) {
504 // Handle OSC sequences (window title, etc.)
505 // Not needed for basic functionality
506 }
507
508 fn apc_dispatch(&mut self, _data: Vec<u8>) {
509 // Handle APC sequences (e.g., Kitty graphics protocol)
510 // Not needed for basic functionality
511 }
512}
513
514/// Represents the current state of the terminal screen.
515///
516/// `ScreenState` is the core terminal emulator that tracks:
517/// - Text content at each cell position
518/// - Current cursor position
519/// - Sixel graphics regions (when rendered via DCS sequences)
520///
521/// It wraps a [`vtparse`] parser that processes VT100/ANSI escape sequences
522/// and maintains the screen state accordingly.
523///
524/// # Usage
525///
526/// The typical workflow is:
527/// 1. Create a `ScreenState` with desired dimensions
528/// 2. Feed PTY output bytes using [`feed()`](Self::feed)
529/// 3. Query the state using various accessor methods
530///
531/// # Example
532///
533/// ```rust
534/// use ratatui_testlib::ScreenState;
535///
536/// let mut screen = ScreenState::new(80, 24);
537///
538/// // Feed some terminal output
539/// screen.feed(b"\x1b[2J"); // Clear screen
540/// screen.feed(b"\x1b[5;10H"); // Move cursor to (5, 10)
541/// screen.feed(b"Hello!");
542///
543/// // Query the state
544/// assert_eq!(screen.cursor_position(), (4, 16)); // 0-indexed
545/// assert_eq!(screen.text_at(4, 9), Some('H'));
546/// assert!(screen.contains("Hello"));
547/// ```
548pub struct ScreenState {
549 parser: VTParser,
550 state: TerminalState,
551 width: u16,
552 height: u16,
553}
554
555impl ScreenState {
556 /// Creates a new screen state with the specified dimensions.
557 ///
558 /// Initializes an empty screen filled with spaces, with the cursor at (0, 0).
559 ///
560 /// # Arguments
561 ///
562 /// * `width` - Screen width in columns
563 /// * `height` - Screen height in rows
564 ///
565 /// # Example
566 ///
567 /// ```rust
568 /// use ratatui_testlib::ScreenState;
569 ///
570 /// let screen = ScreenState::new(80, 24);
571 /// assert_eq!(screen.size(), (80, 24));
572 /// assert_eq!(screen.cursor_position(), (0, 0));
573 /// ```
574 pub fn new(width: u16, height: u16) -> Self {
575 let parser = VTParser::new();
576 let state = TerminalState::new(width, height);
577
578 Self {
579 parser,
580 state,
581 width,
582 height,
583 }
584 }
585
586 /// Feeds data from the PTY to the parser.
587 ///
588 /// This processes VT100/ANSI escape sequences and updates the screen state,
589 /// including:
590 /// - Text output
591 /// - Cursor movements
592 /// - Sixel graphics (tracked via DCS callbacks)
593 ///
594 /// This method can be called multiple times to incrementally feed data.
595 /// The parser maintains state across calls, so partial escape sequences
596 /// are handled correctly.
597 ///
598 /// # Arguments
599 ///
600 /// * `data` - Raw bytes from PTY output
601 ///
602 /// # Example
603 ///
604 /// ```rust
605 /// use ratatui_testlib::ScreenState;
606 ///
607 /// let mut screen = ScreenState::new(80, 24);
608 ///
609 /// // Feed data incrementally
610 /// screen.feed(b"Hello, ");
611 /// screen.feed(b"World!");
612 ///
613 /// assert!(screen.contains("Hello, World!"));
614 /// ```
615 pub fn feed(&mut self, data: &[u8]) {
616 self.parser.parse(data, &mut self.state);
617 }
618
619 /// Returns the screen contents as a string.
620 ///
621 /// This includes all visible characters, preserving layout with newlines
622 /// between rows. Empty cells are represented as spaces.
623 ///
624 /// # Returns
625 ///
626 /// A string containing the entire screen contents, with rows separated by newlines.
627 ///
628 /// # Example
629 ///
630 /// ```rust
631 /// use ratatui_testlib::ScreenState;
632 ///
633 /// let mut screen = ScreenState::new(10, 3);
634 /// screen.feed(b"Hello");
635 ///
636 /// let contents = screen.contents();
637 /// // First line contains "Hello " (padded to 10 chars)
638 /// // Second and third lines are all spaces
639 /// assert!(contents.contains("Hello"));
640 /// ```
641 pub fn contents(&self) -> String {
642 self.state
643 .cells
644 .iter()
645 .map(|row| row.iter().map(|cell| cell.c).collect::<String>())
646 .collect::<Vec<_>>()
647 .join("\n")
648 }
649
650 /// Returns the contents of a specific row.
651 ///
652 /// # Arguments
653 ///
654 /// * `row` - Row index (0-based)
655 ///
656 /// # Returns
657 ///
658 /// The row contents as a string, or empty string if row is out of bounds.
659 pub fn row_contents(&self, row: u16) -> String {
660 if row < self.height {
661 self.state.cells[row as usize].iter().map(|cell| cell.c).collect()
662 } else {
663 String::new()
664 }
665 }
666
667 /// Returns the character at a specific position.
668 ///
669 /// # Arguments
670 ///
671 /// * `row` - Row index (0-based)
672 /// * `col` - Column index (0-based)
673 ///
674 /// # Returns
675 ///
676 /// The character at the position, or None if out of bounds.
677 pub fn text_at(&self, row: u16, col: u16) -> Option<char> {
678 if row < self.height && col < self.width {
679 Some(self.state.cells[row as usize][col as usize].c)
680 } else {
681 None
682 }
683 }
684
685 /// Returns the complete cell (character + attributes) at a specific position.
686 ///
687 /// This method provides access to the full cell state including colors and
688 /// text attributes, enabling verification of ANSI escape sequence handling.
689 ///
690 /// # Arguments
691 ///
692 /// * `row` - Row index (0-based)
693 /// * `col` - Column index (0-based)
694 ///
695 /// # Returns
696 ///
697 /// A reference to the cell, or None if out of bounds.
698 ///
699 /// # Example
700 ///
701 /// ```rust
702 /// use ratatui_testlib::ScreenState;
703 ///
704 /// let mut screen = ScreenState::new(80, 24);
705 /// screen.feed(b"\x1b[31mRed\x1b[0m");
706 ///
707 /// if let Some(cell) = screen.get_cell(0, 0) {
708 /// assert_eq!(cell.c, 'R');
709 /// assert_eq!(cell.fg, Some(1)); // Red = color 1
710 /// }
711 /// ```
712 pub fn get_cell(&self, row: u16, col: u16) -> Option<&Cell> {
713 if row < self.height && col < self.width {
714 Some(&self.state.cells[row as usize][col as usize])
715 } else {
716 None
717 }
718 }
719
720 /// Returns the current cursor position.
721 ///
722 /// # Returns
723 ///
724 /// A tuple of (row, col) with 0-based indexing.
725 pub fn cursor_position(&self) -> (u16, u16) {
726 self.state.cursor_pos
727 }
728
729 /// Returns the screen dimensions.
730 ///
731 /// # Returns
732 ///
733 /// A tuple of (width, height) in columns and rows.
734 pub fn size(&self) -> (u16, u16) {
735 (self.width, self.height)
736 }
737
738 /// Returns all Sixel graphics regions currently on screen.
739 ///
740 /// This method provides access to all Sixel graphics that have been rendered
741 /// via DCS (Device Control String) sequences. Each region includes position
742 /// and dimension information.
743 ///
744 /// This is essential for verifying Sixel positioning in tests, particularly
745 /// for ensuring that graphics appear within designated preview areas.
746 ///
747 /// # Returns
748 ///
749 /// A slice of [`SixelRegion`] containing all detected Sixel graphics.
750 ///
751 /// # Example
752 ///
753 /// ```rust
754 /// use ratatui_testlib::ScreenState;
755 ///
756 /// let mut screen = ScreenState::new(80, 24);
757 /// // ... render some Sixel graphics ...
758 ///
759 /// let regions = screen.sixel_regions();
760 /// for (i, region) in regions.iter().enumerate() {
761 /// println!("Region {}: position ({}, {}), size {}x{}",
762 /// i, region.start_row, region.start_col,
763 /// region.width, region.height);
764 /// }
765 /// ```
766 pub fn sixel_regions(&self) -> &[SixelRegion] {
767 &self.state.sixel_regions
768 }
769
770 /// Checks if a Sixel region exists at the given position.
771 ///
772 /// This method checks if any Sixel region has its starting position
773 /// at the exact (row, col) coordinates provided.
774 ///
775 /// # Arguments
776 ///
777 /// * `row` - Row to check (0-indexed)
778 /// * `col` - Column to check (0-indexed)
779 ///
780 /// # Returns
781 ///
782 /// `true` if a Sixel region starts at the given position, `false` otherwise.
783 ///
784 /// # Example
785 ///
786 /// ```rust
787 /// use ratatui_testlib::ScreenState;
788 ///
789 /// let mut screen = ScreenState::new(80, 24);
790 /// // ... render Sixel at position (5, 10) ...
791 ///
792 /// assert!(screen.has_sixel_at(5, 10));
793 /// assert!(!screen.has_sixel_at(0, 0));
794 /// ```
795 pub fn has_sixel_at(&self, row: u16, col: u16) -> bool {
796 self.state.sixel_regions.iter().any(|region| {
797 region.start_row == row && region.start_col == col
798 })
799 }
800
801 /// Returns the screen contents for debugging purposes.
802 ///
803 /// This is currently an alias for [`contents()`](Self::contents), but may
804 /// include additional debug information in the future.
805 ///
806 /// # Returns
807 ///
808 /// A string containing the screen contents.
809 pub fn debug_contents(&self) -> String {
810 self.contents()
811 }
812
813 /// Checks if the screen contains the specified text.
814 ///
815 /// This is a convenience method that searches the entire screen contents
816 /// for the given substring. It's useful for simple text-based assertions
817 /// in tests.
818 ///
819 /// # Arguments
820 ///
821 /// * `text` - Text to search for
822 ///
823 /// # Returns
824 ///
825 /// `true` if the text appears anywhere on the screen, `false` otherwise.
826 ///
827 /// # Example
828 ///
829 /// ```rust
830 /// use ratatui_testlib::ScreenState;
831 ///
832 /// let mut screen = ScreenState::new(80, 24);
833 /// screen.feed(b"Welcome to the application");
834 ///
835 /// assert!(screen.contains("Welcome"));
836 /// assert!(screen.contains("application"));
837 /// assert!(!screen.contains("goodbye"));
838 /// ```
839 pub fn contains(&self, text: &str) -> bool {
840 self.contents().contains(text)
841 }
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847
848 #[test]
849 fn test_create_screen() {
850 let screen = ScreenState::new(80, 24);
851 assert_eq!(screen.size(), (80, 24));
852 }
853
854 #[test]
855 fn test_feed_simple_text() {
856 let mut screen = ScreenState::new(80, 24);
857 screen.feed(b"Hello, World!");
858 assert!(screen.contents().contains("Hello, World!"));
859 }
860
861 #[test]
862 fn test_cursor_position() {
863 let mut screen = ScreenState::new(80, 24);
864
865 // Initial position
866 assert_eq!(screen.cursor_position(), (0, 0));
867
868 // Move cursor using CSI sequence (ESC [ 5 ; 10 H = row 5, col 10)
869 screen.feed(b"\x1b[5;10H");
870 let (row, col) = screen.cursor_position();
871
872 // CSI uses 1-based, we convert to 0-based
873 assert_eq!(row, 4); // 5-1 = 4
874 assert_eq!(col, 9); // 10-1 = 9
875 }
876
877 #[test]
878 fn test_text_at() {
879 let mut screen = ScreenState::new(80, 24);
880 screen.feed(b"Test");
881
882 assert_eq!(screen.text_at(0, 0), Some('T'));
883 assert_eq!(screen.text_at(0, 1), Some('e'));
884 assert_eq!(screen.text_at(0, 2), Some('s'));
885 assert_eq!(screen.text_at(0, 3), Some('t'));
886 assert_eq!(screen.text_at(0, 4), Some(' '));
887 assert_eq!(screen.text_at(100, 100), None);
888 }
889
890 #[test]
891 fn test_parse_raster_full() {
892 let state = TerminalState::new(80, 24);
893
894 // Full format: Pan;Pad;Ph;Pv
895 let data = b"\"1;1;100;50#0;2;100;100;100#0~";
896 assert_eq!(state.parse_raster_attributes(data), Some((100, 50)));
897
898 // Different aspect ratios
899 let data = b"\"2;1;200;100#0~";
900 assert_eq!(state.parse_raster_attributes(data), Some((200, 100)));
901 }
902
903 #[test]
904 fn test_parse_raster_partial() {
905 let state = TerminalState::new(80, 24);
906
907 // Abbreviated format: Ph;Pv (aspect ratio omitted)
908 let data = b"\"100;50#0~";
909 assert_eq!(state.parse_raster_attributes(data), Some((100, 50)));
910
911 let data = b"\"80;60#0;2;0;0;0";
912 assert_eq!(state.parse_raster_attributes(data), Some((80, 60)));
913 }
914
915 #[test]
916 fn test_parse_raster_malformed() {
917 let state = TerminalState::new(80, 24);
918
919 // No raster attributes
920 assert_eq!(state.parse_raster_attributes(b"#0~"), None);
921
922 // Empty string
923 assert_eq!(state.parse_raster_attributes(b""), None);
924
925 // Invalid UTF-8
926 assert_eq!(state.parse_raster_attributes(&[0xFF, 0xFE]), None);
927
928 // Single parameter
929 assert_eq!(state.parse_raster_attributes(b"\"100"), None);
930
931 // Three parameters (invalid)
932 assert_eq!(state.parse_raster_attributes(b"\"1;1;100"), None);
933
934 // Zero dimensions (invalid)
935 assert_eq!(state.parse_raster_attributes(b"\"1;1;0;50"), None, "Should reject zero width");
936 assert_eq!(state.parse_raster_attributes(b"\"1;1;100;0"), None, "Should reject zero height");
937 assert_eq!(state.parse_raster_attributes(b"\"0;0"), None, "Should reject zero dimensions in abbreviated format");
938
939 // Non-numeric values
940 assert_eq!(state.parse_raster_attributes(b"\"abc;def"), None);
941
942 // Mixed numeric/non-numeric: parser stops at first non-numeric, non-semicolon
943 // "1;1;abc;def" becomes "1;1" which is valid 2-param format
944 // This is intentional - we parse up to the first non-numeric character
945 assert_eq!(state.parse_raster_attributes(b"\"1;1;abc;def"), Some((1, 1)));
946 }
947
948 #[test]
949 fn test_parse_raster_edge_cases() {
950 let state = TerminalState::new(80, 24);
951
952 // Large dimensions
953 let data = b"\"1;1;4096;2048#0~";
954 assert_eq!(state.parse_raster_attributes(data), Some((4096, 2048)));
955
956 // Minimum valid dimensions
957 let data = b"\"1;1;1;1#0~";
958 assert_eq!(state.parse_raster_attributes(data), Some((1, 1)));
959
960 // Extra whitespace/characters after parameters
961 let data = b"\"1;1;100;50 \t#0~";
962 assert_eq!(state.parse_raster_attributes(data), Some((100, 50)));
963 }
964
965 #[test]
966 fn test_pixels_to_cells() {
967 // Standard conversions (8 pixels/col, 6 pixels/row)
968 assert_eq!(TerminalState::pixels_to_cells(80, 60), (10, 10));
969 assert_eq!(TerminalState::pixels_to_cells(0, 0), (0, 0));
970
971 // Exact multiples
972 assert_eq!(TerminalState::pixels_to_cells(800, 600), (100, 100));
973 assert_eq!(TerminalState::pixels_to_cells(16, 12), (2, 2));
974
975 // Fractional cells (should round up)
976 assert_eq!(TerminalState::pixels_to_cells(81, 61), (11, 11));
977 assert_eq!(TerminalState::pixels_to_cells(100, 50), (13, 9));
978 assert_eq!(TerminalState::pixels_to_cells(1, 1), (1, 1));
979
980 // Typical Sixel dimensions from real use
981 assert_eq!(TerminalState::pixels_to_cells(640, 480), (80, 80));
982 assert_eq!(TerminalState::pixels_to_cells(320, 240), (40, 40));
983 }
984
985 #[test]
986 fn test_sixel_region_tracking() {
987 let mut screen = ScreenState::new(80, 24);
988
989 // Feed a complete Sixel sequence with raster attributes
990 screen.feed(b"\x1b[5;10H"); // Move cursor to (5, 10) [1-based]
991 screen.feed(b"\x1bPq"); // DCS - Start Sixel with 'q'
992 screen.feed(b"\"1;1;100;50"); // Raster attributes: 100x50 pixels
993 screen.feed(b"#0;2;100;100;100"); // Define color 0
994 screen.feed(b"#0~~@@"); // Some sixel data
995 screen.feed(b"\x1b\\"); // String terminator (ST)
996
997 // Verify the Sixel region was captured
998 let regions = screen.sixel_regions();
999 assert_eq!(regions.len(), 1, "Should capture exactly one Sixel region");
1000
1001 let region = ®ions[0];
1002 assert_eq!(region.start_row, 4, "Row should be 4 (0-based from 5)");
1003 assert_eq!(region.start_col, 9, "Col should be 9 (0-based from 10)");
1004 assert_eq!(region.width, 100, "Width should be 100 pixels");
1005 assert_eq!(region.height, 50, "Height should be 50 pixels");
1006 assert!(!region.data.is_empty(), "Data should be captured");
1007
1008 // Verify has_sixel_at
1009 assert!(screen.has_sixel_at(4, 9), "Should detect Sixel at position");
1010 assert!(!screen.has_sixel_at(0, 0), "Should not detect Sixel at wrong position");
1011 }
1012
1013 #[test]
1014 fn test_multiple_sixel_regions() {
1015 let mut screen = ScreenState::new(100, 30);
1016
1017 // First Sixel
1018 screen.feed(b"\x1b[5;5H\x1bPq\"1;1;80;60#0~\x1b\\");
1019
1020 // Second Sixel
1021 screen.feed(b"\x1b[15;50H\x1bPq\"1;1;100;80#0~\x1b\\");
1022
1023 let regions = screen.sixel_regions();
1024 assert_eq!(regions.len(), 2, "Should capture both Sixel regions");
1025
1026 // Verify first region
1027 assert_eq!(regions[0].start_row, 4);
1028 assert_eq!(regions[0].start_col, 4);
1029 assert_eq!(regions[0].width, 80);
1030 assert_eq!(regions[0].height, 60);
1031
1032 // Verify second region
1033 assert_eq!(regions[1].start_row, 14);
1034 assert_eq!(regions[1].start_col, 49);
1035 assert_eq!(regions[1].width, 100);
1036 assert_eq!(regions[1].height, 80);
1037 }
1038
1039 #[test]
1040 fn test_sixel_without_raster_attributes() {
1041 let mut screen = ScreenState::new(80, 24);
1042
1043 // Sixel without raster attributes (legacy format)
1044 screen.feed(b"\x1b[10;10H\x1bPq#0~\x1b\\");
1045
1046 let regions = screen.sixel_regions();
1047 assert_eq!(regions.len(), 1, "Should still capture region");
1048
1049 let region = ®ions[0];
1050 assert_eq!(region.width, 0, "Width should be 0 without raster attributes");
1051 assert_eq!(region.height, 0, "Height should be 0 without raster attributes");
1052 }
1053
1054 #[test]
1055 fn test_sixel_abbreviated_format() {
1056 let mut screen = ScreenState::new(80, 24);
1057
1058 // Abbreviated raster format (just width;height)
1059 screen.feed(b"\x1b[1;1H\x1bPq\"200;150#0~\x1b\\");
1060
1061 let regions = screen.sixel_regions();
1062 assert_eq!(regions.len(), 1);
1063
1064 let region = ®ions[0];
1065 assert_eq!(region.width, 200);
1066 assert_eq!(region.height, 150);
1067 }
1068}