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 = &regions[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 = &regions[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 = &regions[0];
1065        assert_eq!(region.width, 200);
1066        assert_eq!(region.height, 150);
1067    }
1068}