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