Skip to main content

ftui_render/
terminal_model.rs

1#![forbid(unsafe_code)]
2
3//! Terminal model for presenter validation.
4//!
5//! This module provides a minimal terminal emulator that understands
6//! the subset of ANSI sequences we emit, enabling deterministic testing
7//! of the presenter without requiring actual terminal I/O.
8//!
9//! # Scope
10//!
11//! This is NOT a full VT emulator. It supports only:
12//! - Cursor positioning (CUP, relative moves)
13//! - SGR (style attributes)
14//! - Erase operations (EL, ED)
15//! - OSC 8 hyperlinks
16//! - DEC 2026 synchronized output (tracked but visual effects ignored)
17//!
18//! # Usage
19//!
20//! ```ignore
21//! let mut model = TerminalModel::new(80, 24);
22//! model.process(b"\x1b[1;1H"); // Move cursor to (0, 0)
23//! model.process(b"\x1b[1mHello\x1b[0m"); // Write "Hello" in bold
24//! assert_eq!(model.cursor(), (5, 0)); // Cursor advanced
25//! assert_eq!(model.cell(0, 0).text, "H");
26//! ```
27
28use crate::{
29    cell::{CellAttrs, PackedRgba, StyleFlags},
30    char_width,
31};
32
33/// A single cell in the terminal model grid.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ModelCell {
36    // ... (existing code matches, just updating imports implies I need context for replace)
37    /// Text content (grapheme cluster). Default is space.
38    pub text: String,
39    /// Foreground color.
40    pub fg: PackedRgba,
41    /// Background color.
42    pub bg: PackedRgba,
43    /// Style flags (bold, italic, etc.).
44    pub attrs: CellAttrs,
45    /// Hyperlink ID (0 = no link).
46    pub link_id: u32,
47}
48
49impl Default for ModelCell {
50    fn default() -> Self {
51        Self {
52            text: " ".to_string(),
53            fg: PackedRgba::WHITE,
54            bg: PackedRgba::TRANSPARENT,
55            attrs: CellAttrs::NONE,
56            link_id: 0,
57        }
58    }
59}
60
61impl ModelCell {
62    /// Create a cell with the given character and default style.
63    pub fn with_char(ch: char) -> Self {
64        Self {
65            text: ch.to_string(),
66            ..Default::default()
67        }
68    }
69}
70
71/// Current SGR (style) state for the terminal model.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SgrState {
74    /// Current foreground color.
75    pub fg: PackedRgba,
76    /// Current background color.
77    pub bg: PackedRgba,
78    /// Current text attribute flags.
79    pub flags: StyleFlags,
80}
81
82impl Default for SgrState {
83    fn default() -> Self {
84        Self {
85            fg: PackedRgba::WHITE,
86            bg: PackedRgba::TRANSPARENT,
87            flags: StyleFlags::empty(),
88        }
89    }
90}
91
92impl SgrState {
93    /// Reset all fields to defaults (white fg, transparent bg, no flags).
94    pub fn reset(&mut self) {
95        *self = Self::default();
96    }
97}
98
99/// Mode flags tracked by the terminal model.
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct ModeFlags {
102    /// Cursor visibility.
103    pub cursor_visible: bool,
104    /// Alternate screen buffer active.
105    pub alt_screen: bool,
106    /// DEC 2026 synchronized output nesting level.
107    pub sync_output_level: u32,
108}
109
110impl ModeFlags {
111    /// Create default mode flags (cursor visible, main screen, sync=0).
112    pub fn new() -> Self {
113        Self {
114            cursor_visible: true,
115            alt_screen: false,
116            sync_output_level: 0,
117        }
118    }
119}
120
121/// Parser state for ANSI escape sequences.
122#[derive(Debug, Clone, PartialEq, Eq)]
123enum ParseState {
124    Ground,
125    Escape,
126    CsiEntry,
127    CsiParam,
128    OscEntry,
129    OscString,
130}
131
132/// A minimal terminal model for testing presenter output.
133///
134/// Tracks grid state, cursor position, SGR state, and hyperlinks.
135/// Processes a subset of ANSI sequences that we emit.
136#[derive(Debug)]
137pub struct TerminalModel {
138    width: usize,
139    height: usize,
140    cells: Vec<ModelCell>,
141    cursor_x: usize,
142    cursor_y: usize,
143    sgr: SgrState,
144    modes: ModeFlags,
145    current_link_id: u32,
146    /// Hyperlink URL registry (link_id -> URL).
147    links: Vec<String>,
148    /// Parser state.
149    parse_state: ParseState,
150    /// CSI parameter buffer.
151    csi_params: Vec<u32>,
152    /// CSI intermediate accumulator.
153    csi_intermediate: Vec<u8>,
154    /// OSC accumulator.
155    osc_buffer: Vec<u8>,
156    /// Pending UTF-8 bytes for multibyte characters.
157    utf8_pending: Vec<u8>,
158    /// Expected UTF-8 sequence length (None if not in a sequence).
159    utf8_expected: Option<usize>,
160    /// Bytes processed (for debugging).
161    bytes_processed: usize,
162}
163
164impl TerminalModel {
165    /// Create a new terminal model with the given dimensions.
166    ///
167    /// Dimensions are clamped to a minimum of 1×1 to prevent arithmetic
168    /// underflows in cursor-positioning and diff helpers.
169    pub fn new(width: usize, height: usize) -> Self {
170        let width = width.max(1);
171        let height = height.max(1);
172        let cells = vec![ModelCell::default(); width * height];
173        Self {
174            width,
175            height,
176            cells,
177            cursor_x: 0,
178            cursor_y: 0,
179            sgr: SgrState::default(),
180            modes: ModeFlags::new(),
181            current_link_id: 0,
182            links: vec![String::new()], // Index 0 is "no link"
183            parse_state: ParseState::Ground,
184            csi_params: Vec::with_capacity(16),
185            csi_intermediate: Vec::with_capacity(4),
186            osc_buffer: Vec::with_capacity(256),
187            utf8_pending: Vec::with_capacity(4),
188            utf8_expected: None,
189            bytes_processed: 0,
190        }
191    }
192
193    /// Get the terminal width.
194    #[must_use]
195    pub fn width(&self) -> usize {
196        self.width
197    }
198
199    /// Get the terminal height.
200    #[must_use]
201    pub fn height(&self) -> usize {
202        self.height
203    }
204
205    /// Get the cursor position as (x, y).
206    #[must_use]
207    pub fn cursor(&self) -> (usize, usize) {
208        (self.cursor_x, self.cursor_y)
209    }
210
211    /// Get the current SGR state.
212    #[must_use]
213    pub fn sgr_state(&self) -> &SgrState {
214        &self.sgr
215    }
216
217    /// Get the current mode flags.
218    #[must_use]
219    pub fn modes(&self) -> &ModeFlags {
220        &self.modes
221    }
222
223    /// Get the cell at (x, y). Returns None if out of bounds.
224    #[must_use]
225    pub fn cell(&self, x: usize, y: usize) -> Option<&ModelCell> {
226        if x < self.width && y < self.height {
227            Some(&self.cells[y * self.width + x])
228        } else {
229            None
230        }
231    }
232
233    /// Get a mutable reference to the cell at (x, y).
234    fn cell_mut(&mut self, x: usize, y: usize) -> Option<&mut ModelCell> {
235        if x < self.width && y < self.height {
236            Some(&mut self.cells[y * self.width + x])
237        } else {
238            None
239        }
240    }
241
242    /// Get the current cell under the cursor.
243    #[must_use]
244    pub fn current_cell(&self) -> Option<&ModelCell> {
245        self.cell(self.cursor_x, self.cursor_y)
246    }
247
248    /// Get all cells as a slice.
249    pub fn cells(&self) -> &[ModelCell] {
250        &self.cells
251    }
252
253    /// Get a row of cells.
254    #[must_use]
255    pub fn row(&self, y: usize) -> Option<&[ModelCell]> {
256        if y < self.height {
257            let start = y * self.width;
258            Some(&self.cells[start..start + self.width])
259        } else {
260            None
261        }
262    }
263
264    /// Extract the text content of a row as a string (trimmed of trailing spaces).
265    #[must_use]
266    pub fn row_text(&self, y: usize) -> Option<String> {
267        self.row(y).map(|cells| {
268            let s: String = cells.iter().map(|c| c.text.as_str()).collect();
269            s.trim_end_matches(' ').to_string()
270        })
271    }
272
273    /// Get the URL for a link ID.
274    #[must_use]
275    pub fn link_url(&self, link_id: u32) -> Option<&str> {
276        self.links.get(link_id as usize).map(|s| s.as_str())
277    }
278
279    /// Check if the terminal has a dangling hyperlink (active link after processing).
280    pub fn has_dangling_link(&self) -> bool {
281        self.current_link_id != 0
282    }
283
284    /// Check if synchronized output is properly balanced.
285    pub fn sync_output_balanced(&self) -> bool {
286        self.modes.sync_output_level == 0
287    }
288
289    /// Reset the terminal model to initial state.
290    pub fn reset(&mut self) {
291        self.cells.fill(ModelCell::default());
292        self.cursor_x = 0;
293        self.cursor_y = 0;
294        self.sgr = SgrState::default();
295        self.modes = ModeFlags::new();
296        self.current_link_id = 0;
297        // Restore hyperlink registry to initial state (index 0 = empty sentinel)
298        self.links.clear();
299        self.links.push(String::new());
300        self.parse_state = ParseState::Ground;
301        self.csi_params.clear();
302        self.csi_intermediate.clear();
303        self.osc_buffer.clear();
304        self.utf8_pending.clear();
305        self.utf8_expected = None;
306    }
307
308    /// Process a byte sequence, updating the terminal state.
309    pub fn process(&mut self, bytes: &[u8]) {
310        for &b in bytes {
311            self.process_byte(b);
312            self.bytes_processed += 1;
313        }
314    }
315
316    /// Process a single byte.
317    fn process_byte(&mut self, b: u8) {
318        match self.parse_state {
319            ParseState::Ground => self.ground_state(b),
320            ParseState::Escape => self.escape_state(b),
321            ParseState::CsiEntry => self.csi_entry_state(b),
322            ParseState::CsiParam => self.csi_param_state(b),
323            ParseState::OscEntry => self.osc_entry_state(b),
324            ParseState::OscString => self.osc_string_state(b),
325        }
326    }
327
328    fn ground_state(&mut self, b: u8) {
329        match b {
330            0x1B => {
331                // ESC
332                self.flush_pending_utf8_invalid();
333                self.parse_state = ParseState::Escape;
334            }
335            0x00..=0x1A | 0x1C..=0x1F => {
336                // C0 controls (mostly ignored)
337                self.flush_pending_utf8_invalid();
338                self.handle_c0(b);
339            }
340            _ => {
341                // Printable character (UTF-8 aware)
342                self.handle_printable(b);
343            }
344        }
345    }
346
347    fn escape_state(&mut self, b: u8) {
348        match b {
349            b'[' => {
350                // CSI
351                self.csi_params.clear();
352                self.csi_intermediate.clear();
353                self.parse_state = ParseState::CsiEntry;
354            }
355            b']' => {
356                // OSC
357                self.osc_buffer.clear();
358                self.parse_state = ParseState::OscEntry;
359            }
360            b'7' => {
361                // DECSC - save cursor (we track but don't implement save/restore stack)
362                self.parse_state = ParseState::Ground;
363            }
364            b'8' => {
365                // DECRC - restore cursor
366                self.parse_state = ParseState::Ground;
367            }
368            b'=' | b'>' => {
369                // Application/Normal keypad mode (ignored)
370                self.parse_state = ParseState::Ground;
371            }
372            0x1B => {
373                // ESC ESC - stay in escape (malformed, but handle gracefully)
374            }
375            _ => {
376                // Unknown escape, return to ground
377                self.parse_state = ParseState::Ground;
378            }
379        }
380    }
381
382    fn csi_entry_state(&mut self, b: u8) {
383        match b {
384            b'0'..=b'9' => {
385                self.csi_params.push((b - b'0') as u32);
386                self.parse_state = ParseState::CsiParam;
387            }
388            b';' => {
389                // Semicolon at entry means "default first param, start second param".
390                // Push 0 for first param (default) AND 0 for the start of the second.
391                self.csi_params.push(0);
392                self.csi_params.push(0);
393                self.parse_state = ParseState::CsiParam;
394            }
395            b'?' | b'>' | b'!' => {
396                self.csi_intermediate.push(b);
397                self.parse_state = ParseState::CsiParam;
398            }
399            0x40..=0x7E => {
400                // Final byte with no params
401                self.execute_csi(b);
402                self.parse_state = ParseState::Ground;
403            }
404            _ => {
405                self.parse_state = ParseState::Ground;
406            }
407        }
408    }
409
410    fn csi_param_state(&mut self, b: u8) {
411        match b {
412            b'0'..=b'9' => {
413                if self.csi_params.is_empty() {
414                    self.csi_params.push(0);
415                }
416                if let Some(last) = self.csi_params.last_mut() {
417                    *last = last.saturating_mul(10).saturating_add((b - b'0') as u32);
418                }
419            }
420            b';' => {
421                self.csi_params.push(0);
422            }
423            b':' => {
424                // Subparameter (e.g., for 256/RGB colors) - we handle in SGR
425                self.csi_params.push(0);
426            }
427            0x20..=0x2F => {
428                self.csi_intermediate.push(b);
429            }
430            0x40..=0x7E => {
431                // Final byte
432                self.execute_csi(b);
433                self.parse_state = ParseState::Ground;
434            }
435            _ => {
436                self.parse_state = ParseState::Ground;
437            }
438        }
439    }
440
441    fn osc_entry_state(&mut self, b: u8) {
442        match b {
443            0x07 => {
444                // BEL - OSC terminator
445                self.execute_osc();
446                self.parse_state = ParseState::Ground;
447            }
448            0x1B => {
449                // Might be ST (ESC \)
450                self.parse_state = ParseState::OscString;
451            }
452            _ => {
453                self.osc_buffer.push(b);
454            }
455        }
456    }
457
458    fn osc_string_state(&mut self, b: u8) {
459        match b {
460            b'\\' => {
461                // ST (ESC \)
462                self.execute_osc();
463                self.parse_state = ParseState::Ground;
464            }
465            _ => {
466                // Not ST, put ESC back and continue
467                self.osc_buffer.push(0x1B);
468                self.osc_buffer.push(b);
469                self.parse_state = ParseState::OscEntry;
470            }
471        }
472    }
473
474    fn handle_c0(&mut self, b: u8) {
475        match b {
476            0x07 => {} // BEL - ignored
477            0x08 if self.cursor_x > 0 => {
478                // BS - backspace
479                self.cursor_x -= 1;
480            }
481            0x09 => {
482                // HT - tab (move to next 8-column stop)
483                self.cursor_x = (self.cursor_x / 8 + 1) * 8;
484                if self.cursor_x >= self.width {
485                    self.cursor_x = self.width - 1;
486                }
487            }
488            0x0A if self.cursor_y + 1 < self.height => {
489                // LF - line feed
490                self.cursor_y += 1;
491            }
492            0x0D => {
493                // CR - carriage return
494                self.cursor_x = 0;
495            }
496            _ => {} // Other C0 controls ignored
497        }
498    }
499
500    fn handle_printable(&mut self, b: u8) {
501        if self.utf8_expected.is_none() {
502            if b < 0x80 {
503                self.put_char(b as char);
504                return;
505            }
506            if let Some(expected) = Self::utf8_expected_len(b) {
507                self.utf8_pending.clear();
508                self.utf8_pending.push(b);
509                self.utf8_expected = Some(expected);
510                if expected == 1 {
511                    self.flush_utf8_sequence();
512                }
513            } else {
514                self.put_char('\u{FFFD}');
515            }
516            return;
517        }
518
519        if !Self::is_utf8_continuation(b) {
520            self.flush_pending_utf8_invalid();
521            self.handle_printable(b);
522            return;
523        }
524
525        self.utf8_pending.push(b);
526        if let Some(expected) = self.utf8_expected {
527            if self.utf8_pending.len() == expected {
528                self.flush_utf8_sequence();
529            } else if self.utf8_pending.len() > expected {
530                self.flush_pending_utf8_invalid();
531            }
532        }
533    }
534
535    fn flush_utf8_sequence(&mut self) {
536        // Collect chars first to avoid borrow conflict with put_char.
537        // UTF-8 sequences are at most 4 bytes, so this is small.
538        let chars: Vec<char> = std::str::from_utf8(&self.utf8_pending)
539            .map(|text| text.chars().collect())
540            .unwrap_or_else(|_| vec!['\u{FFFD}']);
541        self.utf8_pending.clear();
542        self.utf8_expected = None;
543        for ch in chars {
544            self.put_char(ch);
545        }
546    }
547
548    fn flush_pending_utf8_invalid(&mut self) {
549        if self.utf8_expected.is_some() {
550            self.put_char('\u{FFFD}');
551            self.utf8_pending.clear();
552            self.utf8_expected = None;
553        }
554    }
555
556    fn utf8_expected_len(first: u8) -> Option<usize> {
557        if first < 0x80 {
558            Some(1)
559        } else if (0xC2..=0xDF).contains(&first) {
560            Some(2)
561        } else if (0xE0..=0xEF).contains(&first) {
562            Some(3)
563        } else if (0xF0..=0xF4).contains(&first) {
564            Some(4)
565        } else {
566            None
567        }
568    }
569
570    fn is_utf8_continuation(byte: u8) -> bool {
571        (0x80..=0xBF).contains(&byte)
572    }
573
574    fn put_char(&mut self, ch: char) {
575        let width = char_width(ch);
576
577        // Zero-width (combining) character handling
578        if width == 0 {
579            if self.cursor_x > 0 {
580                // Append to previous cell
581                let idx = self.cursor_y * self.width + self.cursor_x - 1;
582                if let Some(cell) = self.cells.get_mut(idx) {
583                    cell.text.push(ch);
584                }
585            } else if self.cursor_x < self.width && self.cursor_y < self.height {
586                // At start of line, attach to current cell (if empty/space) or append
587                let idx = self.cursor_y * self.width + self.cursor_x;
588                let cell = &mut self.cells[idx];
589                if cell.text == " " {
590                    // Replace default space with space+combining
591                    cell.text = format!(" {}", ch);
592                } else {
593                    cell.text.push(ch);
594                }
595            }
596            return;
597        }
598
599        if self.cursor_x < self.width && self.cursor_y < self.height {
600            let cell = &mut self.cells[self.cursor_y * self.width + self.cursor_x];
601            cell.text = ch.to_string();
602            cell.fg = self.sgr.fg;
603            cell.bg = self.sgr.bg;
604            cell.attrs = CellAttrs::new(self.sgr.flags, self.current_link_id);
605            cell.link_id = self.current_link_id;
606
607            // Handle wide characters (clear the next cell if it exists)
608            if width == 2 && self.cursor_x + 1 < self.width {
609                let next_cell = &mut self.cells[self.cursor_y * self.width + self.cursor_x + 1];
610                next_cell.text = String::new(); // Clear content (placeholder)
611                next_cell.fg = self.sgr.fg; // Extend background color
612                next_cell.bg = self.sgr.bg;
613                next_cell.attrs = CellAttrs::NONE; // Clear attributes
614                next_cell.link_id = 0; // Clear link
615            }
616        }
617
618        self.cursor_x += width;
619
620        // Handle line wrap if at edge
621        if self.cursor_x >= self.width {
622            self.cursor_x = 0;
623            if self.cursor_y + 1 < self.height {
624                self.cursor_y += 1;
625            }
626            // Note: If we are at the bottom line, we wrap to the start of the *same* line.
627            // This model does not implement scrolling because it is intended to validate
628            // Frame-based rendering where absolute positioning (CUP) is used, and
629            // wrapping/scrolling behavior should not be triggered by the Presenter.
630        }
631    }
632
633    fn execute_csi(&mut self, final_byte: u8) {
634        let has_question = self.csi_intermediate.contains(&b'?');
635
636        match final_byte {
637            b'H' | b'f' => self.csi_cup(),             // CUP - cursor position
638            b'A' => self.csi_cuu(),                    // CUU - cursor up
639            b'B' => self.csi_cud(),                    // CUD - cursor down
640            b'C' => self.csi_cuf(),                    // CUF - cursor forward
641            b'D' => self.csi_cub(),                    // CUB - cursor back
642            b'G' => self.csi_cha(),                    // CHA - cursor horizontal absolute
643            b'd' => self.csi_vpa(),                    // VPA - vertical position absolute
644            b'J' => self.csi_ed(),                     // ED - erase in display
645            b'K' => self.csi_el(),                     // EL - erase in line
646            b'm' => self.csi_sgr(),                    // SGR - select graphic rendition
647            b'h' if has_question => self.csi_decset(), // DECSET
648            b'l' if has_question => self.csi_decrst(), // DECRST
649            b's' => {
650                // Save cursor position (ANSI)
651            }
652            b'u' => {
653                // Restore cursor position (ANSI)
654            }
655            _ => {} // Unknown CSI - ignored
656        }
657    }
658
659    fn csi_cup(&mut self) {
660        // CSI row ; col H
661        let row = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
662        let col = self.csi_params.get(1).copied().unwrap_or(1).max(1) as usize;
663        self.cursor_y = (row - 1).min(self.height - 1);
664        self.cursor_x = (col - 1).min(self.width - 1);
665    }
666
667    fn csi_cuu(&mut self) {
668        let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
669        self.cursor_y = self.cursor_y.saturating_sub(n);
670    }
671
672    fn csi_cud(&mut self) {
673        let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
674        self.cursor_y = (self.cursor_y + n).min(self.height - 1);
675    }
676
677    fn csi_cuf(&mut self) {
678        let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
679        self.cursor_x = (self.cursor_x + n).min(self.width - 1);
680    }
681
682    fn csi_cub(&mut self) {
683        let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
684        self.cursor_x = self.cursor_x.saturating_sub(n);
685    }
686
687    fn csi_cha(&mut self) {
688        let col = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
689        self.cursor_x = (col - 1).min(self.width - 1);
690    }
691
692    fn csi_vpa(&mut self) {
693        let row = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
694        self.cursor_y = (row - 1).min(self.height - 1);
695    }
696
697    fn csi_ed(&mut self) {
698        let mode = self.csi_params.first().copied().unwrap_or(0);
699        match mode {
700            0 => {
701                // Erase from cursor to end of screen
702                for x in self.cursor_x..self.width {
703                    self.erase_cell(x, self.cursor_y);
704                }
705                for y in (self.cursor_y + 1)..self.height {
706                    for x in 0..self.width {
707                        self.erase_cell(x, y);
708                    }
709                }
710            }
711            1 => {
712                // Erase from start of screen to cursor
713                for y in 0..self.cursor_y {
714                    for x in 0..self.width {
715                        self.erase_cell(x, y);
716                    }
717                }
718                for x in 0..=self.cursor_x {
719                    self.erase_cell(x, self.cursor_y);
720                }
721            }
722            2 | 3 => {
723                // Erase entire screen
724                self.cells.fill(ModelCell::default());
725            }
726            _ => {}
727        }
728    }
729
730    fn csi_el(&mut self) {
731        let mode = self.csi_params.first().copied().unwrap_or(0);
732        match mode {
733            0 => {
734                // Erase from cursor to end of line
735                for x in self.cursor_x..self.width {
736                    self.erase_cell(x, self.cursor_y);
737                }
738            }
739            1 => {
740                // Erase from start of line to cursor
741                for x in 0..=self.cursor_x {
742                    self.erase_cell(x, self.cursor_y);
743                }
744            }
745            2 => {
746                // Erase entire line
747                for x in 0..self.width {
748                    self.erase_cell(x, self.cursor_y);
749                }
750            }
751            _ => {}
752        }
753    }
754
755    fn erase_cell(&mut self, x: usize, y: usize) {
756        // Copy background color before borrowing self mutably
757        let bg = self.sgr.bg;
758        if let Some(cell) = self.cell_mut(x, y) {
759            cell.text = " ".to_string();
760            // Erase uses current background color
761            cell.fg = PackedRgba::WHITE;
762            cell.bg = bg;
763            cell.attrs = CellAttrs::NONE;
764            cell.link_id = 0;
765        }
766    }
767
768    fn csi_sgr(&mut self) {
769        if self.csi_params.is_empty() {
770            self.sgr.reset();
771            return;
772        }
773
774        let mut i = 0;
775        while i < self.csi_params.len() {
776            let code = self.csi_params[i];
777            match code {
778                0 => self.sgr.reset(),
779                1 => self.sgr.flags.insert(StyleFlags::BOLD),
780                2 => self.sgr.flags.insert(StyleFlags::DIM),
781                3 => self.sgr.flags.insert(StyleFlags::ITALIC),
782                4 => self.sgr.flags.insert(StyleFlags::UNDERLINE),
783                5 => self.sgr.flags.insert(StyleFlags::BLINK),
784                7 => self.sgr.flags.insert(StyleFlags::REVERSE),
785                8 => self.sgr.flags.insert(StyleFlags::HIDDEN),
786                9 => self.sgr.flags.insert(StyleFlags::STRIKETHROUGH),
787                21 | 22 => self.sgr.flags.remove(StyleFlags::BOLD | StyleFlags::DIM),
788                23 => self.sgr.flags.remove(StyleFlags::ITALIC),
789                24 => self.sgr.flags.remove(StyleFlags::UNDERLINE),
790                25 => self.sgr.flags.remove(StyleFlags::BLINK),
791                27 => self.sgr.flags.remove(StyleFlags::REVERSE),
792                28 => self.sgr.flags.remove(StyleFlags::HIDDEN),
793                29 => self.sgr.flags.remove(StyleFlags::STRIKETHROUGH),
794                // Basic foreground colors (30-37)
795                30..=37 => {
796                    self.sgr.fg = Self::basic_color(code - 30);
797                }
798                // Default foreground
799                39 => {
800                    self.sgr.fg = PackedRgba::WHITE;
801                }
802                // Basic background colors (40-47)
803                40..=47 => {
804                    self.sgr.bg = Self::basic_color(code - 40);
805                }
806                // Default background
807                49 => {
808                    self.sgr.bg = PackedRgba::TRANSPARENT;
809                }
810                // Bright foreground colors (90-97)
811                90..=97 => {
812                    self.sgr.fg = Self::bright_color(code - 90);
813                }
814                // Bright background colors (100-107)
815                100..=107 => {
816                    self.sgr.bg = Self::bright_color(code - 100);
817                }
818                // Extended colors (38/48)
819                38 => {
820                    if let Some(color) = self.parse_extended_color(&mut i) {
821                        self.sgr.fg = color;
822                    }
823                }
824                48 => {
825                    if let Some(color) = self.parse_extended_color(&mut i) {
826                        self.sgr.bg = color;
827                    }
828                }
829                _ => {} // Unknown SGR code
830            }
831            i += 1;
832        }
833    }
834
835    fn parse_extended_color(&self, i: &mut usize) -> Option<PackedRgba> {
836        let mode = self.csi_params.get(*i + 1)?;
837        match *mode {
838            5 => {
839                // 256-color mode: 38;5;n
840                let idx = self.csi_params.get(*i + 2)?;
841                *i += 2;
842                Some(Self::color_256(*idx as u8))
843            }
844            2 => {
845                // RGB mode: 38;2;r;g;b
846                let r = *self.csi_params.get(*i + 2)? as u8;
847                let g = *self.csi_params.get(*i + 3)? as u8;
848                let b = *self.csi_params.get(*i + 4)? as u8;
849                *i += 4;
850                Some(PackedRgba::rgb(r, g, b))
851            }
852            _ => None,
853        }
854    }
855
856    fn basic_color(idx: u32) -> PackedRgba {
857        match idx {
858            0 => PackedRgba::rgb(0, 0, 0),       // Black
859            1 => PackedRgba::rgb(128, 0, 0),     // Red
860            2 => PackedRgba::rgb(0, 128, 0),     // Green
861            3 => PackedRgba::rgb(128, 128, 0),   // Yellow
862            4 => PackedRgba::rgb(0, 0, 128),     // Blue
863            5 => PackedRgba::rgb(128, 0, 128),   // Magenta
864            6 => PackedRgba::rgb(0, 128, 128),   // Cyan
865            7 => PackedRgba::rgb(192, 192, 192), // White
866            _ => PackedRgba::WHITE,
867        }
868    }
869
870    fn bright_color(idx: u32) -> PackedRgba {
871        match idx {
872            0 => PackedRgba::rgb(128, 128, 128), // Bright Black
873            1 => PackedRgba::rgb(255, 0, 0),     // Bright Red
874            2 => PackedRgba::rgb(0, 255, 0),     // Bright Green
875            3 => PackedRgba::rgb(255, 255, 0),   // Bright Yellow
876            4 => PackedRgba::rgb(0, 0, 255),     // Bright Blue
877            5 => PackedRgba::rgb(255, 0, 255),   // Bright Magenta
878            6 => PackedRgba::rgb(0, 255, 255),   // Bright Cyan
879            7 => PackedRgba::rgb(255, 255, 255), // Bright White
880            _ => PackedRgba::WHITE,
881        }
882    }
883
884    fn color_256(idx: u8) -> PackedRgba {
885        match idx {
886            0..=7 => Self::basic_color(idx as u32),
887            8..=15 => Self::bright_color((idx - 8) as u32),
888            16..=231 => {
889                // 6x6x6 color cube
890                let idx = idx - 16;
891                let r = (idx / 36) % 6;
892                let g = (idx / 6) % 6;
893                let b = idx % 6;
894                let to_channel = |v| if v == 0 { 0 } else { 55 + v * 40 };
895                PackedRgba::rgb(to_channel(r), to_channel(g), to_channel(b))
896            }
897            232..=255 => {
898                // Grayscale ramp
899                let gray = 8 + (idx - 232) * 10;
900                PackedRgba::rgb(gray, gray, gray)
901            }
902        }
903    }
904
905    fn csi_decset(&mut self) {
906        for &code in &self.csi_params {
907            match code {
908                25 => self.modes.cursor_visible = true, // DECTCEM - cursor visible
909                1049 => self.modes.alt_screen = true,   // Alt screen buffer
910                2026 => self.modes.sync_output_level += 1, // Synchronized output begin
911                _ => {}
912            }
913        }
914    }
915
916    fn csi_decrst(&mut self) {
917        for &code in &self.csi_params {
918            match code {
919                25 => self.modes.cursor_visible = false, // DECTCEM - cursor hidden
920                1049 => self.modes.alt_screen = false,   // Alt screen buffer off
921                2026 => {
922                    // Synchronized output end
923                    self.modes.sync_output_level = self.modes.sync_output_level.saturating_sub(1);
924                }
925                _ => {}
926            }
927        }
928    }
929
930    fn execute_osc(&mut self) {
931        // Parse OSC: code ; data
932        // Clone buffer to avoid borrow issues when calling handle_osc8
933        let data = String::from_utf8_lossy(&self.osc_buffer).to_string();
934        let mut parts = data.splitn(2, ';');
935        let code: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
936
937        // OSC 8 - hyperlink (other OSC codes ignored)
938        if code == 8
939            && let Some(rest) = parts.next()
940        {
941            let rest = rest.to_string();
942            self.handle_osc8(&rest);
943        }
944    }
945
946    fn handle_osc8(&mut self, data: &str) {
947        // Format: OSC 8 ; params ; uri ST
948        // We support: OSC 8 ; ; uri ST (start link) and OSC 8 ; ; ST (end link)
949        let mut parts = data.splitn(2, ';');
950        let _params = parts.next().unwrap_or("");
951        let uri = parts.next().unwrap_or("");
952
953        if uri.is_empty() {
954            // End hyperlink
955            self.current_link_id = 0;
956        } else {
957            // Start hyperlink
958            self.links.push(uri.to_string());
959            self.current_link_id = (self.links.len() - 1) as u32;
960        }
961    }
962
963    /// Compare two grids and return a diff description for debugging.
964    #[must_use]
965    pub fn diff_grid(&self, expected: &[ModelCell]) -> Option<String> {
966        if self.cells.len() != expected.len() {
967            return Some(format!(
968                "Grid size mismatch: got {} cells, expected {}",
969                self.cells.len(),
970                expected.len()
971            ));
972        }
973
974        let mut diffs = Vec::new();
975        for (i, (actual, exp)) in self.cells.iter().zip(expected.iter()).enumerate() {
976            if actual != exp {
977                let x = i % self.width;
978                let y = i / self.width;
979                diffs.push(format!(
980                    "  ({}, {}): got {:?}, expected {:?}",
981                    x, y, actual.text, exp.text
982                ));
983            }
984        }
985
986        if diffs.is_empty() {
987            None
988        } else {
989            Some(format!("Grid differences:\n{}", diffs.join("\n")))
990        }
991    }
992
993    /// Dump the escape sequences in a human-readable format (for debugging test failures).
994    pub fn dump_sequences(bytes: &[u8]) -> String {
995        let mut output = String::new();
996        let mut i = 0;
997        while i < bytes.len() {
998            if bytes[i] == 0x1B {
999                if i + 1 < bytes.len() {
1000                    match bytes[i + 1] {
1001                        b'[' => {
1002                            // CSI sequence
1003                            output.push_str("\\e[");
1004                            i += 2;
1005                            while i < bytes.len() && !(0x40..=0x7E).contains(&bytes[i]) {
1006                                output.push(bytes[i] as char);
1007                                i += 1;
1008                            }
1009                            if i < bytes.len() {
1010                                output.push(bytes[i] as char);
1011                                i += 1;
1012                            }
1013                        }
1014                        b']' => {
1015                            // OSC sequence
1016                            output.push_str("\\e]");
1017                            i += 2;
1018                            while i < bytes.len() && bytes[i] != 0x07 {
1019                                if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\'
1020                                {
1021                                    output.push_str("\\e\\\\");
1022                                    i += 2;
1023                                    break;
1024                                }
1025                                output.push(bytes[i] as char);
1026                                i += 1;
1027                            }
1028                            if i < bytes.len() && bytes[i] == 0x07 {
1029                                output.push_str("\\a");
1030                                i += 1;
1031                            }
1032                        }
1033                        _ => {
1034                            output.push_str(&format!("\\e{}", bytes[i + 1] as char));
1035                            i += 2;
1036                        }
1037                    }
1038                } else {
1039                    output.push_str("\\e");
1040                    i += 1;
1041                }
1042            } else if bytes[i] < 0x20 {
1043                output.push_str(&format!("\\x{:02x}", bytes[i]));
1044                i += 1;
1045            } else {
1046                output.push(bytes[i] as char);
1047                i += 1;
1048            }
1049        }
1050        output
1051    }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057    use crate::ansi;
1058
1059    #[test]
1060    fn new_creates_empty_grid() {
1061        let model = TerminalModel::new(80, 24);
1062        assert_eq!(model.width(), 80);
1063        assert_eq!(model.height(), 24);
1064        assert_eq!(model.cursor(), (0, 0));
1065        assert_eq!(model.cells().len(), 80 * 24);
1066    }
1067
1068    #[test]
1069    fn printable_text_writes_to_grid() {
1070        let mut model = TerminalModel::new(10, 5);
1071        model.process(b"Hello");
1072        assert_eq!(model.cursor(), (5, 0));
1073        assert_eq!(model.row_text(0), Some("Hello".to_string()));
1074    }
1075
1076    #[test]
1077    fn cup_moves_cursor() {
1078        let mut model = TerminalModel::new(80, 24);
1079        model.process(b"\x1b[5;10H"); // Row 5, Col 10 (1-indexed)
1080        assert_eq!(model.cursor(), (9, 4)); // 0-indexed
1081    }
1082
1083    #[test]
1084    fn cup_with_defaults() {
1085        let mut model = TerminalModel::new(80, 24);
1086        model.process(b"\x1b[H"); // Should default to 1;1
1087        assert_eq!(model.cursor(), (0, 0));
1088    }
1089
1090    #[test]
1091    fn relative_cursor_moves() {
1092        let mut model = TerminalModel::new(80, 24);
1093        model.process(b"\x1b[10;10H"); // Move to (9, 9)
1094        model.process(b"\x1b[2A"); // Up 2
1095        assert_eq!(model.cursor(), (9, 7));
1096        model.process(b"\x1b[3B"); // Down 3
1097        assert_eq!(model.cursor(), (9, 10));
1098        model.process(b"\x1b[5C"); // Forward 5
1099        assert_eq!(model.cursor(), (14, 10));
1100        model.process(b"\x1b[3D"); // Back 3
1101        assert_eq!(model.cursor(), (11, 10));
1102    }
1103
1104    #[test]
1105    fn sgr_sets_style_flags() {
1106        let mut model = TerminalModel::new(20, 5);
1107        model.process(b"\x1b[1mBold\x1b[0m");
1108        assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1109        assert!(!model.cell(4, 0).unwrap().attrs.has_flag(StyleFlags::BOLD)); // After reset
1110    }
1111
1112    #[test]
1113    fn sgr_sets_colors() {
1114        let mut model = TerminalModel::new(20, 5);
1115        model.process(b"\x1b[31mRed\x1b[0m");
1116        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(128, 0, 0));
1117    }
1118
1119    #[test]
1120    fn sgr_256_colors() {
1121        let mut model = TerminalModel::new(20, 5);
1122        model.process(b"\x1b[38;5;196mX"); // Bright red in 256 palette
1123        let cell = model.cell(0, 0).unwrap();
1124        // 196 = 16 + 180 = 16 + 5*36 + 0*6 + 0 = red=5, g=0, b=0
1125        // r = 55 + 5*40 = 255, g = 0, b = 0
1126        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
1127    }
1128
1129    #[test]
1130    fn sgr_rgb_colors() {
1131        let mut model = TerminalModel::new(20, 5);
1132        model.process(b"\x1b[38;2;100;150;200mX");
1133        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(100, 150, 200));
1134    }
1135
1136    #[test]
1137    fn erase_line() {
1138        let mut model = TerminalModel::new(10, 5);
1139        model.process(b"ABCDEFGHIJ");
1140        // After 10 chars in 10-col terminal, cursor wraps to (0, 1)
1141        // Move back to row 1, column 5 explicitly
1142        model.process(b"\x1b[1;5H"); // Row 1, Col 5 (1-indexed) = (4, 0)
1143        model.process(b"\x1b[K"); // Erase to end of line
1144        assert_eq!(model.row_text(0), Some("ABCD".to_string()));
1145    }
1146
1147    #[test]
1148    fn erase_display() {
1149        let mut model = TerminalModel::new(10, 5);
1150        model.process(b"Line1\n");
1151        model.process(b"Line2\n");
1152        model.process(b"\x1b[2J"); // Erase entire screen
1153        for y in 0..5 {
1154            assert_eq!(model.row_text(y), Some(String::new()));
1155        }
1156    }
1157
1158    #[test]
1159    fn osc8_hyperlinks() {
1160        let mut model = TerminalModel::new(20, 5);
1161        model.process(b"\x1b]8;;https://example.com\x07Link\x1b]8;;\x07");
1162
1163        let cell = model.cell(0, 0).unwrap();
1164        assert!(cell.link_id > 0);
1165        assert_eq!(model.link_url(cell.link_id), Some("https://example.com"));
1166
1167        // After link ends, link_id should be 0
1168        let cell_after = model.cell(4, 0).unwrap();
1169        assert_eq!(cell_after.link_id, 0);
1170    }
1171
1172    #[test]
1173    fn dangling_link_detection() {
1174        let mut model = TerminalModel::new(20, 5);
1175        model.process(b"\x1b]8;;https://example.com\x07Link");
1176        assert!(model.has_dangling_link());
1177
1178        model.process(b"\x1b]8;;\x07");
1179        assert!(!model.has_dangling_link());
1180    }
1181
1182    #[test]
1183    fn sync_output_tracking() {
1184        let mut model = TerminalModel::new(20, 5);
1185        assert!(model.sync_output_balanced());
1186
1187        model.process(b"\x1b[?2026h"); // Begin sync
1188        assert!(!model.sync_output_balanced());
1189        assert_eq!(model.modes().sync_output_level, 1);
1190
1191        model.process(b"\x1b[?2026l"); // End sync
1192        assert!(model.sync_output_balanced());
1193    }
1194
1195    #[test]
1196    fn utf8_multibyte_stream_is_decoded() {
1197        let mut model = TerminalModel::new(10, 1);
1198        let text = "a\u{00E9}\u{4E2D}\u{1F600}";
1199        model.process(text.as_bytes());
1200
1201        assert_eq!(model.row_text(0).as_deref(), Some(text));
1202        assert_eq!(model.cursor(), (6, 0));
1203    }
1204
1205    #[test]
1206    fn utf8_sequence_can_span_process_calls() {
1207        let mut model = TerminalModel::new(10, 1);
1208        let text = "\u{00E9}";
1209        let bytes = text.as_bytes();
1210
1211        model.process(&bytes[..1]);
1212        assert_eq!(model.row_text(0).as_deref(), Some(""));
1213
1214        model.process(&bytes[1..]);
1215        assert_eq!(model.row_text(0).as_deref(), Some(text));
1216    }
1217
1218    #[test]
1219    fn line_wrap() {
1220        let mut model = TerminalModel::new(5, 3);
1221        model.process(b"ABCDEFGH");
1222        assert_eq!(model.row_text(0), Some("ABCDE".to_string()));
1223        assert_eq!(model.row_text(1), Some("FGH".to_string()));
1224        assert_eq!(model.cursor(), (3, 1));
1225    }
1226
1227    #[test]
1228    fn cr_lf_handling() {
1229        let mut model = TerminalModel::new(20, 5);
1230        model.process(b"Hello\r\n");
1231        assert_eq!(model.cursor(), (0, 1));
1232        model.process(b"World");
1233        assert_eq!(model.row_text(0), Some("Hello".to_string()));
1234        assert_eq!(model.row_text(1), Some("World".to_string()));
1235    }
1236
1237    #[test]
1238    fn cursor_visibility() {
1239        let mut model = TerminalModel::new(20, 5);
1240        assert!(model.modes().cursor_visible);
1241
1242        model.process(b"\x1b[?25l"); // Hide cursor
1243        assert!(!model.modes().cursor_visible);
1244
1245        model.process(b"\x1b[?25h"); // Show cursor
1246        assert!(model.modes().cursor_visible);
1247    }
1248
1249    #[test]
1250    fn alt_screen_toggle_is_tracked() {
1251        let mut model = TerminalModel::new(20, 5);
1252        assert!(!model.modes().alt_screen);
1253
1254        model.process(b"\x1b[?1049h");
1255        assert!(model.modes().alt_screen);
1256
1257        model.process(b"\x1b[?1049l");
1258        assert!(!model.modes().alt_screen);
1259    }
1260
1261    #[test]
1262    fn dump_sequences_readable() {
1263        let bytes = b"\x1b[1;1H\x1b[1mHello\x1b[0m";
1264        let dump = TerminalModel::dump_sequences(bytes);
1265        assert!(dump.contains("\\e[1;1H"));
1266        assert!(dump.contains("\\e[1m"));
1267        assert!(dump.contains("Hello"));
1268        assert!(dump.contains("\\e[0m"));
1269    }
1270
1271    #[test]
1272    fn reset_clears_state() {
1273        let mut model = TerminalModel::new(20, 5);
1274        model.process(b"\x1b[10;10HTest\x1b[1m");
1275        model.reset();
1276
1277        assert_eq!(model.cursor(), (0, 0));
1278        assert!(model.sgr_state().flags.is_empty());
1279        for y in 0..5 {
1280            assert_eq!(model.row_text(y), Some(String::new()));
1281        }
1282    }
1283
1284    #[test]
1285    fn erase_scrollback_mode_clears_screen() {
1286        let mut model = TerminalModel::new(10, 3);
1287        model.process(b"Line1\nLine2\nLine3");
1288        model.process(b"\x1b[3J"); // ED scrollback mode
1289
1290        for y in 0..3 {
1291            assert_eq!(model.row_text(y), Some(String::new()));
1292        }
1293    }
1294
1295    #[test]
1296    fn scroll_region_sequences_are_ignored_but_safe() {
1297        let mut model = TerminalModel::new(12, 3);
1298        model.process(b"ABCD");
1299        let cursor_before = model.cursor();
1300
1301        let mut buf = Vec::new();
1302        ansi::set_scroll_region(&mut buf, 1, 2).expect("scroll region sequence");
1303        model.process(&buf);
1304        model.process(ansi::RESET_SCROLL_REGION);
1305
1306        assert_eq!(model.cursor(), cursor_before);
1307        model.process(b"EF");
1308        assert_eq!(model.row_text(0).as_deref(), Some("ABCDEF"));
1309    }
1310
1311    #[test]
1312    fn scroll_region_invalid_params_do_not_corrupt_state() {
1313        let mut model = TerminalModel::new(8, 2);
1314        model.process(b"Hi");
1315        let cursor_before = model.cursor();
1316
1317        model.process(b"\x1b[5;2r"); // bottom < top
1318        model.process(b"\x1b[0;0r"); // zero params
1319        model.process(b"\x1b[999;999r"); // out of bounds
1320
1321        assert_eq!(model.cursor(), cursor_before);
1322        model.process(b"!");
1323        assert_eq!(model.row_text(0).as_deref(), Some("Hi!"));
1324    }
1325
1326    // --- ModelCell ---
1327
1328    #[test]
1329    fn model_cell_default_is_space() {
1330        let cell = ModelCell::default();
1331        assert_eq!(cell.text, " ");
1332        assert_eq!(cell.fg, PackedRgba::WHITE);
1333        assert_eq!(cell.bg, PackedRgba::TRANSPARENT);
1334        assert_eq!(cell.attrs, CellAttrs::NONE);
1335        assert_eq!(cell.link_id, 0);
1336    }
1337
1338    #[test]
1339    fn model_cell_with_char() {
1340        let cell = ModelCell::with_char('X');
1341        assert_eq!(cell.text, "X");
1342        assert_eq!(cell.fg, PackedRgba::WHITE);
1343        assert_eq!(cell.link_id, 0);
1344    }
1345
1346    #[test]
1347    fn model_cell_eq() {
1348        let a = ModelCell::default();
1349        let b = ModelCell::default();
1350        assert_eq!(a, b);
1351        let c = ModelCell::with_char('X');
1352        assert_ne!(a, c);
1353    }
1354
1355    #[test]
1356    fn model_cell_clone() {
1357        let a = ModelCell::with_char('Z');
1358        let b = a.clone();
1359        assert_eq!(b.text, "Z");
1360    }
1361
1362    // --- SgrState ---
1363
1364    #[test]
1365    fn sgr_state_default_fields() {
1366        let s = SgrState::default();
1367        assert_eq!(s.fg, PackedRgba::WHITE);
1368        assert_eq!(s.bg, PackedRgba::TRANSPARENT);
1369        assert!(s.flags.is_empty());
1370    }
1371
1372    #[test]
1373    fn sgr_state_reset() {
1374        let mut s = SgrState {
1375            fg: PackedRgba::rgb(255, 0, 0),
1376            bg: PackedRgba::rgb(0, 0, 255),
1377            flags: StyleFlags::BOLD | StyleFlags::ITALIC,
1378        };
1379        s.reset();
1380        assert_eq!(s.fg, PackedRgba::WHITE);
1381        assert_eq!(s.bg, PackedRgba::TRANSPARENT);
1382        assert!(s.flags.is_empty());
1383    }
1384
1385    // --- ModeFlags ---
1386
1387    #[test]
1388    fn mode_flags_new_defaults() {
1389        let m = ModeFlags::new();
1390        assert!(m.cursor_visible);
1391        assert!(!m.alt_screen);
1392        assert_eq!(m.sync_output_level, 0);
1393    }
1394
1395    #[test]
1396    fn mode_flags_default_vs_new() {
1397        // Default trait gives false for bools, 0 for u32.
1398        let d = ModeFlags::default();
1399        assert!(!d.cursor_visible);
1400        // new() gives cursor_visible=true.
1401        let n = ModeFlags::new();
1402        assert!(n.cursor_visible);
1403    }
1404
1405    // --- Construction edge cases ---
1406
1407    #[test]
1408    fn new_zero_dimensions_clamped() {
1409        let model = TerminalModel::new(0, 0);
1410        assert_eq!(model.width(), 1);
1411        assert_eq!(model.height(), 1);
1412        assert_eq!(model.cells().len(), 1);
1413    }
1414
1415    #[test]
1416    fn new_1x1() {
1417        let model = TerminalModel::new(1, 1);
1418        assert_eq!(model.width(), 1);
1419        assert_eq!(model.height(), 1);
1420        assert_eq!(model.cursor(), (0, 0));
1421    }
1422
1423    // --- Cell access ---
1424
1425    #[test]
1426    fn cell_out_of_bounds_returns_none() {
1427        let model = TerminalModel::new(5, 3);
1428        assert!(model.cell(5, 0).is_none());
1429        assert!(model.cell(0, 3).is_none());
1430        assert!(model.cell(100, 100).is_none());
1431    }
1432
1433    #[test]
1434    fn cell_in_bounds_returns_some() {
1435        let model = TerminalModel::new(5, 3);
1436        assert!(model.cell(0, 0).is_some());
1437        assert!(model.cell(4, 2).is_some());
1438    }
1439
1440    #[test]
1441    fn current_cell_at_cursor() {
1442        let mut model = TerminalModel::new(10, 5);
1443        model.process(b"AB");
1444        // Cursor at (2,0), current_cell should be the cell under it.
1445        let cc = model.current_cell().unwrap();
1446        assert_eq!(cc.text, " "); // Cursor is past "AB", on empty cell.
1447    }
1448
1449    #[test]
1450    fn row_out_of_bounds_returns_none() {
1451        let model = TerminalModel::new(5, 3);
1452        assert!(model.row(3).is_none());
1453        assert!(model.row(100).is_none());
1454    }
1455
1456    #[test]
1457    fn row_text_trims_trailing_spaces() {
1458        let mut model = TerminalModel::new(10, 1);
1459        model.process(b"Hi");
1460        assert_eq!(model.row_text(0), Some("Hi".to_string()));
1461    }
1462
1463    #[test]
1464    fn row_text_preserves_trailing_non_padding_whitespace() {
1465        let mut model = TerminalModel::new(10, 1);
1466        let text = "Hi\u{00A0}";
1467        model.process(text.as_bytes());
1468        assert_eq!(model.row_text(0).as_deref(), Some(text));
1469    }
1470
1471    #[test]
1472    fn link_url_invalid_id_returns_none() {
1473        let model = TerminalModel::new(5, 1);
1474        assert!(model.link_url(999).is_none());
1475    }
1476
1477    #[test]
1478    fn link_url_zero_is_empty() {
1479        let model = TerminalModel::new(5, 1);
1480        assert_eq!(model.link_url(0), Some(""));
1481    }
1482
1483    #[test]
1484    fn has_dangling_link_initially_false() {
1485        let model = TerminalModel::new(5, 1);
1486        assert!(!model.has_dangling_link());
1487    }
1488
1489    // --- CHA (cursor horizontal absolute) ---
1490
1491    #[test]
1492    fn cha_moves_to_column() {
1493        let mut model = TerminalModel::new(80, 24);
1494        model.process(b"\x1b[1;1H"); // (0,0)
1495        model.process(b"\x1b[20G"); // CHA col 20
1496        assert_eq!(model.cursor(), (19, 0));
1497    }
1498
1499    #[test]
1500    fn cha_clamps_to_width() {
1501        let mut model = TerminalModel::new(10, 1);
1502        model.process(b"\x1b[999G");
1503        assert_eq!(model.cursor().0, 9);
1504    }
1505
1506    // --- VPA (vertical position absolute) ---
1507
1508    #[test]
1509    fn vpa_moves_to_row() {
1510        let mut model = TerminalModel::new(80, 24);
1511        model.process(b"\x1b[10d"); // VPA row 10
1512        assert_eq!(model.cursor(), (0, 9));
1513    }
1514
1515    #[test]
1516    fn vpa_clamps_to_height() {
1517        let mut model = TerminalModel::new(10, 5);
1518        model.process(b"\x1b[999d");
1519        assert_eq!(model.cursor().1, 4);
1520    }
1521
1522    // --- Backspace ---
1523
1524    #[test]
1525    fn backspace_moves_cursor_back() {
1526        let mut model = TerminalModel::new(10, 1);
1527        model.process(b"ABC");
1528        assert_eq!(model.cursor(), (3, 0));
1529        model.process(b"\x08"); // BS
1530        assert_eq!(model.cursor(), (2, 0));
1531    }
1532
1533    #[test]
1534    fn backspace_at_column_zero_no_move() {
1535        let mut model = TerminalModel::new(10, 1);
1536        model.process(b"\x08");
1537        assert_eq!(model.cursor(), (0, 0));
1538    }
1539
1540    // --- Tab ---
1541
1542    #[test]
1543    fn tab_moves_to_next_tab_stop() {
1544        let mut model = TerminalModel::new(80, 1);
1545        model.process(b"\t");
1546        assert_eq!(model.cursor(), (8, 0));
1547        model.process(b"A\t");
1548        assert_eq!(model.cursor(), (16, 0));
1549    }
1550
1551    #[test]
1552    fn tab_clamps_at_right_edge() {
1553        let mut model = TerminalModel::new(10, 1);
1554        model.process(b"\t"); // -> 8
1555        model.process(b"\t"); // -> would be 16, but clamped to 9
1556        assert_eq!(model.cursor(), (9, 0));
1557    }
1558
1559    // --- Escape sequences ---
1560
1561    #[test]
1562    fn esc_7_8_do_not_panic() {
1563        let mut model = TerminalModel::new(10, 1);
1564        model.process(b"\x1b7"); // DECSC
1565        model.process(b"\x1b8"); // DECRC
1566        assert_eq!(model.cursor(), (0, 0));
1567    }
1568
1569    #[test]
1570    fn esc_equals_greater_ignored() {
1571        let mut model = TerminalModel::new(10, 1);
1572        model.process(b"\x1b="); // App keypad
1573        model.process(b"\x1b>"); // Normal keypad
1574        assert_eq!(model.cursor(), (0, 0));
1575    }
1576
1577    #[test]
1578    fn esc_esc_double_escape_handled() {
1579        let mut model = TerminalModel::new(10, 1);
1580        model.process(b"\x1b\x1b"); // Double ESC — stays in escape state
1581        // 'A' is consumed as unknown escape sequence, returning to ground.
1582        model.process(b"AB");
1583        // Only 'B' reaches ground as printable.
1584        assert_eq!(model.row_text(0).as_deref(), Some("B"));
1585    }
1586
1587    #[test]
1588    fn unknown_escape_returns_to_ground() {
1589        let mut model = TerminalModel::new(10, 1);
1590        model.process(b"\x1bQ"); // Unknown
1591        model.process(b"Hi");
1592        assert_eq!(model.row_text(0).as_deref(), Some("Hi"));
1593    }
1594
1595    // --- EL modes ---
1596
1597    #[test]
1598    fn el_mode_1_erases_from_start_to_cursor() {
1599        let mut model = TerminalModel::new(10, 1);
1600        model.process(b"ABCDEFGHIJ");
1601        model.process(b"\x1b[1;5H"); // (4, 0)
1602        model.process(b"\x1b[1K"); // Erase from start to cursor
1603        // Columns 0..=4 erased.
1604        let row = model.row_text(0).unwrap();
1605        assert!(row.starts_with("     ") || row.trim_start().starts_with("FGHIJ"));
1606    }
1607
1608    #[test]
1609    fn el_mode_2_erases_entire_line() {
1610        let mut model = TerminalModel::new(10, 1);
1611        model.process(b"ABCDEFGHIJ");
1612        model.process(b"\x1b[1;5H");
1613        model.process(b"\x1b[2K"); // Erase entire line
1614        assert_eq!(model.row_text(0), Some(String::new()));
1615    }
1616
1617    // --- ED modes ---
1618
1619    #[test]
1620    fn ed_mode_0_erases_from_cursor_to_end() {
1621        let mut model = TerminalModel::new(10, 3);
1622        model.process(b"Line1\nLine2\nLine3");
1623        model.process(b"\x1b[2;1H"); // Row 2, Col 1 (line index 1)
1624        model.process(b"\x1b[0J"); // Erase from cursor to end
1625        assert_eq!(model.row_text(0), Some("Line1".to_string()));
1626        assert_eq!(model.row_text(1), Some(String::new()));
1627        assert_eq!(model.row_text(2), Some(String::new()));
1628    }
1629
1630    #[test]
1631    fn ed_mode_1_erases_from_start_to_cursor() {
1632        let mut model = TerminalModel::new(10, 3);
1633        model.process(b"Line1\nLine2\nLine3");
1634        model.process(b"\x1b[2;3H"); // Row 2, Col 3 (0-indexed: y=1, x=2)
1635        model.process(b"\x1b[1J"); // Erase from start to cursor
1636        assert_eq!(model.row_text(0), Some(String::new()));
1637        // Row 1 erased up to and including cursor position (x=2).
1638        let row1 = model.row_text(1).unwrap();
1639        assert!(row1.starts_with("   ") || row1.len() <= 10);
1640    }
1641
1642    // --- SGR attribute flags ---
1643
1644    #[test]
1645    fn sgr_italic() {
1646        let mut model = TerminalModel::new(10, 1);
1647        model.process(b"\x1b[3mI\x1b[0m");
1648        assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::ITALIC));
1649    }
1650
1651    #[test]
1652    fn sgr_underline() {
1653        let mut model = TerminalModel::new(10, 1);
1654        model.process(b"\x1b[4mU\x1b[0m");
1655        assert!(
1656            model
1657                .cell(0, 0)
1658                .unwrap()
1659                .attrs
1660                .has_flag(StyleFlags::UNDERLINE)
1661        );
1662    }
1663
1664    #[test]
1665    fn sgr_dim() {
1666        let mut model = TerminalModel::new(10, 1);
1667        model.process(b"\x1b[2mD\x1b[0m");
1668        assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::DIM));
1669    }
1670
1671    #[test]
1672    fn sgr_strikethrough() {
1673        let mut model = TerminalModel::new(10, 1);
1674        model.process(b"\x1b[9mS\x1b[0m");
1675        assert!(
1676            model
1677                .cell(0, 0)
1678                .unwrap()
1679                .attrs
1680                .has_flag(StyleFlags::STRIKETHROUGH)
1681        );
1682    }
1683
1684    #[test]
1685    fn sgr_reverse() {
1686        let mut model = TerminalModel::new(10, 1);
1687        model.process(b"\x1b[7mR\x1b[0m");
1688        assert!(
1689            model
1690                .cell(0, 0)
1691                .unwrap()
1692                .attrs
1693                .has_flag(StyleFlags::REVERSE)
1694        );
1695    }
1696
1697    #[test]
1698    fn sgr_remove_bold() {
1699        let mut model = TerminalModel::new(10, 1);
1700        model.process(b"\x1b[1mB\x1b[22mX");
1701        assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1702        assert!(!model.cell(1, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1703    }
1704
1705    #[test]
1706    fn sgr_remove_italic() {
1707        let mut model = TerminalModel::new(10, 1);
1708        model.process(b"\x1b[3mI\x1b[23mX");
1709        assert!(!model.cell(1, 0).unwrap().attrs.has_flag(StyleFlags::ITALIC));
1710    }
1711
1712    // --- SGR colors ---
1713
1714    #[test]
1715    fn sgr_basic_background() {
1716        let mut model = TerminalModel::new(10, 1);
1717        model.process(b"\x1b[42mG"); // Green bg
1718        assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::rgb(0, 128, 0));
1719    }
1720
1721    #[test]
1722    fn sgr_default_fg_39() {
1723        let mut model = TerminalModel::new(10, 1);
1724        model.process(b"\x1b[31m\x1b[39mX");
1725        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::WHITE);
1726    }
1727
1728    #[test]
1729    fn sgr_default_bg_49() {
1730        let mut model = TerminalModel::new(10, 1);
1731        model.process(b"\x1b[41m\x1b[49mX");
1732        assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::TRANSPARENT);
1733    }
1734
1735    #[test]
1736    fn sgr_bright_fg() {
1737        let mut model = TerminalModel::new(10, 1);
1738        model.process(b"\x1b[91mX"); // Bright red fg
1739        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(255, 0, 0));
1740    }
1741
1742    #[test]
1743    fn sgr_bright_bg() {
1744        let mut model = TerminalModel::new(10, 1);
1745        model.process(b"\x1b[104mX"); // Bright blue bg
1746        assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::rgb(0, 0, 255));
1747    }
1748
1749    #[test]
1750    fn sgr_256_grayscale() {
1751        let mut model = TerminalModel::new(10, 1);
1752        model.process(b"\x1b[38;5;232mX"); // Grayscale idx 232 → gray=8
1753        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(8, 8, 8));
1754    }
1755
1756    #[test]
1757    fn sgr_256_basic_range() {
1758        let mut model = TerminalModel::new(10, 1);
1759        model.process(b"\x1b[38;5;1mX"); // Index 1 = basic red
1760        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(128, 0, 0));
1761    }
1762
1763    #[test]
1764    fn sgr_256_bright_range() {
1765        let mut model = TerminalModel::new(10, 1);
1766        model.process(b"\x1b[38;5;9mX"); // Index 9 = bright red
1767        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(255, 0, 0));
1768    }
1769
1770    #[test]
1771    fn sgr_empty_params_resets() {
1772        let mut model = TerminalModel::new(10, 1);
1773        model.process(b"\x1b[1m\x1b[mX"); // SGR with no params = reset
1774        assert!(!model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1775    }
1776
1777    // --- Sync output ---
1778
1779    #[test]
1780    fn sync_output_extra_end_saturates() {
1781        let mut model = TerminalModel::new(10, 1);
1782        model.process(b"\x1b[?2026l"); // End without begin
1783        assert_eq!(model.modes().sync_output_level, 0);
1784        assert!(model.sync_output_balanced());
1785    }
1786
1787    #[test]
1788    fn sync_output_nested() {
1789        let mut model = TerminalModel::new(10, 1);
1790        model.process(b"\x1b[?2026h");
1791        model.process(b"\x1b[?2026h");
1792        assert_eq!(model.modes().sync_output_level, 2);
1793        model.process(b"\x1b[?2026l");
1794        assert_eq!(model.modes().sync_output_level, 1);
1795        assert!(!model.sync_output_balanced());
1796    }
1797
1798    // --- diff_grid ---
1799
1800    #[test]
1801    fn diff_grid_identical_returns_none() {
1802        let model = TerminalModel::new(3, 2);
1803        let expected = vec![ModelCell::default(); 6];
1804        assert!(model.diff_grid(&expected).is_none());
1805    }
1806
1807    #[test]
1808    fn diff_grid_different_returns_some() {
1809        let mut model = TerminalModel::new(3, 1);
1810        model.process(b"ABC");
1811        let expected = vec![ModelCell::default(); 3];
1812        let diff = model.diff_grid(&expected);
1813        assert!(diff.is_some());
1814        let diff_str = diff.unwrap();
1815        assert!(diff_str.contains("Grid differences"));
1816    }
1817
1818    #[test]
1819    fn diff_grid_size_mismatch() {
1820        let model = TerminalModel::new(3, 2);
1821        let expected = vec![ModelCell::default(); 5]; // Wrong size
1822        let diff = model.diff_grid(&expected);
1823        assert!(diff.is_some());
1824        assert!(diff.unwrap().contains("Grid size mismatch"));
1825    }
1826
1827    // --- dump_sequences ---
1828
1829    #[test]
1830    fn dump_sequences_osc() {
1831        let bytes = b"\x1b]8;;https://example.com\x07text\x1b]8;;\x07";
1832        let dump = TerminalModel::dump_sequences(bytes);
1833        assert!(dump.contains("\\e]8;;https://example.com\\a"));
1834    }
1835
1836    #[test]
1837    fn dump_sequences_osc_st() {
1838        let bytes = b"\x1b]0;title\x1b\\";
1839        let dump = TerminalModel::dump_sequences(bytes);
1840        assert!(dump.contains("\\e]"));
1841        assert!(dump.contains("\\e\\\\"));
1842    }
1843
1844    #[test]
1845    fn dump_sequences_c0_controls() {
1846        let bytes = b"\x08\x09\x0A";
1847        let dump = TerminalModel::dump_sequences(bytes);
1848        assert!(dump.contains("\\x08"));
1849        assert!(dump.contains("\\x09"));
1850        assert!(dump.contains("\\x0a"));
1851    }
1852
1853    #[test]
1854    fn dump_sequences_trailing_esc() {
1855        let bytes = b"text\x1b";
1856        let dump = TerminalModel::dump_sequences(bytes);
1857        assert!(dump.contains("text"));
1858        assert!(dump.contains("\\e"));
1859    }
1860
1861    #[test]
1862    fn dump_sequences_unknown_escape() {
1863        let bytes = b"\x1bQ";
1864        let dump = TerminalModel::dump_sequences(bytes);
1865        assert!(dump.contains("\\eQ"));
1866    }
1867
1868    // --- Erase uses current bg color ---
1869
1870    #[test]
1871    fn erase_line_uses_current_bg() {
1872        let mut model = TerminalModel::new(5, 1);
1873        model.process(b"Hello");
1874        model.process(b"\x1b[1;1H"); // Move to (0,0)
1875        model.process(b"\x1b[41m"); // Red bg
1876        model.process(b"\x1b[K"); // Erase to end
1877        let cell = model.cell(0, 0).unwrap();
1878        assert_eq!(cell.text, " ");
1879        assert_eq!(cell.bg, PackedRgba::rgb(128, 0, 0));
1880    }
1881
1882    // --- Multiple hyperlinks ---
1883
1884    #[test]
1885    fn multiple_hyperlinks_get_different_ids() {
1886        let mut model = TerminalModel::new(30, 1);
1887        model.process(b"\x1b]8;;https://a.com\x07A\x1b]8;;\x07");
1888        model.process(b"\x1b]8;;https://b.com\x07B\x1b]8;;\x07");
1889        let id_a = model.cell(0, 0).unwrap().link_id;
1890        let id_b = model.cell(1, 0).unwrap().link_id;
1891        assert_ne!(id_a, id_b);
1892        assert_eq!(model.link_url(id_a), Some("https://a.com"));
1893        assert_eq!(model.link_url(id_b), Some("https://b.com"));
1894    }
1895
1896    // --- OSC with ST terminator ---
1897
1898    #[test]
1899    fn osc8_with_st_terminator() {
1900        let mut model = TerminalModel::new(20, 1);
1901        model.process(b"\x1b]8;;https://st.com\x1b\\Link\x1b]8;;\x1b\\");
1902        let cell = model.cell(0, 0).unwrap();
1903        assert!(cell.link_id > 0);
1904        assert_eq!(model.link_url(cell.link_id), Some("https://st.com"));
1905        assert!(!model.has_dangling_link());
1906    }
1907
1908    // --- TerminalModel Debug ---
1909
1910    #[test]
1911    fn terminal_model_debug() {
1912        let model = TerminalModel::new(5, 3);
1913        let dbg = format!("{model:?}");
1914        assert!(dbg.contains("TerminalModel"));
1915    }
1916
1917    // --- Wide character ---
1918
1919    #[test]
1920    fn wide_char_occupies_two_cells() {
1921        let mut model = TerminalModel::new(10, 1);
1922        // CJK character takes 2 columns
1923        model.process("中".as_bytes());
1924        assert_eq!(model.cell(0, 0).unwrap().text, "中");
1925        // Next cell should be cleared (placeholder)
1926        assert_eq!(model.cell(1, 0).unwrap().text, "");
1927        assert_eq!(model.cursor(), (2, 0));
1928    }
1929
1930    // --- Cursor CUP with f final byte ---
1931
1932    #[test]
1933    fn cup_with_f_final_byte() {
1934        let mut model = TerminalModel::new(80, 24);
1935        model.process(b"\x1b[3;7f"); // Same as H
1936        assert_eq!(model.cursor(), (6, 2));
1937    }
1938
1939    // --- CSI unknown final byte ---
1940
1941    #[test]
1942    fn csi_unknown_final_byte_ignored() {
1943        let mut model = TerminalModel::new(10, 1);
1944        model.process(b"A");
1945        model.process(b"\x1b[99X"); // Unknown CSI
1946        model.process(b"B");
1947        assert_eq!(model.row_text(0).as_deref(), Some("AB"));
1948    }
1949
1950    // --- CSI save/restore cursor (s/u) ---
1951
1952    #[test]
1953    fn csi_save_restore_cursor_no_panic() {
1954        let mut model = TerminalModel::new(10, 5);
1955        model.process(b"\x1b[5;5H");
1956        model.process(b"\x1b[s"); // Save
1957        model.process(b"\x1b[1;1H");
1958        model.process(b"\x1b[u"); // Restore (not fully implemented, but shouldn't panic)
1959        // Just verify no crash.
1960        let (x, y) = model.cursor();
1961        assert!(x < model.width());
1962        assert!(y < model.height());
1963    }
1964
1965    // --- BEL in ground state ---
1966
1967    #[test]
1968    fn bel_in_ground_is_ignored() {
1969        let mut model = TerminalModel::new(10, 1);
1970        model.process(b"\x07Hi");
1971        assert_eq!(model.row_text(0).as_deref(), Some("Hi"));
1972    }
1973
1974    // --- CUP clamps out-of-range values ---
1975
1976    #[test]
1977    fn cup_clamps_large_row_col() {
1978        let mut model = TerminalModel::new(10, 5);
1979        model.process(b"\x1b[999;999H");
1980        assert_eq!(model.cursor(), (9, 4));
1981    }
1982
1983    // --- Relative moves at boundaries ---
1984
1985    #[test]
1986    fn cuu_at_top_stays() {
1987        let mut model = TerminalModel::new(10, 5);
1988        model.process(b"\x1b[1;1H");
1989        model.process(b"\x1b[50A"); // Up 50 from top
1990        assert_eq!(model.cursor(), (0, 0));
1991    }
1992
1993    #[test]
1994    fn cud_at_bottom_stays() {
1995        let mut model = TerminalModel::new(10, 5);
1996        model.process(b"\x1b[5;1H");
1997        model.process(b"\x1b[50B"); // Down 50 from bottom
1998        assert_eq!(model.cursor(), (0, 4));
1999    }
2000
2001    #[test]
2002    fn cuf_at_right_stays() {
2003        let mut model = TerminalModel::new(10, 1);
2004        model.process(b"\x1b[1;10H");
2005        model.process(b"\x1b[50C"); // Forward 50 from right edge
2006        assert_eq!(model.cursor().0, 9);
2007    }
2008
2009    #[test]
2010    fn cub_at_left_stays() {
2011        let mut model = TerminalModel::new(10, 1);
2012        model.process(b"\x1b[50D"); // Back 50 from column 0
2013        assert_eq!(model.cursor().0, 0);
2014    }
2015
2016    // --- CSI with intermediate bytes ---
2017
2018    #[test]
2019    fn csi_with_intermediate_no_crash() {
2020        let mut model = TerminalModel::new(10, 1);
2021        // Space (0x20) in CSI entry falls to default → Ground.
2022        // Then 'q' is printed as regular char.
2023        model.process(b"\x1b[ q");
2024        model.process(b"OK");
2025        // 'q' + "OK" are all printed.
2026        assert_eq!(model.row_text(0).as_deref(), Some("qOK"));
2027    }
2028
2029    // --- Reset preserves dimensions ---
2030
2031    #[test]
2032    fn reset_preserves_dimensions() {
2033        let mut model = TerminalModel::new(40, 20);
2034        model.process(b"SomeText");
2035        model.reset();
2036        assert_eq!(model.width(), 40);
2037        assert_eq!(model.height(), 20);
2038        assert_eq!(model.cursor(), (0, 0));
2039    }
2040
2041    // --- LF at bottom does not crash ---
2042
2043    #[test]
2044    fn lf_at_bottom_row_stays() {
2045        let mut model = TerminalModel::new(10, 3);
2046        model.process(b"\x1b[3;1H"); // Row 3 (bottom)
2047        model.process(b"\n"); // LF at bottom
2048        assert_eq!(model.cursor().1, 2); // Stays at bottom
2049    }
2050}
2051
2052/// Property tests for terminal model correctness.
2053#[cfg(test)]
2054mod proptests {
2055    use super::*;
2056    use proptest::prelude::*;
2057
2058    /// Generate a valid CSI sequence for cursor positioning.
2059    fn cup_sequence(row: u8, col: u8) -> Vec<u8> {
2060        format!("\x1b[{};{}H", row.max(1), col.max(1)).into_bytes()
2061    }
2062
2063    /// Generate a valid SGR sequence.
2064    fn sgr_sequence(codes: &[u8]) -> Vec<u8> {
2065        let codes_str: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
2066        format!("\x1b[{}m", codes_str.join(";")).into_bytes()
2067    }
2068
2069    proptest! {
2070        /// Any sequence of printable ASCII doesn't crash.
2071        #[test]
2072        fn printable_ascii_no_crash(s in "[A-Za-z0-9 ]{0,100}") {
2073            let mut model = TerminalModel::new(80, 24);
2074            model.process(s.as_bytes());
2075            // Model should be in a valid state
2076            let (x, y) = model.cursor();
2077            prop_assert!(x < model.width());
2078            prop_assert!(y < model.height());
2079        }
2080
2081        /// CUP sequences always leave cursor in bounds.
2082        #[test]
2083        fn cup_cursor_in_bounds(row in 0u8..100, col in 0u8..200) {
2084            let mut model = TerminalModel::new(80, 24);
2085            let seq = cup_sequence(row, col);
2086            model.process(&seq);
2087
2088            let (x, y) = model.cursor();
2089            prop_assert!(x < model.width(), "cursor_x {} >= width {}", x, model.width());
2090            prop_assert!(y < model.height(), "cursor_y {} >= height {}", y, model.height());
2091        }
2092
2093        /// Relative cursor moves never go out of bounds.
2094        #[test]
2095        fn relative_moves_in_bounds(
2096            start_row in 1u8..24,
2097            start_col in 1u8..80,
2098            up in 0u8..50,
2099            down in 0u8..50,
2100            left in 0u8..100,
2101            right in 0u8..100,
2102        ) {
2103            let mut model = TerminalModel::new(80, 24);
2104
2105            // Position cursor
2106            model.process(&cup_sequence(start_row, start_col));
2107
2108            // Apply relative moves
2109            model.process(format!("\x1b[{}A", up).as_bytes());
2110            model.process(format!("\x1b[{}B", down).as_bytes());
2111            model.process(format!("\x1b[{}D", left).as_bytes());
2112            model.process(format!("\x1b[{}C", right).as_bytes());
2113
2114            let (x, y) = model.cursor();
2115            prop_assert!(x < model.width());
2116            prop_assert!(y < model.height());
2117        }
2118
2119        /// SGR reset always clears all flags.
2120        #[test]
2121        fn sgr_reset_clears_flags(attrs in proptest::collection::vec(1u8..9, 0..5)) {
2122            let mut model = TerminalModel::new(80, 24);
2123
2124            // Set some attributes
2125            if !attrs.is_empty() {
2126                model.process(&sgr_sequence(&attrs));
2127            }
2128
2129            // Reset
2130            model.process(b"\x1b[0m");
2131
2132            prop_assert!(model.sgr_state().flags.is_empty());
2133        }
2134
2135        /// Hyperlinks always balance (no dangling after close).
2136        #[test]
2137        fn hyperlinks_balance(text in "[a-z]{1,20}") {
2138            let mut model = TerminalModel::new(80, 24);
2139
2140            // Start link
2141            model.process(b"\x1b]8;;https://example.com\x07");
2142            prop_assert!(model.has_dangling_link());
2143
2144            // Write some text
2145            model.process(text.as_bytes());
2146
2147            // End link
2148            model.process(b"\x1b]8;;\x07");
2149            prop_assert!(!model.has_dangling_link());
2150        }
2151
2152        /// Sync output always balances with nested begin/end.
2153        #[test]
2154        fn sync_output_balances(nesting in 1usize..5) {
2155            let mut model = TerminalModel::new(80, 24);
2156
2157            // Begin sync N times
2158            for _ in 0..nesting {
2159                model.process(b"\x1b[?2026h");
2160            }
2161            prop_assert_eq!(model.modes().sync_output_level, nesting as u32);
2162
2163            // End sync N times
2164            for _ in 0..nesting {
2165                model.process(b"\x1b[?2026l");
2166            }
2167            prop_assert!(model.sync_output_balanced());
2168        }
2169
2170        /// Erase operations don't crash and leave cursor in bounds.
2171        #[test]
2172        fn erase_operations_safe(
2173            row in 1u8..24,
2174            col in 1u8..80,
2175            ed_mode in 0u8..4,
2176            el_mode in 0u8..3,
2177        ) {
2178            let mut model = TerminalModel::new(80, 24);
2179
2180            // Position cursor
2181            model.process(&cup_sequence(row, col));
2182
2183            // Erase display
2184            model.process(format!("\x1b[{}J", ed_mode).as_bytes());
2185
2186            // Position again and erase line
2187            model.process(&cup_sequence(row, col));
2188            model.process(format!("\x1b[{}K", el_mode).as_bytes());
2189
2190            let (x, y) = model.cursor();
2191            prop_assert!(x < model.width());
2192            prop_assert!(y < model.height());
2193        }
2194
2195        /// Random bytes never cause a panic (fuzz-like test).
2196        #[test]
2197        fn random_bytes_no_panic(bytes in proptest::collection::vec(any::<u8>(), 0..200)) {
2198            let mut model = TerminalModel::new(80, 24);
2199            model.process(&bytes);
2200
2201            // Just check it didn't panic and cursor is valid
2202            let (x, y) = model.cursor();
2203            prop_assert!(x < model.width());
2204            prop_assert!(y < model.height());
2205        }
2206    }
2207}