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().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                for cell in &mut self.cells {
725                    *cell = ModelCell::default();
726                }
727            }
728            _ => {}
729        }
730    }
731
732    fn csi_el(&mut self) {
733        let mode = self.csi_params.first().copied().unwrap_or(0);
734        match mode {
735            0 => {
736                // Erase from cursor to end of line
737                for x in self.cursor_x..self.width {
738                    self.erase_cell(x, self.cursor_y);
739                }
740            }
741            1 => {
742                // Erase from start of line to cursor
743                for x in 0..=self.cursor_x {
744                    self.erase_cell(x, self.cursor_y);
745                }
746            }
747            2 => {
748                // Erase entire line
749                for x in 0..self.width {
750                    self.erase_cell(x, self.cursor_y);
751                }
752            }
753            _ => {}
754        }
755    }
756
757    fn erase_cell(&mut self, x: usize, y: usize) {
758        // Copy background color before borrowing self mutably
759        let bg = self.sgr.bg;
760        if let Some(cell) = self.cell_mut(x, y) {
761            cell.text = " ".to_string();
762            // Erase uses current background color
763            cell.fg = PackedRgba::WHITE;
764            cell.bg = bg;
765            cell.attrs = CellAttrs::NONE;
766            cell.link_id = 0;
767        }
768    }
769
770    fn csi_sgr(&mut self) {
771        if self.csi_params.is_empty() {
772            self.sgr.reset();
773            return;
774        }
775
776        let mut i = 0;
777        while i < self.csi_params.len() {
778            let code = self.csi_params[i];
779            match code {
780                0 => self.sgr.reset(),
781                1 => self.sgr.flags.insert(StyleFlags::BOLD),
782                2 => self.sgr.flags.insert(StyleFlags::DIM),
783                3 => self.sgr.flags.insert(StyleFlags::ITALIC),
784                4 => self.sgr.flags.insert(StyleFlags::UNDERLINE),
785                5 => self.sgr.flags.insert(StyleFlags::BLINK),
786                7 => self.sgr.flags.insert(StyleFlags::REVERSE),
787                8 => self.sgr.flags.insert(StyleFlags::HIDDEN),
788                9 => self.sgr.flags.insert(StyleFlags::STRIKETHROUGH),
789                21 | 22 => self.sgr.flags.remove(StyleFlags::BOLD | StyleFlags::DIM),
790                23 => self.sgr.flags.remove(StyleFlags::ITALIC),
791                24 => self.sgr.flags.remove(StyleFlags::UNDERLINE),
792                25 => self.sgr.flags.remove(StyleFlags::BLINK),
793                27 => self.sgr.flags.remove(StyleFlags::REVERSE),
794                28 => self.sgr.flags.remove(StyleFlags::HIDDEN),
795                29 => self.sgr.flags.remove(StyleFlags::STRIKETHROUGH),
796                // Basic foreground colors (30-37)
797                30..=37 => {
798                    self.sgr.fg = Self::basic_color(code - 30);
799                }
800                // Default foreground
801                39 => {
802                    self.sgr.fg = PackedRgba::WHITE;
803                }
804                // Basic background colors (40-47)
805                40..=47 => {
806                    self.sgr.bg = Self::basic_color(code - 40);
807                }
808                // Default background
809                49 => {
810                    self.sgr.bg = PackedRgba::TRANSPARENT;
811                }
812                // Bright foreground colors (90-97)
813                90..=97 => {
814                    self.sgr.fg = Self::bright_color(code - 90);
815                }
816                // Bright background colors (100-107)
817                100..=107 => {
818                    self.sgr.bg = Self::bright_color(code - 100);
819                }
820                // Extended colors (38/48)
821                38 => {
822                    if let Some(color) = self.parse_extended_color(&mut i) {
823                        self.sgr.fg = color;
824                    }
825                }
826                48 => {
827                    if let Some(color) = self.parse_extended_color(&mut i) {
828                        self.sgr.bg = color;
829                    }
830                }
831                _ => {} // Unknown SGR code
832            }
833            i += 1;
834        }
835    }
836
837    fn parse_extended_color(&self, i: &mut usize) -> Option<PackedRgba> {
838        let mode = self.csi_params.get(*i + 1)?;
839        match *mode {
840            5 => {
841                // 256-color mode: 38;5;n
842                let idx = self.csi_params.get(*i + 2)?;
843                *i += 2;
844                Some(Self::color_256(*idx as u8))
845            }
846            2 => {
847                // RGB mode: 38;2;r;g;b
848                let r = *self.csi_params.get(*i + 2)? as u8;
849                let g = *self.csi_params.get(*i + 3)? as u8;
850                let b = *self.csi_params.get(*i + 4)? as u8;
851                *i += 4;
852                Some(PackedRgba::rgb(r, g, b))
853            }
854            _ => None,
855        }
856    }
857
858    fn basic_color(idx: u32) -> PackedRgba {
859        match idx {
860            0 => PackedRgba::rgb(0, 0, 0),       // Black
861            1 => PackedRgba::rgb(128, 0, 0),     // Red
862            2 => PackedRgba::rgb(0, 128, 0),     // Green
863            3 => PackedRgba::rgb(128, 128, 0),   // Yellow
864            4 => PackedRgba::rgb(0, 0, 128),     // Blue
865            5 => PackedRgba::rgb(128, 0, 128),   // Magenta
866            6 => PackedRgba::rgb(0, 128, 128),   // Cyan
867            7 => PackedRgba::rgb(192, 192, 192), // White
868            _ => PackedRgba::WHITE,
869        }
870    }
871
872    fn bright_color(idx: u32) -> PackedRgba {
873        match idx {
874            0 => PackedRgba::rgb(128, 128, 128), // Bright Black
875            1 => PackedRgba::rgb(255, 0, 0),     // Bright Red
876            2 => PackedRgba::rgb(0, 255, 0),     // Bright Green
877            3 => PackedRgba::rgb(255, 255, 0),   // Bright Yellow
878            4 => PackedRgba::rgb(0, 0, 255),     // Bright Blue
879            5 => PackedRgba::rgb(255, 0, 255),   // Bright Magenta
880            6 => PackedRgba::rgb(0, 255, 255),   // Bright Cyan
881            7 => PackedRgba::rgb(255, 255, 255), // Bright White
882            _ => PackedRgba::WHITE,
883        }
884    }
885
886    fn color_256(idx: u8) -> PackedRgba {
887        match idx {
888            0..=7 => Self::basic_color(idx as u32),
889            8..=15 => Self::bright_color((idx - 8) as u32),
890            16..=231 => {
891                // 6x6x6 color cube
892                let idx = idx - 16;
893                let r = (idx / 36) % 6;
894                let g = (idx / 6) % 6;
895                let b = idx % 6;
896                let to_channel = |v| if v == 0 { 0 } else { 55 + v * 40 };
897                PackedRgba::rgb(to_channel(r), to_channel(g), to_channel(b))
898            }
899            232..=255 => {
900                // Grayscale ramp
901                let gray = 8 + (idx - 232) * 10;
902                PackedRgba::rgb(gray, gray, gray)
903            }
904        }
905    }
906
907    fn csi_decset(&mut self) {
908        for &code in &self.csi_params {
909            match code {
910                25 => self.modes.cursor_visible = true, // DECTCEM - cursor visible
911                1049 => self.modes.alt_screen = true,   // Alt screen buffer
912                2026 => self.modes.sync_output_level += 1, // Synchronized output begin
913                _ => {}
914            }
915        }
916    }
917
918    fn csi_decrst(&mut self) {
919        for &code in &self.csi_params {
920            match code {
921                25 => self.modes.cursor_visible = false, // DECTCEM - cursor hidden
922                1049 => self.modes.alt_screen = false,   // Alt screen buffer off
923                2026 => {
924                    // Synchronized output end
925                    self.modes.sync_output_level = self.modes.sync_output_level.saturating_sub(1);
926                }
927                _ => {}
928            }
929        }
930    }
931
932    fn execute_osc(&mut self) {
933        // Parse OSC: code ; data
934        // Clone buffer to avoid borrow issues when calling handle_osc8
935        let data = String::from_utf8_lossy(&self.osc_buffer).to_string();
936        let mut parts = data.splitn(2, ';');
937        let code: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
938
939        // OSC 8 - hyperlink (other OSC codes ignored)
940        if code == 8
941            && let Some(rest) = parts.next()
942        {
943            let rest = rest.to_string();
944            self.handle_osc8(&rest);
945        }
946    }
947
948    fn handle_osc8(&mut self, data: &str) {
949        // Format: OSC 8 ; params ; uri ST
950        // We support: OSC 8 ; ; uri ST (start link) and OSC 8 ; ; ST (end link)
951        let mut parts = data.splitn(2, ';');
952        let _params = parts.next().unwrap_or("");
953        let uri = parts.next().unwrap_or("");
954
955        if uri.is_empty() {
956            // End hyperlink
957            self.current_link_id = 0;
958        } else {
959            // Start hyperlink
960            self.links.push(uri.to_string());
961            self.current_link_id = (self.links.len() - 1) as u32;
962        }
963    }
964
965    /// Compare two grids and return a diff description for debugging.
966    #[must_use]
967    pub fn diff_grid(&self, expected: &[ModelCell]) -> Option<String> {
968        if self.cells.len() != expected.len() {
969            return Some(format!(
970                "Grid size mismatch: got {} cells, expected {}",
971                self.cells.len(),
972                expected.len()
973            ));
974        }
975
976        let mut diffs = Vec::new();
977        for (i, (actual, exp)) in self.cells.iter().zip(expected.iter()).enumerate() {
978            if actual != exp {
979                let x = i % self.width;
980                let y = i / self.width;
981                diffs.push(format!(
982                    "  ({}, {}): got {:?}, expected {:?}",
983                    x, y, actual.text, exp.text
984                ));
985            }
986        }
987
988        if diffs.is_empty() {
989            None
990        } else {
991            Some(format!("Grid differences:\n{}", diffs.join("\n")))
992        }
993    }
994
995    /// Dump the escape sequences in a human-readable format (for debugging test failures).
996    pub fn dump_sequences(bytes: &[u8]) -> String {
997        let mut output = String::new();
998        let mut i = 0;
999        while i < bytes.len() {
1000            if bytes[i] == 0x1B {
1001                if i + 1 < bytes.len() {
1002                    match bytes[i + 1] {
1003                        b'[' => {
1004                            // CSI sequence
1005                            output.push_str("\\e[");
1006                            i += 2;
1007                            while i < bytes.len() && !(0x40..=0x7E).contains(&bytes[i]) {
1008                                output.push(bytes[i] as char);
1009                                i += 1;
1010                            }
1011                            if i < bytes.len() {
1012                                output.push(bytes[i] as char);
1013                                i += 1;
1014                            }
1015                        }
1016                        b']' => {
1017                            // OSC sequence
1018                            output.push_str("\\e]");
1019                            i += 2;
1020                            while i < bytes.len() && bytes[i] != 0x07 {
1021                                if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\'
1022                                {
1023                                    output.push_str("\\e\\\\");
1024                                    i += 2;
1025                                    break;
1026                                }
1027                                output.push(bytes[i] as char);
1028                                i += 1;
1029                            }
1030                            if i < bytes.len() && bytes[i] == 0x07 {
1031                                output.push_str("\\a");
1032                                i += 1;
1033                            }
1034                        }
1035                        _ => {
1036                            output.push_str(&format!("\\e{}", bytes[i + 1] as char));
1037                            i += 2;
1038                        }
1039                    }
1040                } else {
1041                    output.push_str("\\e");
1042                    i += 1;
1043                }
1044            } else if bytes[i] < 0x20 {
1045                output.push_str(&format!("\\x{:02x}", bytes[i]));
1046                i += 1;
1047            } else {
1048                output.push(bytes[i] as char);
1049                i += 1;
1050            }
1051        }
1052        output
1053    }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059    use crate::ansi;
1060
1061    #[test]
1062    fn new_creates_empty_grid() {
1063        let model = TerminalModel::new(80, 24);
1064        assert_eq!(model.width(), 80);
1065        assert_eq!(model.height(), 24);
1066        assert_eq!(model.cursor(), (0, 0));
1067        assert_eq!(model.cells().len(), 80 * 24);
1068    }
1069
1070    #[test]
1071    fn printable_text_writes_to_grid() {
1072        let mut model = TerminalModel::new(10, 5);
1073        model.process(b"Hello");
1074        assert_eq!(model.cursor(), (5, 0));
1075        assert_eq!(model.row_text(0), Some("Hello".to_string()));
1076    }
1077
1078    #[test]
1079    fn cup_moves_cursor() {
1080        let mut model = TerminalModel::new(80, 24);
1081        model.process(b"\x1b[5;10H"); // Row 5, Col 10 (1-indexed)
1082        assert_eq!(model.cursor(), (9, 4)); // 0-indexed
1083    }
1084
1085    #[test]
1086    fn cup_with_defaults() {
1087        let mut model = TerminalModel::new(80, 24);
1088        model.process(b"\x1b[H"); // Should default to 1;1
1089        assert_eq!(model.cursor(), (0, 0));
1090    }
1091
1092    #[test]
1093    fn relative_cursor_moves() {
1094        let mut model = TerminalModel::new(80, 24);
1095        model.process(b"\x1b[10;10H"); // Move to (9, 9)
1096        model.process(b"\x1b[2A"); // Up 2
1097        assert_eq!(model.cursor(), (9, 7));
1098        model.process(b"\x1b[3B"); // Down 3
1099        assert_eq!(model.cursor(), (9, 10));
1100        model.process(b"\x1b[5C"); // Forward 5
1101        assert_eq!(model.cursor(), (14, 10));
1102        model.process(b"\x1b[3D"); // Back 3
1103        assert_eq!(model.cursor(), (11, 10));
1104    }
1105
1106    #[test]
1107    fn sgr_sets_style_flags() {
1108        let mut model = TerminalModel::new(20, 5);
1109        model.process(b"\x1b[1mBold\x1b[0m");
1110        assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1111        assert!(!model.cell(4, 0).unwrap().attrs.has_flag(StyleFlags::BOLD)); // After reset
1112    }
1113
1114    #[test]
1115    fn sgr_sets_colors() {
1116        let mut model = TerminalModel::new(20, 5);
1117        model.process(b"\x1b[31mRed\x1b[0m");
1118        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(128, 0, 0));
1119    }
1120
1121    #[test]
1122    fn sgr_256_colors() {
1123        let mut model = TerminalModel::new(20, 5);
1124        model.process(b"\x1b[38;5;196mX"); // Bright red in 256 palette
1125        let cell = model.cell(0, 0).unwrap();
1126        // 196 = 16 + 180 = 16 + 5*36 + 0*6 + 0 = red=5, g=0, b=0
1127        // r = 55 + 5*40 = 255, g = 0, b = 0
1128        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
1129    }
1130
1131    #[test]
1132    fn sgr_rgb_colors() {
1133        let mut model = TerminalModel::new(20, 5);
1134        model.process(b"\x1b[38;2;100;150;200mX");
1135        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(100, 150, 200));
1136    }
1137
1138    #[test]
1139    fn erase_line() {
1140        let mut model = TerminalModel::new(10, 5);
1141        model.process(b"ABCDEFGHIJ");
1142        // After 10 chars in 10-col terminal, cursor wraps to (0, 1)
1143        // Move back to row 1, column 5 explicitly
1144        model.process(b"\x1b[1;5H"); // Row 1, Col 5 (1-indexed) = (4, 0)
1145        model.process(b"\x1b[K"); // Erase to end of line
1146        assert_eq!(model.row_text(0), Some("ABCD".to_string()));
1147    }
1148
1149    #[test]
1150    fn erase_display() {
1151        let mut model = TerminalModel::new(10, 5);
1152        model.process(b"Line1\n");
1153        model.process(b"Line2\n");
1154        model.process(b"\x1b[2J"); // Erase entire screen
1155        for y in 0..5 {
1156            assert_eq!(model.row_text(y), Some(String::new()));
1157        }
1158    }
1159
1160    #[test]
1161    fn osc8_hyperlinks() {
1162        let mut model = TerminalModel::new(20, 5);
1163        model.process(b"\x1b]8;;https://example.com\x07Link\x1b]8;;\x07");
1164
1165        let cell = model.cell(0, 0).unwrap();
1166        assert!(cell.link_id > 0);
1167        assert_eq!(model.link_url(cell.link_id), Some("https://example.com"));
1168
1169        // After link ends, link_id should be 0
1170        let cell_after = model.cell(4, 0).unwrap();
1171        assert_eq!(cell_after.link_id, 0);
1172    }
1173
1174    #[test]
1175    fn dangling_link_detection() {
1176        let mut model = TerminalModel::new(20, 5);
1177        model.process(b"\x1b]8;;https://example.com\x07Link");
1178        assert!(model.has_dangling_link());
1179
1180        model.process(b"\x1b]8;;\x07");
1181        assert!(!model.has_dangling_link());
1182    }
1183
1184    #[test]
1185    fn sync_output_tracking() {
1186        let mut model = TerminalModel::new(20, 5);
1187        assert!(model.sync_output_balanced());
1188
1189        model.process(b"\x1b[?2026h"); // Begin sync
1190        assert!(!model.sync_output_balanced());
1191        assert_eq!(model.modes().sync_output_level, 1);
1192
1193        model.process(b"\x1b[?2026l"); // End sync
1194        assert!(model.sync_output_balanced());
1195    }
1196
1197    #[test]
1198    fn utf8_multibyte_stream_is_decoded() {
1199        let mut model = TerminalModel::new(10, 1);
1200        let text = "a\u{00E9}\u{4E2D}\u{1F600}";
1201        model.process(text.as_bytes());
1202
1203        assert_eq!(model.row_text(0).as_deref(), Some(text));
1204        assert_eq!(model.cursor(), (6, 0));
1205    }
1206
1207    #[test]
1208    fn utf8_sequence_can_span_process_calls() {
1209        let mut model = TerminalModel::new(10, 1);
1210        let text = "\u{00E9}";
1211        let bytes = text.as_bytes();
1212
1213        model.process(&bytes[..1]);
1214        assert_eq!(model.row_text(0).as_deref(), Some(""));
1215
1216        model.process(&bytes[1..]);
1217        assert_eq!(model.row_text(0).as_deref(), Some(text));
1218    }
1219
1220    #[test]
1221    fn line_wrap() {
1222        let mut model = TerminalModel::new(5, 3);
1223        model.process(b"ABCDEFGH");
1224        assert_eq!(model.row_text(0), Some("ABCDE".to_string()));
1225        assert_eq!(model.row_text(1), Some("FGH".to_string()));
1226        assert_eq!(model.cursor(), (3, 1));
1227    }
1228
1229    #[test]
1230    fn cr_lf_handling() {
1231        let mut model = TerminalModel::new(20, 5);
1232        model.process(b"Hello\r\n");
1233        assert_eq!(model.cursor(), (0, 1));
1234        model.process(b"World");
1235        assert_eq!(model.row_text(0), Some("Hello".to_string()));
1236        assert_eq!(model.row_text(1), Some("World".to_string()));
1237    }
1238
1239    #[test]
1240    fn cursor_visibility() {
1241        let mut model = TerminalModel::new(20, 5);
1242        assert!(model.modes().cursor_visible);
1243
1244        model.process(b"\x1b[?25l"); // Hide cursor
1245        assert!(!model.modes().cursor_visible);
1246
1247        model.process(b"\x1b[?25h"); // Show cursor
1248        assert!(model.modes().cursor_visible);
1249    }
1250
1251    #[test]
1252    fn alt_screen_toggle_is_tracked() {
1253        let mut model = TerminalModel::new(20, 5);
1254        assert!(!model.modes().alt_screen);
1255
1256        model.process(b"\x1b[?1049h");
1257        assert!(model.modes().alt_screen);
1258
1259        model.process(b"\x1b[?1049l");
1260        assert!(!model.modes().alt_screen);
1261    }
1262
1263    #[test]
1264    fn dump_sequences_readable() {
1265        let bytes = b"\x1b[1;1H\x1b[1mHello\x1b[0m";
1266        let dump = TerminalModel::dump_sequences(bytes);
1267        assert!(dump.contains("\\e[1;1H"));
1268        assert!(dump.contains("\\e[1m"));
1269        assert!(dump.contains("Hello"));
1270        assert!(dump.contains("\\e[0m"));
1271    }
1272
1273    #[test]
1274    fn reset_clears_state() {
1275        let mut model = TerminalModel::new(20, 5);
1276        model.process(b"\x1b[10;10HTest\x1b[1m");
1277        model.reset();
1278
1279        assert_eq!(model.cursor(), (0, 0));
1280        assert!(model.sgr_state().flags.is_empty());
1281        for y in 0..5 {
1282            assert_eq!(model.row_text(y), Some(String::new()));
1283        }
1284    }
1285
1286    #[test]
1287    fn erase_scrollback_mode_clears_screen() {
1288        let mut model = TerminalModel::new(10, 3);
1289        model.process(b"Line1\nLine2\nLine3");
1290        model.process(b"\x1b[3J"); // ED scrollback mode
1291
1292        for y in 0..3 {
1293            assert_eq!(model.row_text(y), Some(String::new()));
1294        }
1295    }
1296
1297    #[test]
1298    fn scroll_region_sequences_are_ignored_but_safe() {
1299        let mut model = TerminalModel::new(12, 3);
1300        model.process(b"ABCD");
1301        let cursor_before = model.cursor();
1302
1303        let mut buf = Vec::new();
1304        ansi::set_scroll_region(&mut buf, 1, 2).expect("scroll region sequence");
1305        model.process(&buf);
1306        model.process(ansi::RESET_SCROLL_REGION);
1307
1308        assert_eq!(model.cursor(), cursor_before);
1309        model.process(b"EF");
1310        assert_eq!(model.row_text(0).as_deref(), Some("ABCDEF"));
1311    }
1312
1313    #[test]
1314    fn scroll_region_invalid_params_do_not_corrupt_state() {
1315        let mut model = TerminalModel::new(8, 2);
1316        model.process(b"Hi");
1317        let cursor_before = model.cursor();
1318
1319        model.process(b"\x1b[5;2r"); // bottom < top
1320        model.process(b"\x1b[0;0r"); // zero params
1321        model.process(b"\x1b[999;999r"); // out of bounds
1322
1323        assert_eq!(model.cursor(), cursor_before);
1324        model.process(b"!");
1325        assert_eq!(model.row_text(0).as_deref(), Some("Hi!"));
1326    }
1327
1328    // --- ModelCell ---
1329
1330    #[test]
1331    fn model_cell_default_is_space() {
1332        let cell = ModelCell::default();
1333        assert_eq!(cell.text, " ");
1334        assert_eq!(cell.fg, PackedRgba::WHITE);
1335        assert_eq!(cell.bg, PackedRgba::TRANSPARENT);
1336        assert_eq!(cell.attrs, CellAttrs::NONE);
1337        assert_eq!(cell.link_id, 0);
1338    }
1339
1340    #[test]
1341    fn model_cell_with_char() {
1342        let cell = ModelCell::with_char('X');
1343        assert_eq!(cell.text, "X");
1344        assert_eq!(cell.fg, PackedRgba::WHITE);
1345        assert_eq!(cell.link_id, 0);
1346    }
1347
1348    #[test]
1349    fn model_cell_eq() {
1350        let a = ModelCell::default();
1351        let b = ModelCell::default();
1352        assert_eq!(a, b);
1353        let c = ModelCell::with_char('X');
1354        assert_ne!(a, c);
1355    }
1356
1357    #[test]
1358    fn model_cell_clone() {
1359        let a = ModelCell::with_char('Z');
1360        let b = a.clone();
1361        assert_eq!(b.text, "Z");
1362    }
1363
1364    // --- SgrState ---
1365
1366    #[test]
1367    fn sgr_state_default_fields() {
1368        let s = SgrState::default();
1369        assert_eq!(s.fg, PackedRgba::WHITE);
1370        assert_eq!(s.bg, PackedRgba::TRANSPARENT);
1371        assert!(s.flags.is_empty());
1372    }
1373
1374    #[test]
1375    fn sgr_state_reset() {
1376        let mut s = SgrState {
1377            fg: PackedRgba::rgb(255, 0, 0),
1378            bg: PackedRgba::rgb(0, 0, 255),
1379            flags: StyleFlags::BOLD | StyleFlags::ITALIC,
1380        };
1381        s.reset();
1382        assert_eq!(s.fg, PackedRgba::WHITE);
1383        assert_eq!(s.bg, PackedRgba::TRANSPARENT);
1384        assert!(s.flags.is_empty());
1385    }
1386
1387    // --- ModeFlags ---
1388
1389    #[test]
1390    fn mode_flags_new_defaults() {
1391        let m = ModeFlags::new();
1392        assert!(m.cursor_visible);
1393        assert!(!m.alt_screen);
1394        assert_eq!(m.sync_output_level, 0);
1395    }
1396
1397    #[test]
1398    fn mode_flags_default_vs_new() {
1399        // Default trait gives false for bools, 0 for u32.
1400        let d = ModeFlags::default();
1401        assert!(!d.cursor_visible);
1402        // new() gives cursor_visible=true.
1403        let n = ModeFlags::new();
1404        assert!(n.cursor_visible);
1405    }
1406
1407    // --- Construction edge cases ---
1408
1409    #[test]
1410    fn new_zero_dimensions_clamped() {
1411        let model = TerminalModel::new(0, 0);
1412        assert_eq!(model.width(), 1);
1413        assert_eq!(model.height(), 1);
1414        assert_eq!(model.cells().len(), 1);
1415    }
1416
1417    #[test]
1418    fn new_1x1() {
1419        let model = TerminalModel::new(1, 1);
1420        assert_eq!(model.width(), 1);
1421        assert_eq!(model.height(), 1);
1422        assert_eq!(model.cursor(), (0, 0));
1423    }
1424
1425    // --- Cell access ---
1426
1427    #[test]
1428    fn cell_out_of_bounds_returns_none() {
1429        let model = TerminalModel::new(5, 3);
1430        assert!(model.cell(5, 0).is_none());
1431        assert!(model.cell(0, 3).is_none());
1432        assert!(model.cell(100, 100).is_none());
1433    }
1434
1435    #[test]
1436    fn cell_in_bounds_returns_some() {
1437        let model = TerminalModel::new(5, 3);
1438        assert!(model.cell(0, 0).is_some());
1439        assert!(model.cell(4, 2).is_some());
1440    }
1441
1442    #[test]
1443    fn current_cell_at_cursor() {
1444        let mut model = TerminalModel::new(10, 5);
1445        model.process(b"AB");
1446        // Cursor at (2,0), current_cell should be the cell under it.
1447        let cc = model.current_cell().unwrap();
1448        assert_eq!(cc.text, " "); // Cursor is past "AB", on empty cell.
1449    }
1450
1451    #[test]
1452    fn row_out_of_bounds_returns_none() {
1453        let model = TerminalModel::new(5, 3);
1454        assert!(model.row(3).is_none());
1455        assert!(model.row(100).is_none());
1456    }
1457
1458    #[test]
1459    fn row_text_trims_trailing_spaces() {
1460        let mut model = TerminalModel::new(10, 1);
1461        model.process(b"Hi");
1462        assert_eq!(model.row_text(0), Some("Hi".to_string()));
1463    }
1464
1465    #[test]
1466    fn link_url_invalid_id_returns_none() {
1467        let model = TerminalModel::new(5, 1);
1468        assert!(model.link_url(999).is_none());
1469    }
1470
1471    #[test]
1472    fn link_url_zero_is_empty() {
1473        let model = TerminalModel::new(5, 1);
1474        assert_eq!(model.link_url(0), Some(""));
1475    }
1476
1477    #[test]
1478    fn has_dangling_link_initially_false() {
1479        let model = TerminalModel::new(5, 1);
1480        assert!(!model.has_dangling_link());
1481    }
1482
1483    // --- CHA (cursor horizontal absolute) ---
1484
1485    #[test]
1486    fn cha_moves_to_column() {
1487        let mut model = TerminalModel::new(80, 24);
1488        model.process(b"\x1b[1;1H"); // (0,0)
1489        model.process(b"\x1b[20G"); // CHA col 20
1490        assert_eq!(model.cursor(), (19, 0));
1491    }
1492
1493    #[test]
1494    fn cha_clamps_to_width() {
1495        let mut model = TerminalModel::new(10, 1);
1496        model.process(b"\x1b[999G");
1497        assert_eq!(model.cursor().0, 9);
1498    }
1499
1500    // --- VPA (vertical position absolute) ---
1501
1502    #[test]
1503    fn vpa_moves_to_row() {
1504        let mut model = TerminalModel::new(80, 24);
1505        model.process(b"\x1b[10d"); // VPA row 10
1506        assert_eq!(model.cursor(), (0, 9));
1507    }
1508
1509    #[test]
1510    fn vpa_clamps_to_height() {
1511        let mut model = TerminalModel::new(10, 5);
1512        model.process(b"\x1b[999d");
1513        assert_eq!(model.cursor().1, 4);
1514    }
1515
1516    // --- Backspace ---
1517
1518    #[test]
1519    fn backspace_moves_cursor_back() {
1520        let mut model = TerminalModel::new(10, 1);
1521        model.process(b"ABC");
1522        assert_eq!(model.cursor(), (3, 0));
1523        model.process(b"\x08"); // BS
1524        assert_eq!(model.cursor(), (2, 0));
1525    }
1526
1527    #[test]
1528    fn backspace_at_column_zero_no_move() {
1529        let mut model = TerminalModel::new(10, 1);
1530        model.process(b"\x08");
1531        assert_eq!(model.cursor(), (0, 0));
1532    }
1533
1534    // --- Tab ---
1535
1536    #[test]
1537    fn tab_moves_to_next_tab_stop() {
1538        let mut model = TerminalModel::new(80, 1);
1539        model.process(b"\t");
1540        assert_eq!(model.cursor(), (8, 0));
1541        model.process(b"A\t");
1542        assert_eq!(model.cursor(), (16, 0));
1543    }
1544
1545    #[test]
1546    fn tab_clamps_at_right_edge() {
1547        let mut model = TerminalModel::new(10, 1);
1548        model.process(b"\t"); // -> 8
1549        model.process(b"\t"); // -> would be 16, but clamped to 9
1550        assert_eq!(model.cursor(), (9, 0));
1551    }
1552
1553    // --- Escape sequences ---
1554
1555    #[test]
1556    fn esc_7_8_do_not_panic() {
1557        let mut model = TerminalModel::new(10, 1);
1558        model.process(b"\x1b7"); // DECSC
1559        model.process(b"\x1b8"); // DECRC
1560        assert_eq!(model.cursor(), (0, 0));
1561    }
1562
1563    #[test]
1564    fn esc_equals_greater_ignored() {
1565        let mut model = TerminalModel::new(10, 1);
1566        model.process(b"\x1b="); // App keypad
1567        model.process(b"\x1b>"); // Normal keypad
1568        assert_eq!(model.cursor(), (0, 0));
1569    }
1570
1571    #[test]
1572    fn esc_esc_double_escape_handled() {
1573        let mut model = TerminalModel::new(10, 1);
1574        model.process(b"\x1b\x1b"); // Double ESC — stays in escape state
1575        // 'A' is consumed as unknown escape sequence, returning to ground.
1576        model.process(b"AB");
1577        // Only 'B' reaches ground as printable.
1578        assert_eq!(model.row_text(0).as_deref(), Some("B"));
1579    }
1580
1581    #[test]
1582    fn unknown_escape_returns_to_ground() {
1583        let mut model = TerminalModel::new(10, 1);
1584        model.process(b"\x1bQ"); // Unknown
1585        model.process(b"Hi");
1586        assert_eq!(model.row_text(0).as_deref(), Some("Hi"));
1587    }
1588
1589    // --- EL modes ---
1590
1591    #[test]
1592    fn el_mode_1_erases_from_start_to_cursor() {
1593        let mut model = TerminalModel::new(10, 1);
1594        model.process(b"ABCDEFGHIJ");
1595        model.process(b"\x1b[1;5H"); // (4, 0)
1596        model.process(b"\x1b[1K"); // Erase from start to cursor
1597        // Columns 0..=4 erased.
1598        let row = model.row_text(0).unwrap();
1599        assert!(row.starts_with("     ") || row.trim_start().starts_with("FGHIJ"));
1600    }
1601
1602    #[test]
1603    fn el_mode_2_erases_entire_line() {
1604        let mut model = TerminalModel::new(10, 1);
1605        model.process(b"ABCDEFGHIJ");
1606        model.process(b"\x1b[1;5H");
1607        model.process(b"\x1b[2K"); // Erase entire line
1608        assert_eq!(model.row_text(0), Some(String::new()));
1609    }
1610
1611    // --- ED modes ---
1612
1613    #[test]
1614    fn ed_mode_0_erases_from_cursor_to_end() {
1615        let mut model = TerminalModel::new(10, 3);
1616        model.process(b"Line1\nLine2\nLine3");
1617        model.process(b"\x1b[2;1H"); // Row 2, Col 1 (line index 1)
1618        model.process(b"\x1b[0J"); // Erase from cursor to end
1619        assert_eq!(model.row_text(0), Some("Line1".to_string()));
1620        assert_eq!(model.row_text(1), Some(String::new()));
1621        assert_eq!(model.row_text(2), Some(String::new()));
1622    }
1623
1624    #[test]
1625    fn ed_mode_1_erases_from_start_to_cursor() {
1626        let mut model = TerminalModel::new(10, 3);
1627        model.process(b"Line1\nLine2\nLine3");
1628        model.process(b"\x1b[2;3H"); // Row 2, Col 3 (0-indexed: y=1, x=2)
1629        model.process(b"\x1b[1J"); // Erase from start to cursor
1630        assert_eq!(model.row_text(0), Some(String::new()));
1631        // Row 1 erased up to and including cursor position (x=2).
1632        let row1 = model.row_text(1).unwrap();
1633        assert!(row1.starts_with("   ") || row1.len() <= 10);
1634    }
1635
1636    // --- SGR attribute flags ---
1637
1638    #[test]
1639    fn sgr_italic() {
1640        let mut model = TerminalModel::new(10, 1);
1641        model.process(b"\x1b[3mI\x1b[0m");
1642        assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::ITALIC));
1643    }
1644
1645    #[test]
1646    fn sgr_underline() {
1647        let mut model = TerminalModel::new(10, 1);
1648        model.process(b"\x1b[4mU\x1b[0m");
1649        assert!(
1650            model
1651                .cell(0, 0)
1652                .unwrap()
1653                .attrs
1654                .has_flag(StyleFlags::UNDERLINE)
1655        );
1656    }
1657
1658    #[test]
1659    fn sgr_dim() {
1660        let mut model = TerminalModel::new(10, 1);
1661        model.process(b"\x1b[2mD\x1b[0m");
1662        assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::DIM));
1663    }
1664
1665    #[test]
1666    fn sgr_strikethrough() {
1667        let mut model = TerminalModel::new(10, 1);
1668        model.process(b"\x1b[9mS\x1b[0m");
1669        assert!(
1670            model
1671                .cell(0, 0)
1672                .unwrap()
1673                .attrs
1674                .has_flag(StyleFlags::STRIKETHROUGH)
1675        );
1676    }
1677
1678    #[test]
1679    fn sgr_reverse() {
1680        let mut model = TerminalModel::new(10, 1);
1681        model.process(b"\x1b[7mR\x1b[0m");
1682        assert!(
1683            model
1684                .cell(0, 0)
1685                .unwrap()
1686                .attrs
1687                .has_flag(StyleFlags::REVERSE)
1688        );
1689    }
1690
1691    #[test]
1692    fn sgr_remove_bold() {
1693        let mut model = TerminalModel::new(10, 1);
1694        model.process(b"\x1b[1mB\x1b[22mX");
1695        assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1696        assert!(!model.cell(1, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1697    }
1698
1699    #[test]
1700    fn sgr_remove_italic() {
1701        let mut model = TerminalModel::new(10, 1);
1702        model.process(b"\x1b[3mI\x1b[23mX");
1703        assert!(!model.cell(1, 0).unwrap().attrs.has_flag(StyleFlags::ITALIC));
1704    }
1705
1706    // --- SGR colors ---
1707
1708    #[test]
1709    fn sgr_basic_background() {
1710        let mut model = TerminalModel::new(10, 1);
1711        model.process(b"\x1b[42mG"); // Green bg
1712        assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::rgb(0, 128, 0));
1713    }
1714
1715    #[test]
1716    fn sgr_default_fg_39() {
1717        let mut model = TerminalModel::new(10, 1);
1718        model.process(b"\x1b[31m\x1b[39mX");
1719        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::WHITE);
1720    }
1721
1722    #[test]
1723    fn sgr_default_bg_49() {
1724        let mut model = TerminalModel::new(10, 1);
1725        model.process(b"\x1b[41m\x1b[49mX");
1726        assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::TRANSPARENT);
1727    }
1728
1729    #[test]
1730    fn sgr_bright_fg() {
1731        let mut model = TerminalModel::new(10, 1);
1732        model.process(b"\x1b[91mX"); // Bright red fg
1733        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(255, 0, 0));
1734    }
1735
1736    #[test]
1737    fn sgr_bright_bg() {
1738        let mut model = TerminalModel::new(10, 1);
1739        model.process(b"\x1b[104mX"); // Bright blue bg
1740        assert_eq!(model.cell(0, 0).unwrap().bg, PackedRgba::rgb(0, 0, 255));
1741    }
1742
1743    #[test]
1744    fn sgr_256_grayscale() {
1745        let mut model = TerminalModel::new(10, 1);
1746        model.process(b"\x1b[38;5;232mX"); // Grayscale idx 232 → gray=8
1747        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(8, 8, 8));
1748    }
1749
1750    #[test]
1751    fn sgr_256_basic_range() {
1752        let mut model = TerminalModel::new(10, 1);
1753        model.process(b"\x1b[38;5;1mX"); // Index 1 = basic red
1754        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(128, 0, 0));
1755    }
1756
1757    #[test]
1758    fn sgr_256_bright_range() {
1759        let mut model = TerminalModel::new(10, 1);
1760        model.process(b"\x1b[38;5;9mX"); // Index 9 = bright red
1761        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(255, 0, 0));
1762    }
1763
1764    #[test]
1765    fn sgr_empty_params_resets() {
1766        let mut model = TerminalModel::new(10, 1);
1767        model.process(b"\x1b[1m\x1b[mX"); // SGR with no params = reset
1768        assert!(!model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1769    }
1770
1771    // --- Sync output ---
1772
1773    #[test]
1774    fn sync_output_extra_end_saturates() {
1775        let mut model = TerminalModel::new(10, 1);
1776        model.process(b"\x1b[?2026l"); // End without begin
1777        assert_eq!(model.modes().sync_output_level, 0);
1778        assert!(model.sync_output_balanced());
1779    }
1780
1781    #[test]
1782    fn sync_output_nested() {
1783        let mut model = TerminalModel::new(10, 1);
1784        model.process(b"\x1b[?2026h");
1785        model.process(b"\x1b[?2026h");
1786        assert_eq!(model.modes().sync_output_level, 2);
1787        model.process(b"\x1b[?2026l");
1788        assert_eq!(model.modes().sync_output_level, 1);
1789        assert!(!model.sync_output_balanced());
1790    }
1791
1792    // --- diff_grid ---
1793
1794    #[test]
1795    fn diff_grid_identical_returns_none() {
1796        let model = TerminalModel::new(3, 2);
1797        let expected = vec![ModelCell::default(); 6];
1798        assert!(model.diff_grid(&expected).is_none());
1799    }
1800
1801    #[test]
1802    fn diff_grid_different_returns_some() {
1803        let mut model = TerminalModel::new(3, 1);
1804        model.process(b"ABC");
1805        let expected = vec![ModelCell::default(); 3];
1806        let diff = model.diff_grid(&expected);
1807        assert!(diff.is_some());
1808        let diff_str = diff.unwrap();
1809        assert!(diff_str.contains("Grid differences"));
1810    }
1811
1812    #[test]
1813    fn diff_grid_size_mismatch() {
1814        let model = TerminalModel::new(3, 2);
1815        let expected = vec![ModelCell::default(); 5]; // Wrong size
1816        let diff = model.diff_grid(&expected);
1817        assert!(diff.is_some());
1818        assert!(diff.unwrap().contains("Grid size mismatch"));
1819    }
1820
1821    // --- dump_sequences ---
1822
1823    #[test]
1824    fn dump_sequences_osc() {
1825        let bytes = b"\x1b]8;;https://example.com\x07text\x1b]8;;\x07";
1826        let dump = TerminalModel::dump_sequences(bytes);
1827        assert!(dump.contains("\\e]8;;https://example.com\\a"));
1828    }
1829
1830    #[test]
1831    fn dump_sequences_osc_st() {
1832        let bytes = b"\x1b]0;title\x1b\\";
1833        let dump = TerminalModel::dump_sequences(bytes);
1834        assert!(dump.contains("\\e]"));
1835        assert!(dump.contains("\\e\\\\"));
1836    }
1837
1838    #[test]
1839    fn dump_sequences_c0_controls() {
1840        let bytes = b"\x08\x09\x0A";
1841        let dump = TerminalModel::dump_sequences(bytes);
1842        assert!(dump.contains("\\x08"));
1843        assert!(dump.contains("\\x09"));
1844        assert!(dump.contains("\\x0a"));
1845    }
1846
1847    #[test]
1848    fn dump_sequences_trailing_esc() {
1849        let bytes = b"text\x1b";
1850        let dump = TerminalModel::dump_sequences(bytes);
1851        assert!(dump.contains("text"));
1852        assert!(dump.contains("\\e"));
1853    }
1854
1855    #[test]
1856    fn dump_sequences_unknown_escape() {
1857        let bytes = b"\x1bQ";
1858        let dump = TerminalModel::dump_sequences(bytes);
1859        assert!(dump.contains("\\eQ"));
1860    }
1861
1862    // --- Erase uses current bg color ---
1863
1864    #[test]
1865    fn erase_line_uses_current_bg() {
1866        let mut model = TerminalModel::new(5, 1);
1867        model.process(b"Hello");
1868        model.process(b"\x1b[1;1H"); // Move to (0,0)
1869        model.process(b"\x1b[41m"); // Red bg
1870        model.process(b"\x1b[K"); // Erase to end
1871        let cell = model.cell(0, 0).unwrap();
1872        assert_eq!(cell.text, " ");
1873        assert_eq!(cell.bg, PackedRgba::rgb(128, 0, 0));
1874    }
1875
1876    // --- Multiple hyperlinks ---
1877
1878    #[test]
1879    fn multiple_hyperlinks_get_different_ids() {
1880        let mut model = TerminalModel::new(30, 1);
1881        model.process(b"\x1b]8;;https://a.com\x07A\x1b]8;;\x07");
1882        model.process(b"\x1b]8;;https://b.com\x07B\x1b]8;;\x07");
1883        let id_a = model.cell(0, 0).unwrap().link_id;
1884        let id_b = model.cell(1, 0).unwrap().link_id;
1885        assert_ne!(id_a, id_b);
1886        assert_eq!(model.link_url(id_a), Some("https://a.com"));
1887        assert_eq!(model.link_url(id_b), Some("https://b.com"));
1888    }
1889
1890    // --- OSC with ST terminator ---
1891
1892    #[test]
1893    fn osc8_with_st_terminator() {
1894        let mut model = TerminalModel::new(20, 1);
1895        model.process(b"\x1b]8;;https://st.com\x1b\\Link\x1b]8;;\x1b\\");
1896        let cell = model.cell(0, 0).unwrap();
1897        assert!(cell.link_id > 0);
1898        assert_eq!(model.link_url(cell.link_id), Some("https://st.com"));
1899        assert!(!model.has_dangling_link());
1900    }
1901
1902    // --- TerminalModel Debug ---
1903
1904    #[test]
1905    fn terminal_model_debug() {
1906        let model = TerminalModel::new(5, 3);
1907        let dbg = format!("{model:?}");
1908        assert!(dbg.contains("TerminalModel"));
1909    }
1910
1911    // --- Wide character ---
1912
1913    #[test]
1914    fn wide_char_occupies_two_cells() {
1915        let mut model = TerminalModel::new(10, 1);
1916        // CJK character takes 2 columns
1917        model.process("中".as_bytes());
1918        assert_eq!(model.cell(0, 0).unwrap().text, "中");
1919        // Next cell should be cleared (placeholder)
1920        assert_eq!(model.cell(1, 0).unwrap().text, "");
1921        assert_eq!(model.cursor(), (2, 0));
1922    }
1923
1924    // --- Cursor CUP with f final byte ---
1925
1926    #[test]
1927    fn cup_with_f_final_byte() {
1928        let mut model = TerminalModel::new(80, 24);
1929        model.process(b"\x1b[3;7f"); // Same as H
1930        assert_eq!(model.cursor(), (6, 2));
1931    }
1932
1933    // --- CSI unknown final byte ---
1934
1935    #[test]
1936    fn csi_unknown_final_byte_ignored() {
1937        let mut model = TerminalModel::new(10, 1);
1938        model.process(b"A");
1939        model.process(b"\x1b[99X"); // Unknown CSI
1940        model.process(b"B");
1941        assert_eq!(model.row_text(0).as_deref(), Some("AB"));
1942    }
1943
1944    // --- CSI save/restore cursor (s/u) ---
1945
1946    #[test]
1947    fn csi_save_restore_cursor_no_panic() {
1948        let mut model = TerminalModel::new(10, 5);
1949        model.process(b"\x1b[5;5H");
1950        model.process(b"\x1b[s"); // Save
1951        model.process(b"\x1b[1;1H");
1952        model.process(b"\x1b[u"); // Restore (not fully implemented, but shouldn't panic)
1953        // Just verify no crash.
1954        let (x, y) = model.cursor();
1955        assert!(x < model.width());
1956        assert!(y < model.height());
1957    }
1958
1959    // --- BEL in ground state ---
1960
1961    #[test]
1962    fn bel_in_ground_is_ignored() {
1963        let mut model = TerminalModel::new(10, 1);
1964        model.process(b"\x07Hi");
1965        assert_eq!(model.row_text(0).as_deref(), Some("Hi"));
1966    }
1967
1968    // --- CUP clamps out-of-range values ---
1969
1970    #[test]
1971    fn cup_clamps_large_row_col() {
1972        let mut model = TerminalModel::new(10, 5);
1973        model.process(b"\x1b[999;999H");
1974        assert_eq!(model.cursor(), (9, 4));
1975    }
1976
1977    // --- Relative moves at boundaries ---
1978
1979    #[test]
1980    fn cuu_at_top_stays() {
1981        let mut model = TerminalModel::new(10, 5);
1982        model.process(b"\x1b[1;1H");
1983        model.process(b"\x1b[50A"); // Up 50 from top
1984        assert_eq!(model.cursor(), (0, 0));
1985    }
1986
1987    #[test]
1988    fn cud_at_bottom_stays() {
1989        let mut model = TerminalModel::new(10, 5);
1990        model.process(b"\x1b[5;1H");
1991        model.process(b"\x1b[50B"); // Down 50 from bottom
1992        assert_eq!(model.cursor(), (0, 4));
1993    }
1994
1995    #[test]
1996    fn cuf_at_right_stays() {
1997        let mut model = TerminalModel::new(10, 1);
1998        model.process(b"\x1b[1;10H");
1999        model.process(b"\x1b[50C"); // Forward 50 from right edge
2000        assert_eq!(model.cursor().0, 9);
2001    }
2002
2003    #[test]
2004    fn cub_at_left_stays() {
2005        let mut model = TerminalModel::new(10, 1);
2006        model.process(b"\x1b[50D"); // Back 50 from column 0
2007        assert_eq!(model.cursor().0, 0);
2008    }
2009
2010    // --- CSI with intermediate bytes ---
2011
2012    #[test]
2013    fn csi_with_intermediate_no_crash() {
2014        let mut model = TerminalModel::new(10, 1);
2015        // Space (0x20) in CSI entry falls to default → Ground.
2016        // Then 'q' is printed as regular char.
2017        model.process(b"\x1b[ q");
2018        model.process(b"OK");
2019        // 'q' + "OK" are all printed.
2020        assert_eq!(model.row_text(0).as_deref(), Some("qOK"));
2021    }
2022
2023    // --- Reset preserves dimensions ---
2024
2025    #[test]
2026    fn reset_preserves_dimensions() {
2027        let mut model = TerminalModel::new(40, 20);
2028        model.process(b"SomeText");
2029        model.reset();
2030        assert_eq!(model.width(), 40);
2031        assert_eq!(model.height(), 20);
2032        assert_eq!(model.cursor(), (0, 0));
2033    }
2034
2035    // --- LF at bottom does not crash ---
2036
2037    #[test]
2038    fn lf_at_bottom_row_stays() {
2039        let mut model = TerminalModel::new(10, 3);
2040        model.process(b"\x1b[3;1H"); // Row 3 (bottom)
2041        model.process(b"\n"); // LF at bottom
2042        assert_eq!(model.cursor().1, 2); // Stays at bottom
2043    }
2044}
2045
2046/// Property tests for terminal model correctness.
2047#[cfg(test)]
2048mod proptests {
2049    use super::*;
2050    use proptest::prelude::*;
2051
2052    /// Generate a valid CSI sequence for cursor positioning.
2053    fn cup_sequence(row: u8, col: u8) -> Vec<u8> {
2054        format!("\x1b[{};{}H", row.max(1), col.max(1)).into_bytes()
2055    }
2056
2057    /// Generate a valid SGR sequence.
2058    fn sgr_sequence(codes: &[u8]) -> Vec<u8> {
2059        let codes_str: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
2060        format!("\x1b[{}m", codes_str.join(";")).into_bytes()
2061    }
2062
2063    proptest! {
2064        /// Any sequence of printable ASCII doesn't crash.
2065        #[test]
2066        fn printable_ascii_no_crash(s in "[A-Za-z0-9 ]{0,100}") {
2067            let mut model = TerminalModel::new(80, 24);
2068            model.process(s.as_bytes());
2069            // Model should be in a valid state
2070            let (x, y) = model.cursor();
2071            prop_assert!(x < model.width());
2072            prop_assert!(y < model.height());
2073        }
2074
2075        /// CUP sequences always leave cursor in bounds.
2076        #[test]
2077        fn cup_cursor_in_bounds(row in 0u8..100, col in 0u8..200) {
2078            let mut model = TerminalModel::new(80, 24);
2079            let seq = cup_sequence(row, col);
2080            model.process(&seq);
2081
2082            let (x, y) = model.cursor();
2083            prop_assert!(x < model.width(), "cursor_x {} >= width {}", x, model.width());
2084            prop_assert!(y < model.height(), "cursor_y {} >= height {}", y, model.height());
2085        }
2086
2087        /// Relative cursor moves never go out of bounds.
2088        #[test]
2089        fn relative_moves_in_bounds(
2090            start_row in 1u8..24,
2091            start_col in 1u8..80,
2092            up in 0u8..50,
2093            down in 0u8..50,
2094            left in 0u8..100,
2095            right in 0u8..100,
2096        ) {
2097            let mut model = TerminalModel::new(80, 24);
2098
2099            // Position cursor
2100            model.process(&cup_sequence(start_row, start_col));
2101
2102            // Apply relative moves
2103            model.process(format!("\x1b[{}A", up).as_bytes());
2104            model.process(format!("\x1b[{}B", down).as_bytes());
2105            model.process(format!("\x1b[{}D", left).as_bytes());
2106            model.process(format!("\x1b[{}C", right).as_bytes());
2107
2108            let (x, y) = model.cursor();
2109            prop_assert!(x < model.width());
2110            prop_assert!(y < model.height());
2111        }
2112
2113        /// SGR reset always clears all flags.
2114        #[test]
2115        fn sgr_reset_clears_flags(attrs in proptest::collection::vec(1u8..9, 0..5)) {
2116            let mut model = TerminalModel::new(80, 24);
2117
2118            // Set some attributes
2119            if !attrs.is_empty() {
2120                model.process(&sgr_sequence(&attrs));
2121            }
2122
2123            // Reset
2124            model.process(b"\x1b[0m");
2125
2126            prop_assert!(model.sgr_state().flags.is_empty());
2127        }
2128
2129        /// Hyperlinks always balance (no dangling after close).
2130        #[test]
2131        fn hyperlinks_balance(text in "[a-z]{1,20}") {
2132            let mut model = TerminalModel::new(80, 24);
2133
2134            // Start link
2135            model.process(b"\x1b]8;;https://example.com\x07");
2136            prop_assert!(model.has_dangling_link());
2137
2138            // Write some text
2139            model.process(text.as_bytes());
2140
2141            // End link
2142            model.process(b"\x1b]8;;\x07");
2143            prop_assert!(!model.has_dangling_link());
2144        }
2145
2146        /// Sync output always balances with nested begin/end.
2147        #[test]
2148        fn sync_output_balances(nesting in 1usize..5) {
2149            let mut model = TerminalModel::new(80, 24);
2150
2151            // Begin sync N times
2152            for _ in 0..nesting {
2153                model.process(b"\x1b[?2026h");
2154            }
2155            prop_assert_eq!(model.modes().sync_output_level, nesting as u32);
2156
2157            // End sync N times
2158            for _ in 0..nesting {
2159                model.process(b"\x1b[?2026l");
2160            }
2161            prop_assert!(model.sync_output_balanced());
2162        }
2163
2164        /// Erase operations don't crash and leave cursor in bounds.
2165        #[test]
2166        fn erase_operations_safe(
2167            row in 1u8..24,
2168            col in 1u8..80,
2169            ed_mode in 0u8..4,
2170            el_mode in 0u8..3,
2171        ) {
2172            let mut model = TerminalModel::new(80, 24);
2173
2174            // Position cursor
2175            model.process(&cup_sequence(row, col));
2176
2177            // Erase display
2178            model.process(format!("\x1b[{}J", ed_mode).as_bytes());
2179
2180            // Position again and erase line
2181            model.process(&cup_sequence(row, col));
2182            model.process(format!("\x1b[{}K", el_mode).as_bytes());
2183
2184            let (x, y) = model.cursor();
2185            prop_assert!(x < model.width());
2186            prop_assert!(y < model.height());
2187        }
2188
2189        /// Random bytes never cause a panic (fuzz-like test).
2190        #[test]
2191        fn random_bytes_no_panic(bytes in proptest::collection::vec(any::<u8>(), 0..200)) {
2192            let mut model = TerminalModel::new(80, 24);
2193            model.process(&bytes);
2194
2195            // Just check it didn't panic and cursor is valid
2196            let (x, y) = model.cursor();
2197            prop_assert!(x < model.width());
2198            prop_assert!(y < model.height());
2199        }
2200    }
2201}