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    pub fn width(&self) -> usize {
195        self.width
196    }
197
198    /// Get the terminal height.
199    pub fn height(&self) -> usize {
200        self.height
201    }
202
203    /// Get the cursor position as (x, y).
204    pub fn cursor(&self) -> (usize, usize) {
205        (self.cursor_x, self.cursor_y)
206    }
207
208    /// Get the current SGR state.
209    pub fn sgr_state(&self) -> &SgrState {
210        &self.sgr
211    }
212
213    /// Get the current mode flags.
214    pub fn modes(&self) -> &ModeFlags {
215        &self.modes
216    }
217
218    /// Get the cell at (x, y). Returns None if out of bounds.
219    pub fn cell(&self, x: usize, y: usize) -> Option<&ModelCell> {
220        if x < self.width && y < self.height {
221            Some(&self.cells[y * self.width + x])
222        } else {
223            None
224        }
225    }
226
227    /// Get a mutable reference to the cell at (x, y).
228    fn cell_mut(&mut self, x: usize, y: usize) -> Option<&mut ModelCell> {
229        if x < self.width && y < self.height {
230            Some(&mut self.cells[y * self.width + x])
231        } else {
232            None
233        }
234    }
235
236    /// Get the current cell under the cursor.
237    pub fn current_cell(&self) -> Option<&ModelCell> {
238        self.cell(self.cursor_x, self.cursor_y)
239    }
240
241    /// Get all cells as a slice.
242    pub fn cells(&self) -> &[ModelCell] {
243        &self.cells
244    }
245
246    /// Get a row of cells.
247    pub fn row(&self, y: usize) -> Option<&[ModelCell]> {
248        if y < self.height {
249            let start = y * self.width;
250            Some(&self.cells[start..start + self.width])
251        } else {
252            None
253        }
254    }
255
256    /// Extract the text content of a row as a string (trimmed of trailing spaces).
257    pub fn row_text(&self, y: usize) -> Option<String> {
258        self.row(y).map(|cells| {
259            let s: String = cells.iter().map(|c| c.text.as_str()).collect();
260            s.trim_end().to_string()
261        })
262    }
263
264    /// Get the URL for a link ID.
265    pub fn link_url(&self, link_id: u32) -> Option<&str> {
266        self.links.get(link_id as usize).map(|s| s.as_str())
267    }
268
269    /// Check if the terminal has a dangling hyperlink (active link after processing).
270    pub fn has_dangling_link(&self) -> bool {
271        self.current_link_id != 0
272    }
273
274    /// Check if synchronized output is properly balanced.
275    pub fn sync_output_balanced(&self) -> bool {
276        self.modes.sync_output_level == 0
277    }
278
279    /// Reset the terminal model to initial state.
280    pub fn reset(&mut self) {
281        self.cells.fill(ModelCell::default());
282        self.cursor_x = 0;
283        self.cursor_y = 0;
284        self.sgr = SgrState::default();
285        self.modes = ModeFlags::new();
286        self.current_link_id = 0;
287        self.parse_state = ParseState::Ground;
288        self.csi_params.clear();
289        self.csi_intermediate.clear();
290        self.osc_buffer.clear();
291        self.utf8_pending.clear();
292        self.utf8_expected = None;
293    }
294
295    /// Process a byte sequence, updating the terminal state.
296    pub fn process(&mut self, bytes: &[u8]) {
297        for &b in bytes {
298            self.process_byte(b);
299            self.bytes_processed += 1;
300        }
301    }
302
303    /// Process a single byte.
304    fn process_byte(&mut self, b: u8) {
305        match self.parse_state {
306            ParseState::Ground => self.ground_state(b),
307            ParseState::Escape => self.escape_state(b),
308            ParseState::CsiEntry => self.csi_entry_state(b),
309            ParseState::CsiParam => self.csi_param_state(b),
310            ParseState::OscEntry => self.osc_entry_state(b),
311            ParseState::OscString => self.osc_string_state(b),
312        }
313    }
314
315    fn ground_state(&mut self, b: u8) {
316        match b {
317            0x1B => {
318                // ESC
319                self.flush_pending_utf8_invalid();
320                self.parse_state = ParseState::Escape;
321            }
322            0x00..=0x1A | 0x1C..=0x1F => {
323                // C0 controls (mostly ignored)
324                self.flush_pending_utf8_invalid();
325                self.handle_c0(b);
326            }
327            _ => {
328                // Printable character (UTF-8 aware)
329                self.handle_printable(b);
330            }
331        }
332    }
333
334    fn escape_state(&mut self, b: u8) {
335        match b {
336            b'[' => {
337                // CSI
338                self.csi_params.clear();
339                self.csi_intermediate.clear();
340                self.parse_state = ParseState::CsiEntry;
341            }
342            b']' => {
343                // OSC
344                self.osc_buffer.clear();
345                self.parse_state = ParseState::OscEntry;
346            }
347            b'7' => {
348                // DECSC - save cursor (we track but don't implement save/restore stack)
349                self.parse_state = ParseState::Ground;
350            }
351            b'8' => {
352                // DECRC - restore cursor
353                self.parse_state = ParseState::Ground;
354            }
355            b'=' | b'>' => {
356                // Application/Normal keypad mode (ignored)
357                self.parse_state = ParseState::Ground;
358            }
359            0x1B => {
360                // ESC ESC - stay in escape (malformed, but handle gracefully)
361            }
362            _ => {
363                // Unknown escape, return to ground
364                self.parse_state = ParseState::Ground;
365            }
366        }
367    }
368
369    fn csi_entry_state(&mut self, b: u8) {
370        match b {
371            b'0'..=b'9' => {
372                self.csi_params.push((b - b'0') as u32);
373                self.parse_state = ParseState::CsiParam;
374            }
375            b';' => {
376                self.csi_params.push(0);
377                self.parse_state = ParseState::CsiParam;
378            }
379            b'?' | b'>' | b'!' => {
380                self.csi_intermediate.push(b);
381                self.parse_state = ParseState::CsiParam;
382            }
383            0x40..=0x7E => {
384                // Final byte with no params
385                self.execute_csi(b);
386                self.parse_state = ParseState::Ground;
387            }
388            _ => {
389                self.parse_state = ParseState::Ground;
390            }
391        }
392    }
393
394    fn csi_param_state(&mut self, b: u8) {
395        match b {
396            b'0'..=b'9' => {
397                if self.csi_params.is_empty() {
398                    self.csi_params.push(0);
399                }
400                if let Some(last) = self.csi_params.last_mut() {
401                    *last = last.saturating_mul(10).saturating_add((b - b'0') as u32);
402                }
403            }
404            b';' => {
405                self.csi_params.push(0);
406            }
407            b':' => {
408                // Subparameter (e.g., for 256/RGB colors) - we handle in SGR
409                self.csi_params.push(0);
410            }
411            0x20..=0x2F => {
412                self.csi_intermediate.push(b);
413            }
414            0x40..=0x7E => {
415                // Final byte
416                self.execute_csi(b);
417                self.parse_state = ParseState::Ground;
418            }
419            _ => {
420                self.parse_state = ParseState::Ground;
421            }
422        }
423    }
424
425    fn osc_entry_state(&mut self, b: u8) {
426        match b {
427            0x07 => {
428                // BEL - OSC terminator
429                self.execute_osc();
430                self.parse_state = ParseState::Ground;
431            }
432            0x1B => {
433                // Might be ST (ESC \)
434                self.parse_state = ParseState::OscString;
435            }
436            _ => {
437                self.osc_buffer.push(b);
438            }
439        }
440    }
441
442    fn osc_string_state(&mut self, b: u8) {
443        match b {
444            b'\\' => {
445                // ST (ESC \)
446                self.execute_osc();
447                self.parse_state = ParseState::Ground;
448            }
449            _ => {
450                // Not ST, put ESC back and continue
451                self.osc_buffer.push(0x1B);
452                self.osc_buffer.push(b);
453                self.parse_state = ParseState::OscEntry;
454            }
455        }
456    }
457
458    fn handle_c0(&mut self, b: u8) {
459        match b {
460            0x07 => {} // BEL - ignored
461            0x08 => {
462                // BS - backspace
463                if self.cursor_x > 0 {
464                    self.cursor_x -= 1;
465                }
466            }
467            0x09 => {
468                // HT - tab (move to next 8-column stop)
469                self.cursor_x = (self.cursor_x / 8 + 1) * 8;
470                if self.cursor_x >= self.width {
471                    self.cursor_x = self.width - 1;
472                }
473            }
474            0x0A => {
475                // LF - line feed
476                if self.cursor_y + 1 < self.height {
477                    self.cursor_y += 1;
478                }
479            }
480            0x0D => {
481                // CR - carriage return
482                self.cursor_x = 0;
483            }
484            _ => {} // Other C0 controls ignored
485        }
486    }
487
488    fn handle_printable(&mut self, b: u8) {
489        if self.utf8_expected.is_none() {
490            if b < 0x80 {
491                self.put_char(b as char);
492                return;
493            }
494            if let Some(expected) = Self::utf8_expected_len(b) {
495                self.utf8_pending.clear();
496                self.utf8_pending.push(b);
497                self.utf8_expected = Some(expected);
498                if expected == 1 {
499                    self.flush_utf8_sequence();
500                }
501            } else {
502                self.put_char('\u{FFFD}');
503            }
504            return;
505        }
506
507        if !Self::is_utf8_continuation(b) {
508            self.flush_pending_utf8_invalid();
509            self.handle_printable(b);
510            return;
511        }
512
513        self.utf8_pending.push(b);
514        if let Some(expected) = self.utf8_expected {
515            if self.utf8_pending.len() == expected {
516                self.flush_utf8_sequence();
517            } else if self.utf8_pending.len() > expected {
518                self.flush_pending_utf8_invalid();
519            }
520        }
521    }
522
523    fn flush_utf8_sequence(&mut self) {
524        // Collect chars first to avoid borrow conflict with put_char.
525        // UTF-8 sequences are at most 4 bytes, so this is small.
526        let chars: Vec<char> = std::str::from_utf8(&self.utf8_pending)
527            .map(|text| text.chars().collect())
528            .unwrap_or_else(|_| vec!['\u{FFFD}']);
529        self.utf8_pending.clear();
530        self.utf8_expected = None;
531        for ch in chars {
532            self.put_char(ch);
533        }
534    }
535
536    fn flush_pending_utf8_invalid(&mut self) {
537        if self.utf8_expected.is_some() {
538            self.put_char('\u{FFFD}');
539            self.utf8_pending.clear();
540            self.utf8_expected = None;
541        }
542    }
543
544    fn utf8_expected_len(first: u8) -> Option<usize> {
545        if first < 0x80 {
546            Some(1)
547        } else if (0xC2..=0xDF).contains(&first) {
548            Some(2)
549        } else if (0xE0..=0xEF).contains(&first) {
550            Some(3)
551        } else if (0xF0..=0xF4).contains(&first) {
552            Some(4)
553        } else {
554            None
555        }
556    }
557
558    fn is_utf8_continuation(byte: u8) -> bool {
559        (0x80..=0xBF).contains(&byte)
560    }
561
562    fn put_char(&mut self, ch: char) {
563        let width = char_width(ch);
564
565        // Zero-width (combining) character handling
566        if width == 0 {
567            if self.cursor_x > 0 {
568                // Append to previous cell
569                let idx = self.cursor_y * self.width + self.cursor_x - 1;
570                if let Some(cell) = self.cells.get_mut(idx) {
571                    cell.text.push(ch);
572                }
573            } else if self.cursor_x < self.width && self.cursor_y < self.height {
574                // At start of line, attach to current cell (if empty/space) or append
575                let idx = self.cursor_y * self.width + self.cursor_x;
576                let cell = &mut self.cells[idx];
577                if cell.text == " " {
578                    // Replace default space with space+combining
579                    cell.text = format!(" {}", ch);
580                } else {
581                    cell.text.push(ch);
582                }
583            }
584            return;
585        }
586
587        if self.cursor_x < self.width && self.cursor_y < self.height {
588            let cell = &mut self.cells[self.cursor_y * self.width + self.cursor_x];
589            cell.text = ch.to_string();
590            cell.fg = self.sgr.fg;
591            cell.bg = self.sgr.bg;
592            cell.attrs = CellAttrs::new(self.sgr.flags, self.current_link_id);
593            cell.link_id = self.current_link_id;
594
595            // Handle wide characters (clear the next cell if it exists)
596            if width == 2 && self.cursor_x + 1 < self.width {
597                let next_cell = &mut self.cells[self.cursor_y * self.width + self.cursor_x + 1];
598                next_cell.text = String::new(); // Clear content (placeholder)
599                next_cell.fg = self.sgr.fg; // Extend background color
600                next_cell.bg = self.sgr.bg;
601                next_cell.attrs = CellAttrs::NONE; // Clear attributes
602                next_cell.link_id = 0; // Clear link
603            }
604        }
605
606        self.cursor_x += width;
607
608        // Handle line wrap if at edge
609        if self.cursor_x >= self.width {
610            self.cursor_x = 0;
611            if self.cursor_y + 1 < self.height {
612                self.cursor_y += 1;
613            }
614        }
615    }
616
617    fn execute_csi(&mut self, final_byte: u8) {
618        let has_question = self.csi_intermediate.contains(&b'?');
619
620        match final_byte {
621            b'H' | b'f' => self.csi_cup(),             // CUP - cursor position
622            b'A' => self.csi_cuu(),                    // CUU - cursor up
623            b'B' => self.csi_cud(),                    // CUD - cursor down
624            b'C' => self.csi_cuf(),                    // CUF - cursor forward
625            b'D' => self.csi_cub(),                    // CUB - cursor back
626            b'G' => self.csi_cha(),                    // CHA - cursor horizontal absolute
627            b'd' => self.csi_vpa(),                    // VPA - vertical position absolute
628            b'J' => self.csi_ed(),                     // ED - erase in display
629            b'K' => self.csi_el(),                     // EL - erase in line
630            b'm' => self.csi_sgr(),                    // SGR - select graphic rendition
631            b'h' if has_question => self.csi_decset(), // DECSET
632            b'l' if has_question => self.csi_decrst(), // DECRST
633            b's' => {
634                // Save cursor position (ANSI)
635            }
636            b'u' => {
637                // Restore cursor position (ANSI)
638            }
639            _ => {} // Unknown CSI - ignored
640        }
641    }
642
643    fn csi_cup(&mut self) {
644        // CSI row ; col H
645        let row = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
646        let col = self.csi_params.get(1).copied().unwrap_or(1).max(1) as usize;
647        self.cursor_y = (row - 1).min(self.height - 1);
648        self.cursor_x = (col - 1).min(self.width - 1);
649    }
650
651    fn csi_cuu(&mut self) {
652        let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
653        self.cursor_y = self.cursor_y.saturating_sub(n);
654    }
655
656    fn csi_cud(&mut self) {
657        let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
658        self.cursor_y = (self.cursor_y + n).min(self.height - 1);
659    }
660
661    fn csi_cuf(&mut self) {
662        let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
663        self.cursor_x = (self.cursor_x + n).min(self.width - 1);
664    }
665
666    fn csi_cub(&mut self) {
667        let n = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
668        self.cursor_x = self.cursor_x.saturating_sub(n);
669    }
670
671    fn csi_cha(&mut self) {
672        let col = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
673        self.cursor_x = (col - 1).min(self.width - 1);
674    }
675
676    fn csi_vpa(&mut self) {
677        let row = self.csi_params.first().copied().unwrap_or(1).max(1) as usize;
678        self.cursor_y = (row - 1).min(self.height - 1);
679    }
680
681    fn csi_ed(&mut self) {
682        let mode = self.csi_params.first().copied().unwrap_or(0);
683        match mode {
684            0 => {
685                // Erase from cursor to end of screen
686                for x in self.cursor_x..self.width {
687                    self.erase_cell(x, self.cursor_y);
688                }
689                for y in (self.cursor_y + 1)..self.height {
690                    for x in 0..self.width {
691                        self.erase_cell(x, y);
692                    }
693                }
694            }
695            1 => {
696                // Erase from start of screen to cursor
697                for y in 0..self.cursor_y {
698                    for x in 0..self.width {
699                        self.erase_cell(x, y);
700                    }
701                }
702                for x in 0..=self.cursor_x {
703                    self.erase_cell(x, self.cursor_y);
704                }
705            }
706            2 | 3 => {
707                // Erase entire screen
708                for cell in &mut self.cells {
709                    *cell = ModelCell::default();
710                }
711            }
712            _ => {}
713        }
714    }
715
716    fn csi_el(&mut self) {
717        let mode = self.csi_params.first().copied().unwrap_or(0);
718        match mode {
719            0 => {
720                // Erase from cursor to end of line
721                for x in self.cursor_x..self.width {
722                    self.erase_cell(x, self.cursor_y);
723                }
724            }
725            1 => {
726                // Erase from start of line to cursor
727                for x in 0..=self.cursor_x {
728                    self.erase_cell(x, self.cursor_y);
729                }
730            }
731            2 => {
732                // Erase entire line
733                for x in 0..self.width {
734                    self.erase_cell(x, self.cursor_y);
735                }
736            }
737            _ => {}
738        }
739    }
740
741    fn erase_cell(&mut self, x: usize, y: usize) {
742        // Copy background color before borrowing self mutably
743        let bg = self.sgr.bg;
744        if let Some(cell) = self.cell_mut(x, y) {
745            cell.text = " ".to_string();
746            // Erase uses current background color
747            cell.fg = PackedRgba::WHITE;
748            cell.bg = bg;
749            cell.attrs = CellAttrs::NONE;
750            cell.link_id = 0;
751        }
752    }
753
754    fn csi_sgr(&mut self) {
755        if self.csi_params.is_empty() {
756            self.sgr.reset();
757            return;
758        }
759
760        let mut i = 0;
761        while i < self.csi_params.len() {
762            let code = self.csi_params[i];
763            match code {
764                0 => self.sgr.reset(),
765                1 => self.sgr.flags.insert(StyleFlags::BOLD),
766                2 => self.sgr.flags.insert(StyleFlags::DIM),
767                3 => self.sgr.flags.insert(StyleFlags::ITALIC),
768                4 => self.sgr.flags.insert(StyleFlags::UNDERLINE),
769                5 => self.sgr.flags.insert(StyleFlags::BLINK),
770                7 => self.sgr.flags.insert(StyleFlags::REVERSE),
771                8 => self.sgr.flags.insert(StyleFlags::HIDDEN),
772                9 => self.sgr.flags.insert(StyleFlags::STRIKETHROUGH),
773                21 | 22 => self.sgr.flags.remove(StyleFlags::BOLD | StyleFlags::DIM),
774                23 => self.sgr.flags.remove(StyleFlags::ITALIC),
775                24 => self.sgr.flags.remove(StyleFlags::UNDERLINE),
776                25 => self.sgr.flags.remove(StyleFlags::BLINK),
777                27 => self.sgr.flags.remove(StyleFlags::REVERSE),
778                28 => self.sgr.flags.remove(StyleFlags::HIDDEN),
779                29 => self.sgr.flags.remove(StyleFlags::STRIKETHROUGH),
780                // Basic foreground colors (30-37)
781                30..=37 => {
782                    self.sgr.fg = Self::basic_color(code - 30);
783                }
784                // Default foreground
785                39 => {
786                    self.sgr.fg = PackedRgba::WHITE;
787                }
788                // Basic background colors (40-47)
789                40..=47 => {
790                    self.sgr.bg = Self::basic_color(code - 40);
791                }
792                // Default background
793                49 => {
794                    self.sgr.bg = PackedRgba::TRANSPARENT;
795                }
796                // Bright foreground colors (90-97)
797                90..=97 => {
798                    self.sgr.fg = Self::bright_color(code - 90);
799                }
800                // Bright background colors (100-107)
801                100..=107 => {
802                    self.sgr.bg = Self::bright_color(code - 100);
803                }
804                // Extended colors (38/48)
805                38 => {
806                    if let Some(color) = self.parse_extended_color(&mut i) {
807                        self.sgr.fg = color;
808                    }
809                }
810                48 => {
811                    if let Some(color) = self.parse_extended_color(&mut i) {
812                        self.sgr.bg = color;
813                    }
814                }
815                _ => {} // Unknown SGR code
816            }
817            i += 1;
818        }
819    }
820
821    fn parse_extended_color(&self, i: &mut usize) -> Option<PackedRgba> {
822        let mode = self.csi_params.get(*i + 1)?;
823        match *mode {
824            5 => {
825                // 256-color mode: 38;5;n
826                let idx = self.csi_params.get(*i + 2)?;
827                *i += 2;
828                Some(Self::color_256(*idx as u8))
829            }
830            2 => {
831                // RGB mode: 38;2;r;g;b
832                let r = *self.csi_params.get(*i + 2)? as u8;
833                let g = *self.csi_params.get(*i + 3)? as u8;
834                let b = *self.csi_params.get(*i + 4)? as u8;
835                *i += 4;
836                Some(PackedRgba::rgb(r, g, b))
837            }
838            _ => None,
839        }
840    }
841
842    fn basic_color(idx: u32) -> PackedRgba {
843        match idx {
844            0 => PackedRgba::rgb(0, 0, 0),       // Black
845            1 => PackedRgba::rgb(128, 0, 0),     // Red
846            2 => PackedRgba::rgb(0, 128, 0),     // Green
847            3 => PackedRgba::rgb(128, 128, 0),   // Yellow
848            4 => PackedRgba::rgb(0, 0, 128),     // Blue
849            5 => PackedRgba::rgb(128, 0, 128),   // Magenta
850            6 => PackedRgba::rgb(0, 128, 128),   // Cyan
851            7 => PackedRgba::rgb(192, 192, 192), // White
852            _ => PackedRgba::WHITE,
853        }
854    }
855
856    fn bright_color(idx: u32) -> PackedRgba {
857        match idx {
858            0 => PackedRgba::rgb(128, 128, 128), // Bright Black
859            1 => PackedRgba::rgb(255, 0, 0),     // Bright Red
860            2 => PackedRgba::rgb(0, 255, 0),     // Bright Green
861            3 => PackedRgba::rgb(255, 255, 0),   // Bright Yellow
862            4 => PackedRgba::rgb(0, 0, 255),     // Bright Blue
863            5 => PackedRgba::rgb(255, 0, 255),   // Bright Magenta
864            6 => PackedRgba::rgb(0, 255, 255),   // Bright Cyan
865            7 => PackedRgba::rgb(255, 255, 255), // Bright White
866            _ => PackedRgba::WHITE,
867        }
868    }
869
870    fn color_256(idx: u8) -> PackedRgba {
871        match idx {
872            0..=7 => Self::basic_color(idx as u32),
873            8..=15 => Self::bright_color((idx - 8) as u32),
874            16..=231 => {
875                // 6x6x6 color cube
876                let idx = idx - 16;
877                let r = (idx / 36) % 6;
878                let g = (idx / 6) % 6;
879                let b = idx % 6;
880                let to_channel = |v| if v == 0 { 0 } else { 55 + v * 40 };
881                PackedRgba::rgb(to_channel(r), to_channel(g), to_channel(b))
882            }
883            232..=255 => {
884                // Grayscale ramp
885                let gray = 8 + (idx - 232) * 10;
886                PackedRgba::rgb(gray, gray, gray)
887            }
888        }
889    }
890
891    fn csi_decset(&mut self) {
892        for &code in &self.csi_params {
893            match code {
894                25 => self.modes.cursor_visible = true, // DECTCEM - cursor visible
895                1049 => self.modes.alt_screen = true,   // Alt screen buffer
896                2026 => self.modes.sync_output_level += 1, // Synchronized output begin
897                _ => {}
898            }
899        }
900    }
901
902    fn csi_decrst(&mut self) {
903        for &code in &self.csi_params {
904            match code {
905                25 => self.modes.cursor_visible = false, // DECTCEM - cursor hidden
906                1049 => self.modes.alt_screen = false,   // Alt screen buffer off
907                2026 => {
908                    // Synchronized output end
909                    self.modes.sync_output_level = self.modes.sync_output_level.saturating_sub(1);
910                }
911                _ => {}
912            }
913        }
914    }
915
916    fn execute_osc(&mut self) {
917        // Parse OSC: code ; data
918        // Clone buffer to avoid borrow issues when calling handle_osc8
919        let data = String::from_utf8_lossy(&self.osc_buffer).to_string();
920        let mut parts = data.splitn(2, ';');
921        let code: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
922
923        // OSC 8 - hyperlink (other OSC codes ignored)
924        if code == 8
925            && let Some(rest) = parts.next()
926        {
927            let rest = rest.to_string();
928            self.handle_osc8(&rest);
929        }
930    }
931
932    fn handle_osc8(&mut self, data: &str) {
933        // Format: OSC 8 ; params ; uri ST
934        // We support: OSC 8 ; ; uri ST (start link) and OSC 8 ; ; ST (end link)
935        let mut parts = data.splitn(2, ';');
936        let _params = parts.next().unwrap_or("");
937        let uri = parts.next().unwrap_or("");
938
939        if uri.is_empty() {
940            // End hyperlink
941            self.current_link_id = 0;
942        } else {
943            // Start hyperlink
944            self.links.push(uri.to_string());
945            self.current_link_id = (self.links.len() - 1) as u32;
946        }
947    }
948
949    /// Compare two grids and return a diff description for debugging.
950    pub fn diff_grid(&self, expected: &[ModelCell]) -> Option<String> {
951        if self.cells.len() != expected.len() {
952            return Some(format!(
953                "Grid size mismatch: got {} cells, expected {}",
954                self.cells.len(),
955                expected.len()
956            ));
957        }
958
959        let mut diffs = Vec::new();
960        for (i, (actual, exp)) in self.cells.iter().zip(expected.iter()).enumerate() {
961            if actual != exp {
962                let x = i % self.width;
963                let y = i / self.width;
964                diffs.push(format!(
965                    "  ({}, {}): got {:?}, expected {:?}",
966                    x, y, actual.text, exp.text
967                ));
968            }
969        }
970
971        if diffs.is_empty() {
972            None
973        } else {
974            Some(format!("Grid differences:\n{}", diffs.join("\n")))
975        }
976    }
977
978    /// Dump the escape sequences in a human-readable format (for debugging test failures).
979    pub fn dump_sequences(bytes: &[u8]) -> String {
980        let mut output = String::new();
981        let mut i = 0;
982        while i < bytes.len() {
983            if bytes[i] == 0x1B {
984                if i + 1 < bytes.len() {
985                    match bytes[i + 1] {
986                        b'[' => {
987                            // CSI sequence
988                            output.push_str("\\e[");
989                            i += 2;
990                            while i < bytes.len() && !(0x40..=0x7E).contains(&bytes[i]) {
991                                output.push(bytes[i] as char);
992                                i += 1;
993                            }
994                            if i < bytes.len() {
995                                output.push(bytes[i] as char);
996                                i += 1;
997                            }
998                        }
999                        b']' => {
1000                            // OSC sequence
1001                            output.push_str("\\e]");
1002                            i += 2;
1003                            while i < bytes.len() && bytes[i] != 0x07 {
1004                                if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\'
1005                                {
1006                                    output.push_str("\\e\\\\");
1007                                    i += 2;
1008                                    break;
1009                                }
1010                                output.push(bytes[i] as char);
1011                                i += 1;
1012                            }
1013                            if i < bytes.len() && bytes[i] == 0x07 {
1014                                output.push_str("\\a");
1015                                i += 1;
1016                            }
1017                        }
1018                        _ => {
1019                            output.push_str(&format!("\\e{}", bytes[i + 1] as char));
1020                            i += 2;
1021                        }
1022                    }
1023                } else {
1024                    output.push_str("\\e");
1025                    i += 1;
1026                }
1027            } else if bytes[i] < 0x20 {
1028                output.push_str(&format!("\\x{:02x}", bytes[i]));
1029                i += 1;
1030            } else {
1031                output.push(bytes[i] as char);
1032                i += 1;
1033            }
1034        }
1035        output
1036    }
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042    use crate::ansi;
1043
1044    #[test]
1045    fn new_creates_empty_grid() {
1046        let model = TerminalModel::new(80, 24);
1047        assert_eq!(model.width(), 80);
1048        assert_eq!(model.height(), 24);
1049        assert_eq!(model.cursor(), (0, 0));
1050        assert_eq!(model.cells().len(), 80 * 24);
1051    }
1052
1053    #[test]
1054    fn printable_text_writes_to_grid() {
1055        let mut model = TerminalModel::new(10, 5);
1056        model.process(b"Hello");
1057        assert_eq!(model.cursor(), (5, 0));
1058        assert_eq!(model.row_text(0), Some("Hello".to_string()));
1059    }
1060
1061    #[test]
1062    fn cup_moves_cursor() {
1063        let mut model = TerminalModel::new(80, 24);
1064        model.process(b"\x1b[5;10H"); // Row 5, Col 10 (1-indexed)
1065        assert_eq!(model.cursor(), (9, 4)); // 0-indexed
1066    }
1067
1068    #[test]
1069    fn cup_with_defaults() {
1070        let mut model = TerminalModel::new(80, 24);
1071        model.process(b"\x1b[H"); // Should default to 1;1
1072        assert_eq!(model.cursor(), (0, 0));
1073    }
1074
1075    #[test]
1076    fn relative_cursor_moves() {
1077        let mut model = TerminalModel::new(80, 24);
1078        model.process(b"\x1b[10;10H"); // Move to (9, 9)
1079        model.process(b"\x1b[2A"); // Up 2
1080        assert_eq!(model.cursor(), (9, 7));
1081        model.process(b"\x1b[3B"); // Down 3
1082        assert_eq!(model.cursor(), (9, 10));
1083        model.process(b"\x1b[5C"); // Forward 5
1084        assert_eq!(model.cursor(), (14, 10));
1085        model.process(b"\x1b[3D"); // Back 3
1086        assert_eq!(model.cursor(), (11, 10));
1087    }
1088
1089    #[test]
1090    fn sgr_sets_style_flags() {
1091        let mut model = TerminalModel::new(20, 5);
1092        model.process(b"\x1b[1mBold\x1b[0m");
1093        assert!(model.cell(0, 0).unwrap().attrs.has_flag(StyleFlags::BOLD));
1094        assert!(!model.cell(4, 0).unwrap().attrs.has_flag(StyleFlags::BOLD)); // After reset
1095    }
1096
1097    #[test]
1098    fn sgr_sets_colors() {
1099        let mut model = TerminalModel::new(20, 5);
1100        model.process(b"\x1b[31mRed\x1b[0m");
1101        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(128, 0, 0));
1102    }
1103
1104    #[test]
1105    fn sgr_256_colors() {
1106        let mut model = TerminalModel::new(20, 5);
1107        model.process(b"\x1b[38;5;196mX"); // Bright red in 256 palette
1108        let cell = model.cell(0, 0).unwrap();
1109        // 196 = 16 + 180 = 16 + 5*36 + 0*6 + 0 = red=5, g=0, b=0
1110        // r = 55 + 5*40 = 255, g = 0, b = 0
1111        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
1112    }
1113
1114    #[test]
1115    fn sgr_rgb_colors() {
1116        let mut model = TerminalModel::new(20, 5);
1117        model.process(b"\x1b[38;2;100;150;200mX");
1118        assert_eq!(model.cell(0, 0).unwrap().fg, PackedRgba::rgb(100, 150, 200));
1119    }
1120
1121    #[test]
1122    fn erase_line() {
1123        let mut model = TerminalModel::new(10, 5);
1124        model.process(b"ABCDEFGHIJ");
1125        // After 10 chars in 10-col terminal, cursor wraps to (0, 1)
1126        // Move back to row 1, column 5 explicitly
1127        model.process(b"\x1b[1;5H"); // Row 1, Col 5 (1-indexed) = (4, 0)
1128        model.process(b"\x1b[K"); // Erase to end of line
1129        assert_eq!(model.row_text(0), Some("ABCD".to_string()));
1130    }
1131
1132    #[test]
1133    fn erase_display() {
1134        let mut model = TerminalModel::new(10, 5);
1135        model.process(b"Line1\n");
1136        model.process(b"Line2\n");
1137        model.process(b"\x1b[2J"); // Erase entire screen
1138        for y in 0..5 {
1139            assert_eq!(model.row_text(y), Some(String::new()));
1140        }
1141    }
1142
1143    #[test]
1144    fn osc8_hyperlinks() {
1145        let mut model = TerminalModel::new(20, 5);
1146        model.process(b"\x1b]8;;https://example.com\x07Link\x1b]8;;\x07");
1147
1148        let cell = model.cell(0, 0).unwrap();
1149        assert!(cell.link_id > 0);
1150        assert_eq!(model.link_url(cell.link_id), Some("https://example.com"));
1151
1152        // After link ends, link_id should be 0
1153        let cell_after = model.cell(4, 0).unwrap();
1154        assert_eq!(cell_after.link_id, 0);
1155    }
1156
1157    #[test]
1158    fn dangling_link_detection() {
1159        let mut model = TerminalModel::new(20, 5);
1160        model.process(b"\x1b]8;;https://example.com\x07Link");
1161        assert!(model.has_dangling_link());
1162
1163        model.process(b"\x1b]8;;\x07");
1164        assert!(!model.has_dangling_link());
1165    }
1166
1167    #[test]
1168    fn sync_output_tracking() {
1169        let mut model = TerminalModel::new(20, 5);
1170        assert!(model.sync_output_balanced());
1171
1172        model.process(b"\x1b[?2026h"); // Begin sync
1173        assert!(!model.sync_output_balanced());
1174        assert_eq!(model.modes().sync_output_level, 1);
1175
1176        model.process(b"\x1b[?2026l"); // End sync
1177        assert!(model.sync_output_balanced());
1178    }
1179
1180    #[test]
1181    fn utf8_multibyte_stream_is_decoded() {
1182        let mut model = TerminalModel::new(10, 1);
1183        let text = "a\u{00E9}\u{4E2D}\u{1F600}";
1184        model.process(text.as_bytes());
1185
1186        assert_eq!(model.row_text(0).as_deref(), Some(text));
1187        assert_eq!(model.cursor(), (6, 0));
1188    }
1189
1190    #[test]
1191    fn utf8_sequence_can_span_process_calls() {
1192        let mut model = TerminalModel::new(10, 1);
1193        let text = "\u{00E9}";
1194        let bytes = text.as_bytes();
1195
1196        model.process(&bytes[..1]);
1197        assert_eq!(model.row_text(0).as_deref(), Some(""));
1198
1199        model.process(&bytes[1..]);
1200        assert_eq!(model.row_text(0).as_deref(), Some(text));
1201    }
1202
1203    #[test]
1204    fn line_wrap() {
1205        let mut model = TerminalModel::new(5, 3);
1206        model.process(b"ABCDEFGH");
1207        assert_eq!(model.row_text(0), Some("ABCDE".to_string()));
1208        assert_eq!(model.row_text(1), Some("FGH".to_string()));
1209        assert_eq!(model.cursor(), (3, 1));
1210    }
1211
1212    #[test]
1213    fn cr_lf_handling() {
1214        let mut model = TerminalModel::new(20, 5);
1215        model.process(b"Hello\r\n");
1216        assert_eq!(model.cursor(), (0, 1));
1217        model.process(b"World");
1218        assert_eq!(model.row_text(0), Some("Hello".to_string()));
1219        assert_eq!(model.row_text(1), Some("World".to_string()));
1220    }
1221
1222    #[test]
1223    fn cursor_visibility() {
1224        let mut model = TerminalModel::new(20, 5);
1225        assert!(model.modes().cursor_visible);
1226
1227        model.process(b"\x1b[?25l"); // Hide cursor
1228        assert!(!model.modes().cursor_visible);
1229
1230        model.process(b"\x1b[?25h"); // Show cursor
1231        assert!(model.modes().cursor_visible);
1232    }
1233
1234    #[test]
1235    fn alt_screen_toggle_is_tracked() {
1236        let mut model = TerminalModel::new(20, 5);
1237        assert!(!model.modes().alt_screen);
1238
1239        model.process(b"\x1b[?1049h");
1240        assert!(model.modes().alt_screen);
1241
1242        model.process(b"\x1b[?1049l");
1243        assert!(!model.modes().alt_screen);
1244    }
1245
1246    #[test]
1247    fn dump_sequences_readable() {
1248        let bytes = b"\x1b[1;1H\x1b[1mHello\x1b[0m";
1249        let dump = TerminalModel::dump_sequences(bytes);
1250        assert!(dump.contains("\\e[1;1H"));
1251        assert!(dump.contains("\\e[1m"));
1252        assert!(dump.contains("Hello"));
1253        assert!(dump.contains("\\e[0m"));
1254    }
1255
1256    #[test]
1257    fn reset_clears_state() {
1258        let mut model = TerminalModel::new(20, 5);
1259        model.process(b"\x1b[10;10HTest\x1b[1m");
1260        model.reset();
1261
1262        assert_eq!(model.cursor(), (0, 0));
1263        assert!(model.sgr_state().flags.is_empty());
1264        for y in 0..5 {
1265            assert_eq!(model.row_text(y), Some(String::new()));
1266        }
1267    }
1268
1269    #[test]
1270    fn erase_scrollback_mode_clears_screen() {
1271        let mut model = TerminalModel::new(10, 3);
1272        model.process(b"Line1\nLine2\nLine3");
1273        model.process(b"\x1b[3J"); // ED scrollback mode
1274
1275        for y in 0..3 {
1276            assert_eq!(model.row_text(y), Some(String::new()));
1277        }
1278    }
1279
1280    #[test]
1281    fn scroll_region_sequences_are_ignored_but_safe() {
1282        let mut model = TerminalModel::new(12, 3);
1283        model.process(b"ABCD");
1284        let cursor_before = model.cursor();
1285
1286        let mut buf = Vec::new();
1287        ansi::set_scroll_region(&mut buf, 1, 2).expect("scroll region sequence");
1288        model.process(&buf);
1289        model.process(ansi::RESET_SCROLL_REGION);
1290
1291        assert_eq!(model.cursor(), cursor_before);
1292        model.process(b"EF");
1293        assert_eq!(model.row_text(0).as_deref(), Some("ABCDEF"));
1294    }
1295
1296    #[test]
1297    fn scroll_region_invalid_params_do_not_corrupt_state() {
1298        let mut model = TerminalModel::new(8, 2);
1299        model.process(b"Hi");
1300        let cursor_before = model.cursor();
1301
1302        model.process(b"\x1b[5;2r"); // bottom < top
1303        model.process(b"\x1b[0;0r"); // zero params
1304        model.process(b"\x1b[999;999r"); // out of bounds
1305
1306        assert_eq!(model.cursor(), cursor_before);
1307        model.process(b"!");
1308        assert_eq!(model.row_text(0).as_deref(), Some("Hi!"));
1309    }
1310}
1311
1312/// Property tests for terminal model correctness.
1313#[cfg(test)]
1314mod proptests {
1315    use super::*;
1316    use proptest::prelude::*;
1317
1318    /// Generate a valid CSI sequence for cursor positioning.
1319    fn cup_sequence(row: u8, col: u8) -> Vec<u8> {
1320        format!("\x1b[{};{}H", row.max(1), col.max(1)).into_bytes()
1321    }
1322
1323    /// Generate a valid SGR sequence.
1324    fn sgr_sequence(codes: &[u8]) -> Vec<u8> {
1325        let codes_str: Vec<String> = codes.iter().map(|c| c.to_string()).collect();
1326        format!("\x1b[{}m", codes_str.join(";")).into_bytes()
1327    }
1328
1329    proptest! {
1330        /// Any sequence of printable ASCII doesn't crash.
1331        #[test]
1332        fn printable_ascii_no_crash(s in "[A-Za-z0-9 ]{0,100}") {
1333            let mut model = TerminalModel::new(80, 24);
1334            model.process(s.as_bytes());
1335            // Model should be in a valid state
1336            let (x, y) = model.cursor();
1337            prop_assert!(x < model.width());
1338            prop_assert!(y < model.height());
1339        }
1340
1341        /// CUP sequences always leave cursor in bounds.
1342        #[test]
1343        fn cup_cursor_in_bounds(row in 0u8..100, col in 0u8..200) {
1344            let mut model = TerminalModel::new(80, 24);
1345            let seq = cup_sequence(row, col);
1346            model.process(&seq);
1347
1348            let (x, y) = model.cursor();
1349            prop_assert!(x < model.width(), "cursor_x {} >= width {}", x, model.width());
1350            prop_assert!(y < model.height(), "cursor_y {} >= height {}", y, model.height());
1351        }
1352
1353        /// Relative cursor moves never go out of bounds.
1354        #[test]
1355        fn relative_moves_in_bounds(
1356            start_row in 1u8..24,
1357            start_col in 1u8..80,
1358            up in 0u8..50,
1359            down in 0u8..50,
1360            left in 0u8..100,
1361            right in 0u8..100,
1362        ) {
1363            let mut model = TerminalModel::new(80, 24);
1364
1365            // Position cursor
1366            model.process(&cup_sequence(start_row, start_col));
1367
1368            // Apply relative moves
1369            model.process(format!("\x1b[{}A", up).as_bytes());
1370            model.process(format!("\x1b[{}B", down).as_bytes());
1371            model.process(format!("\x1b[{}D", left).as_bytes());
1372            model.process(format!("\x1b[{}C", right).as_bytes());
1373
1374            let (x, y) = model.cursor();
1375            prop_assert!(x < model.width());
1376            prop_assert!(y < model.height());
1377        }
1378
1379        /// SGR reset always clears all flags.
1380        #[test]
1381        fn sgr_reset_clears_flags(attrs in proptest::collection::vec(1u8..9, 0..5)) {
1382            let mut model = TerminalModel::new(80, 24);
1383
1384            // Set some attributes
1385            if !attrs.is_empty() {
1386                model.process(&sgr_sequence(&attrs));
1387            }
1388
1389            // Reset
1390            model.process(b"\x1b[0m");
1391
1392            prop_assert!(model.sgr_state().flags.is_empty());
1393        }
1394
1395        /// Hyperlinks always balance (no dangling after close).
1396        #[test]
1397        fn hyperlinks_balance(text in "[a-z]{1,20}") {
1398            let mut model = TerminalModel::new(80, 24);
1399
1400            // Start link
1401            model.process(b"\x1b]8;;https://example.com\x07");
1402            prop_assert!(model.has_dangling_link());
1403
1404            // Write some text
1405            model.process(text.as_bytes());
1406
1407            // End link
1408            model.process(b"\x1b]8;;\x07");
1409            prop_assert!(!model.has_dangling_link());
1410        }
1411
1412        /// Sync output always balances with nested begin/end.
1413        #[test]
1414        fn sync_output_balances(nesting in 1usize..5) {
1415            let mut model = TerminalModel::new(80, 24);
1416
1417            // Begin sync N times
1418            for _ in 0..nesting {
1419                model.process(b"\x1b[?2026h");
1420            }
1421            prop_assert_eq!(model.modes().sync_output_level, nesting as u32);
1422
1423            // End sync N times
1424            for _ in 0..nesting {
1425                model.process(b"\x1b[?2026l");
1426            }
1427            prop_assert!(model.sync_output_balanced());
1428        }
1429
1430        /// Erase operations don't crash and leave cursor in bounds.
1431        #[test]
1432        fn erase_operations_safe(
1433            row in 1u8..24,
1434            col in 1u8..80,
1435            ed_mode in 0u8..4,
1436            el_mode in 0u8..3,
1437        ) {
1438            let mut model = TerminalModel::new(80, 24);
1439
1440            // Position cursor
1441            model.process(&cup_sequence(row, col));
1442
1443            // Erase display
1444            model.process(format!("\x1b[{}J", ed_mode).as_bytes());
1445
1446            // Position again and erase line
1447            model.process(&cup_sequence(row, col));
1448            model.process(format!("\x1b[{}K", el_mode).as_bytes());
1449
1450            let (x, y) = model.cursor();
1451            prop_assert!(x < model.width());
1452            prop_assert!(y < model.height());
1453        }
1454
1455        /// Random bytes never cause a panic (fuzz-like test).
1456        #[test]
1457        fn random_bytes_no_panic(bytes in proptest::collection::vec(any::<u8>(), 0..200)) {
1458            let mut model = TerminalModel::new(80, 24);
1459            model.process(&bytes);
1460
1461            // Just check it didn't panic and cursor is valid
1462            let (x, y) = model.cursor();
1463            prop_assert!(x < model.width());
1464            prop_assert!(y < model.height());
1465        }
1466    }
1467}