Skip to main content

ftui_pty/
virtual_terminal.rs

1//! In-memory virtual terminal state machine for testing.
2//!
3//! `VirtualTerminal` maintains a full terminal grid with cursor tracking,
4//! ANSI sequence interpretation, scrollback buffer, and state inspection
5//! methods. It is designed for testing terminal applications without
6//! requiring a real PTY or terminal emulator.
7//!
8//! # Invariants
9//!
10//! 1. **Cursor always in bounds**: `cursor_x <= width`, `cursor_y < height`.
11//!    When `cursor_x == width`, the terminal is in "pending wrap" state:
12//!    the next character will wrap to the start of the next line. This is
13//!    standard terminal behavior (DECAWM). Operations that would move the
14//!    cursor out of bounds clamp to edges (except wrapping, which advances
15//!    to the next line).
16//!
17//! 2. **Grid always fully populated**: `grid.len() == width * height`.
18//!    Every cell is initialized to the default (space, no style).
19//!
20//! 3. **Scrollback is append-only during normal operation**: Lines pushed
21//!    into scrollback are never modified (new lines are appended). The
22//!    scrollback may be truncated from the front when `max_scrollback`
23//!    is exceeded.
24//!
25//! 4. **Attribute state is sticky**: SGR attributes apply to all
26//!    subsequent characters until explicitly reset.
27//!
28//! # Failure Modes
29//!
30//! | Failure | Cause | Behavior |
31//! |---------|-------|----------|
32//! | Unrecognized CSI | Unknown terminal sequence | Silently ignored |
33//! | Scrollback overflow | Excessive output | Front-truncated to `max_scrollback` |
34//! | Cursor wrap past bottom | Text output fills screen | Scroll up, top line to scrollback |
35//!
36//! # Evidence Ledger
37//!
38//! | Claim | Evidence |
39//! |-------|----------|
40//! | Quirks are explicit, not inferred | `QuirkSet` must be passed or set explicitly; no runtime detection |
41//! | Default behavior unchanged | `VirtualTerminal::new` uses `QuirkSet::empty()` |
42//!
43//! # Behavioral Isomorphism (Performance)
44//!
45//! `QuirkSet::empty()` is the identity: quirk checks short-circuit and preserve
46//! the pre-existing control flow. The quirk branches are constant-time boolean
47//! guards, with no extra allocations or buffering. Golden output checksums are
48//! emitted in E2E JSONL logs for reproducibility.
49//!
50//! # Performance Profile
51//!
52//! - Baseline: `QuirkSet::empty()` (default).
53//! - Profiles: `tmux_nested`, `gnu_screen`, `windows_console`.
54//! - Opportunity Matrix:
55//!   - Branch locality: keep quirk checks adjacent to affected escape handlers.
56//!   - Allocation: no new buffers; reuse the existing grid and scrollback.
57//!   - Diff cost: avoid extra passes over the grid in quirk branches.
58
59use std::collections::VecDeque;
60use unicode_width::UnicodeWidthChar;
61
62/// Sentinel character used for the continuation (right) cell of a wide character.
63const WIDE_CONTINUATION: char = '\0';
64
65/// Translate a character through the DEC Special Graphics charset.
66///
67/// Maps ASCII 0x60–0x7E to Unicode line-drawing and symbol characters.
68/// Characters outside this range pass through unchanged.
69fn dec_graphics_char(ch: char) -> char {
70    match ch {
71        '`' => '\u{25C6}', // ◆ diamond
72        'a' => '\u{2592}', // ▒ checker board
73        'b' => '\u{2409}', // ␉ HT symbol
74        'c' => '\u{240C}', // ␌ FF symbol
75        'd' => '\u{240D}', // ␍ CR symbol
76        'e' => '\u{240A}', // ␊ LF symbol
77        'f' => '\u{00B0}', // ° degree sign
78        'g' => '\u{00B1}', // ± plus-minus
79        'h' => '\u{2424}', // ␤ NL symbol
80        'i' => '\u{240B}', // ␋ VT symbol
81        'j' => '\u{2518}', // ┘ lower-right corner
82        'k' => '\u{2510}', // ┐ upper-right corner
83        'l' => '\u{250C}', // ┌ upper-left corner
84        'm' => '\u{2514}', // └ lower-left corner
85        'n' => '\u{253C}', // ┼ crossing lines
86        'o' => '\u{23BA}', // ⎺ scan line 1
87        'p' => '\u{23BB}', // ⎻ scan line 3
88        'q' => '\u{2500}', // ─ horizontal line
89        'r' => '\u{23BC}', // ⎼ scan line 7
90        's' => '\u{23BD}', // ⎽ scan line 9
91        't' => '\u{251C}', // ├ left tee
92        'u' => '\u{2524}', // ┤ right tee
93        'v' => '\u{2534}', // ┴ bottom tee
94        'w' => '\u{252C}', // ┬ top tee
95        'x' => '\u{2502}', // │ vertical line
96        'y' => '\u{2264}', // ≤ less-than-or-equal
97        'z' => '\u{2265}', // ≥ greater-than-or-equal
98        '{' => '\u{03C0}', // π pi
99        '|' => '\u{2260}', // ≠ not-equal
100        '}' => '\u{00A3}', // £ pound sign
101        '~' => '\u{00B7}', // · centered dot
102        _ => ch,
103    }
104}
105
106/// Translate a character through the given charset designator.
107fn translate_charset(ch: char, designator: u8) -> char {
108    match designator {
109        b'0' => dec_graphics_char(ch),
110        _ => ch,
111    }
112}
113
114/// RGB color value.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub struct Color {
117    pub r: u8,
118    pub g: u8,
119    pub b: u8,
120}
121
122impl Color {
123    #[must_use]
124    pub const fn new(r: u8, g: u8, b: u8) -> Self {
125        Self { r, g, b }
126    }
127}
128
129/// Style attributes tracked per-cell.
130#[derive(Debug, Clone, PartialEq, Eq, Default)]
131pub struct CellStyle {
132    pub fg: Option<Color>,
133    pub bg: Option<Color>,
134    pub bold: bool,
135    pub dim: bool,
136    pub italic: bool,
137    pub underline: bool,
138    pub blink: bool,
139    pub reverse: bool,
140    pub strikethrough: bool,
141    pub hidden: bool,
142    pub overline: bool,
143}
144
145impl CellStyle {
146    fn reset(&mut self) {
147        *self = Self::default();
148    }
149}
150
151/// A single cell in the virtual terminal grid.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct VCell {
154    pub ch: char,
155    pub style: CellStyle,
156}
157
158impl Default for VCell {
159    fn default() -> Self {
160        Self {
161            ch: ' ',
162            style: CellStyle::default(),
163        }
164    }
165}
166
167/// Parser state for ANSI escape sequence interpretation.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169enum ParseState {
170    Ground,
171    Escape,
172    EscapeHash,
173    EscapeCharset(u8),
174    Csi,
175    Osc,
176    /// Seen ESC inside an OSC sequence — waiting for `\` to complete ST.
177    OscEscapeSeen,
178}
179
180/// Terminal quirks that can be simulated by the virtual terminal.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum TerminalQuirk {
183    /// Nested tmux sessions can drop DEC save/restore in alt-screen mode.
184    TmuxNestedCursorSaveRestore,
185    /// GNU screen-style immediate wrap on last column writes.
186    ScreenImmediateWrap,
187    /// Windows console without alternate screen support.
188    WindowsNoAltScreen,
189}
190
191/// Set of terminal quirks to simulate.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub struct QuirkSet {
194    tmux_nested_cursor: bool,
195    screen_immediate_wrap: bool,
196    windows_no_alt_screen: bool,
197}
198
199impl Default for QuirkSet {
200    fn default() -> Self {
201        Self::empty()
202    }
203}
204
205impl QuirkSet {
206    /// No quirks enabled.
207    #[must_use]
208    pub const fn empty() -> Self {
209        Self {
210            tmux_nested_cursor: false,
211            screen_immediate_wrap: false,
212            windows_no_alt_screen: false,
213        }
214    }
215
216    /// Simulate nested tmux cursor save/restore quirks in alt-screen.
217    #[must_use]
218    pub const fn tmux_nested() -> Self {
219        Self {
220            tmux_nested_cursor: true,
221            ..Self::empty()
222        }
223    }
224
225    /// Simulate GNU screen line-wrap behavior.
226    #[must_use]
227    pub const fn gnu_screen() -> Self {
228        Self {
229            screen_immediate_wrap: true,
230            ..Self::empty()
231        }
232    }
233
234    /// Simulate Windows console limitations (no alt-screen).
235    #[must_use]
236    pub const fn windows_console() -> Self {
237        Self {
238            windows_no_alt_screen: true,
239            ..Self::empty()
240        }
241    }
242
243    /// Enable or disable the tmux nested cursor quirk.
244    #[must_use]
245    pub const fn with_tmux_nested_cursor(mut self, enabled: bool) -> Self {
246        self.tmux_nested_cursor = enabled;
247        self
248    }
249
250    /// Enable or disable the immediate wrap quirk.
251    #[must_use]
252    pub const fn with_screen_immediate_wrap(mut self, enabled: bool) -> Self {
253        self.screen_immediate_wrap = enabled;
254        self
255    }
256
257    /// Enable or disable the Windows no-alt-screen quirk.
258    #[must_use]
259    pub const fn with_windows_no_alt_screen(mut self, enabled: bool) -> Self {
260        self.windows_no_alt_screen = enabled;
261        self
262    }
263
264    /// Check if a specific quirk is enabled.
265    #[must_use]
266    pub const fn has(self, quirk: TerminalQuirk) -> bool {
267        match quirk {
268            TerminalQuirk::TmuxNestedCursorSaveRestore => self.tmux_nested_cursor,
269            TerminalQuirk::ScreenImmediateWrap => self.screen_immediate_wrap,
270            TerminalQuirk::WindowsNoAltScreen => self.windows_no_alt_screen,
271        }
272    }
273}
274
275/// In-memory virtual terminal with cursor tracking and ANSI interpretation.
276///
277/// # Example
278///
279/// ```
280/// use ftui_pty::virtual_terminal::VirtualTerminal;
281///
282/// let mut vt = VirtualTerminal::new(80, 24);
283/// vt.feed(b"Hello, World!");
284/// assert_eq!(vt.char_at(0, 0), Some('H'));
285/// assert_eq!(vt.char_at(12, 0), Some('!'));
286/// assert_eq!(vt.cursor(), (13, 0));
287/// ```
288pub struct VirtualTerminal {
289    width: u16,
290    height: u16,
291    grid: Vec<VCell>,
292    cursor_x: u16,
293    cursor_y: u16,
294    cursor_visible: bool,
295    current_style: CellStyle,
296    scrollback: VecDeque<Vec<VCell>>,
297    max_scrollback: usize,
298    // Saved cursor position (DEC save/restore)
299    saved_cursor: Option<(u16, u16)>,
300    // Scroll region (top, bottom) — 0-indexed, inclusive
301    scroll_top: u16,
302    scroll_bottom: u16,
303    // Parser state
304    parse_state: ParseState,
305    csi_params: Vec<u16>,
306    csi_intermediate: Vec<u8>,
307    osc_data: Vec<u8>,
308    // Modes
309    alternate_screen: bool,
310    alternate_grid: Option<Vec<VCell>>,
311    alternate_cursor: Option<(u16, u16)>,
312    // Title
313    title: String,
314    quirks: QuirkSet,
315    /// DECOM (DEC private mode 6): origin mode — cursor addressing relative
316    /// to scroll region.
317    origin_mode: bool,
318    /// Last printed character for REP (CSI b) support.
319    last_char: Option<char>,
320    /// UTF-8 accumulator for multi-byte character decoding.
321    utf8_buf: [u8; 4],
322    /// Number of bytes accumulated in `utf8_buf`.
323    utf8_len: u8,
324    /// Expected total bytes for current UTF-8 sequence.
325    utf8_expected: u8,
326    /// Tab stops — `tab_stops[col]` is true if col is a tab stop.
327    tab_stops: Vec<bool>,
328    /// IRM (Insert/Replace Mode, ANSI mode 4): when true, printed chars
329    /// insert (shift existing text right) instead of overwriting.
330    insert_mode: bool,
331    /// DECAWM (Auto-Wrap Mode, DEC private mode 7): when true, printing
332    /// past the right margin wraps to the next line. Default: true.
333    autowrap: bool,
334    /// Charset designators for G0–G3 slots. b'B' = ASCII, b'0' = DEC Special Graphics.
335    charset_slots: [u8; 4],
336    /// Active charset slot index (0 = G0, 1 = G1).
337    active_charset: u8,
338    /// Single-shift override: if Some(n), next printed char uses G<n> then reverts.
339    single_shift: Option<u8>,
340}
341
342impl VirtualTerminal {
343    /// Create a new virtual terminal with the given dimensions.
344    ///
345    /// # Panics
346    ///
347    /// Panics if width or height is 0.
348    #[must_use]
349    pub fn new(width: u16, height: u16) -> Self {
350        Self::with_quirks(width, height, QuirkSet::default())
351    }
352
353    /// Create a new virtual terminal with quirks enabled.
354    ///
355    /// # Panics
356    ///
357    /// Panics if width or height is 0.
358    #[must_use]
359    pub fn with_quirks(width: u16, height: u16, quirks: QuirkSet) -> Self {
360        assert!(width > 0 && height > 0, "terminal dimensions must be > 0");
361        let grid = vec![VCell::default(); usize::from(width) * usize::from(height)];
362        Self {
363            width,
364            height,
365            grid,
366            cursor_x: 0,
367            cursor_y: 0,
368            cursor_visible: true,
369            current_style: CellStyle::default(),
370            scrollback: VecDeque::new(),
371            max_scrollback: 1000,
372            saved_cursor: None,
373            scroll_top: 0,
374            scroll_bottom: height.saturating_sub(1),
375            parse_state: ParseState::Ground,
376            csi_params: Vec::new(),
377            csi_intermediate: Vec::new(),
378            osc_data: Vec::new(),
379            alternate_screen: false,
380            alternate_grid: None,
381            alternate_cursor: None,
382            title: String::new(),
383            quirks,
384            origin_mode: false,
385            last_char: None,
386            utf8_buf: [0; 4],
387            utf8_len: 0,
388            utf8_expected: 0,
389            tab_stops: Self::default_tab_stops(width),
390            insert_mode: false,
391            autowrap: true,
392            charset_slots: [b'B'; 4],
393            active_charset: 0,
394            single_shift: None,
395        }
396    }
397
398    /// Build default tab stops every 8 columns.
399    fn default_tab_stops(width: u16) -> Vec<bool> {
400        (0..width).map(|c| c > 0 && c % 8 == 0).collect()
401    }
402
403    // ── Dimensions & Cursor ─────────────────────────────────────────
404
405    /// Terminal width in columns.
406    #[must_use]
407    pub const fn width(&self) -> u16 {
408        self.width
409    }
410
411    /// Terminal height in rows.
412    #[must_use]
413    pub const fn height(&self) -> u16 {
414        self.height
415    }
416
417    /// Current cursor position (x, y), 0-indexed.
418    #[must_use]
419    pub const fn cursor(&self) -> (u16, u16) {
420        (self.cursor_x, self.cursor_y)
421    }
422
423    /// Whether the cursor is currently visible.
424    #[must_use]
425    pub const fn cursor_visible(&self) -> bool {
426        self.cursor_visible
427    }
428
429    /// Whether alternate screen mode is active.
430    #[must_use]
431    pub const fn is_alternate_screen(&self) -> bool {
432        self.alternate_screen
433    }
434
435    /// Current window title (set via OSC 0/2).
436    #[must_use]
437    pub fn title(&self) -> &str {
438        &self.title
439    }
440
441    /// Active quirk set.
442    #[must_use]
443    pub const fn quirks(&self) -> QuirkSet {
444        self.quirks
445    }
446
447    /// Override the active quirk set.
448    pub fn set_quirks(&mut self, quirks: QuirkSet) {
449        self.quirks = quirks;
450    }
451
452    /// Number of lines in the scrollback buffer.
453    #[must_use]
454    pub fn scrollback_len(&self) -> usize {
455        self.scrollback.len()
456    }
457
458    /// Set the maximum scrollback lines.
459    pub fn set_max_scrollback(&mut self, max: usize) {
460        self.max_scrollback = max;
461        while self.scrollback.len() > self.max_scrollback {
462            self.scrollback.pop_front();
463        }
464    }
465
466    // ── Cell Access ─────────────────────────────────────────────────
467
468    /// Get the character at (x, y). Returns `None` if out of bounds.
469    #[must_use]
470    pub fn char_at(&self, x: u16, y: u16) -> Option<char> {
471        self.cell_at(x, y).map(|c| c.ch)
472    }
473
474    /// Get the style at (x, y). Returns `None` if out of bounds.
475    #[must_use]
476    pub fn style_at(&self, x: u16, y: u16) -> Option<&CellStyle> {
477        self.cell_at(x, y).map(|c| &c.style)
478    }
479
480    /// Get a reference to the cell at (x, y). Returns `None` if out of bounds.
481    #[must_use]
482    pub fn cell_at(&self, x: u16, y: u16) -> Option<&VCell> {
483        if x < self.width && y < self.height {
484            Some(&self.grid[self.idx(x, y)])
485        } else {
486            None
487        }
488    }
489
490    /// Get the text content of a row (trailing spaces trimmed).
491    #[must_use]
492    pub fn row_text(&self, y: u16) -> String {
493        if y >= self.height {
494            return String::new();
495        }
496        let start = self.idx(0, y);
497        let end = start + usize::from(self.width);
498        let s: String = self.grid[start..end]
499            .iter()
500            .filter(|c| c.ch != WIDE_CONTINUATION)
501            .map(|c| c.ch)
502            .collect();
503        s.trim_end().to_string()
504    }
505
506    /// Get all visible text as a string (rows separated by newlines).
507    #[must_use]
508    pub fn screen_text(&self) -> String {
509        (0..self.height)
510            .map(|y| self.row_text(y))
511            .collect::<Vec<_>>()
512            .join("\n")
513    }
514
515    /// Get a scrollback line by index (0 = oldest).
516    #[must_use]
517    pub fn scrollback_line(&self, idx: usize) -> Option<String> {
518        self.scrollback.get(idx).map(|cells| {
519            let s: String = cells
520                .iter()
521                .filter(|c| c.ch != WIDE_CONTINUATION)
522                .map(|c| c.ch)
523                .collect();
524            s.trim_end().to_string()
525        })
526    }
527
528    // ── Input Processing ────────────────────────────────────────────
529
530    /// Feed raw bytes into the terminal (ANSI-aware).
531    pub fn feed(&mut self, data: &[u8]) {
532        for &byte in data {
533            self.process_byte(byte);
534        }
535    }
536
537    /// Feed a string into the terminal.
538    pub fn feed_str(&mut self, s: &str) {
539        self.feed(s.as_bytes());
540    }
541
542    // ── Higher-level Write API ──────────────────────────────────────
543
544    /// Write a plain-text string to the terminal.
545    ///
546    /// Each character is fed through [`put_char`](Self::put_char), which
547    /// handles auto-wrap, wide characters, insert mode, and charset
548    /// translation — but **no** ANSI escape sequences are interpreted.
549    /// Use this when you have pre-sanitized text and want deterministic
550    /// character-level output.
551    ///
552    /// # Example
553    ///
554    /// ```
555    /// use ftui_pty::virtual_terminal::VirtualTerminal;
556    ///
557    /// let mut vt = VirtualTerminal::new(10, 1);
558    /// vt.put_str("Hello");
559    /// assert_eq!(vt.row_text(0), "Hello");
560    /// assert_eq!(vt.cursor(), (5, 0));
561    /// ```
562    pub fn put_str(&mut self, s: &str) {
563        for ch in s.chars() {
564            self.put_char(ch);
565        }
566    }
567
568    /// Move the cursor to an absolute position, clamped to terminal bounds.
569    ///
570    /// Coordinates are 0-indexed. Out-of-range values are clamped:
571    /// `x` to `width - 1`, `y` to `height - 1`.
572    ///
573    /// # Example
574    ///
575    /// ```
576    /// use ftui_pty::virtual_terminal::VirtualTerminal;
577    ///
578    /// let mut vt = VirtualTerminal::new(80, 24);
579    /// vt.set_cursor_position(5, 10);
580    /// assert_eq!(vt.cursor(), (5, 10));
581    ///
582    /// // Out-of-range values are clamped.
583    /// vt.set_cursor_position(999, 999);
584    /// assert_eq!(vt.cursor(), (79, 23));
585    /// ```
586    pub fn set_cursor_position(&mut self, x: u16, y: u16) {
587        self.cursor_x = x.min(self.width.saturating_sub(1));
588        self.cursor_y = y.min(self.height.saturating_sub(1));
589    }
590
591    /// Clear the entire visible display, filling every cell with the
592    /// current style's background.
593    ///
594    /// This is the programmatic equivalent of `ESC[2J`. The cursor
595    /// position is **not** changed. Scrollback is not affected — use
596    /// [`clear_scrollback`](Self::clear_scrollback) for that.
597    ///
598    /// # Example
599    ///
600    /// ```
601    /// use ftui_pty::virtual_terminal::VirtualTerminal;
602    ///
603    /// let mut vt = VirtualTerminal::new(5, 1);
604    /// vt.put_str("Hello");
605    /// vt.clear();
606    /// assert_eq!(vt.row_text(0), "");
607    /// ```
608    pub fn clear(&mut self) {
609        let blank = self.styled_blank();
610        for cell in &mut self.grid {
611            *cell = blank.clone();
612        }
613    }
614
615    /// Clear the scrollback buffer.
616    ///
617    /// This removes all lines that have scrolled off the visible area.
618    /// The visible display is not affected.
619    pub fn clear_scrollback(&mut self) {
620        self.scrollback.clear();
621    }
622
623    // ── Query Responses ─────────────────────────────────────────────
624
625    /// Generate a cursor position report (CPR) response.
626    /// Format: `ESC [ Py ; Px R` (1-indexed).
627    #[must_use]
628    pub fn cpr_response(&self) -> Vec<u8> {
629        format!("\x1b[{};{}R", self.cursor_y + 1, self.cursor_x + 1).into_bytes()
630    }
631
632    /// Generate a device attributes (DA1) response.
633    /// Reports as a VT220 with ANSI color.
634    #[must_use]
635    pub fn da1_response(&self) -> Vec<u8> {
636        b"\x1b[?62;22c".to_vec()
637    }
638
639    // ── Internal ────────────────────────────────────────────────────
640
641    fn idx(&self, x: u16, y: u16) -> usize {
642        usize::from(y) * usize::from(self.width) + usize::from(x)
643    }
644
645    fn process_byte(&mut self, byte: u8) {
646        match self.parse_state {
647            ParseState::Ground => self.ground(byte),
648            ParseState::Escape => self.escape(byte),
649            ParseState::EscapeHash => self.escape_hash(byte),
650            ParseState::EscapeCharset(slot) => self.escape_charset(slot, byte),
651            ParseState::Csi => self.csi(byte),
652            ParseState::Osc => self.osc(byte),
653            ParseState::OscEscapeSeen => self.osc_escape_seen(byte),
654        }
655    }
656
657    fn ground(&mut self, byte: u8) {
658        match byte {
659            0x1b => {
660                self.parse_state = ParseState::Escape;
661            }
662            b'\n' | b'\x0b' | b'\x0c' => {
663                // LF, VT, FF — all treated as line feed
664                self.linefeed();
665            }
666            b'\r' => {
667                self.cursor_x = 0;
668            }
669            b'\x08' => {
670                // Backspace
671                self.cursor_x = self.cursor_x.saturating_sub(1);
672            }
673            b'\t' => {
674                // Tab: advance to next tab stop
675                if self.cursor_x >= self.width {
676                    self.cursor_x = self.width.saturating_sub(1);
677                } else {
678                    let max_col = self.width.saturating_sub(1);
679                    let mut col = self.cursor_x + 1;
680                    while col < self.width {
681                        if self.tab_stops[usize::from(col)] {
682                            break;
683                        }
684                        col += 1;
685                    }
686                    self.cursor_x = col.min(max_col);
687                }
688            }
689            b'\x07' => {
690                // Bell: ignored
691            }
692            b'\x0e' => {
693                // SO: Shift Out — activate G1 charset
694                self.active_charset = 1;
695            }
696            b'\x0f' => {
697                // SI: Shift In — activate G0 charset
698                self.active_charset = 0;
699            }
700            0x20..=0x7e => {
701                self.put_char(byte as char);
702            }
703            0xc2..=0xdf => {
704                // UTF-8 2-byte lead
705                self.utf8_buf[0] = byte;
706                self.utf8_len = 1;
707                self.utf8_expected = 2;
708            }
709            0xe0..=0xef => {
710                // UTF-8 3-byte lead
711                self.utf8_buf[0] = byte;
712                self.utf8_len = 1;
713                self.utf8_expected = 3;
714            }
715            0xf0..=0xf4 => {
716                // UTF-8 4-byte lead
717                self.utf8_buf[0] = byte;
718                self.utf8_len = 1;
719                self.utf8_expected = 4;
720            }
721            0x80..=0xbf if self.utf8_len > 0 => {
722                // UTF-8 continuation byte
723                let idx = usize::from(self.utf8_len);
724                self.utf8_buf[idx] = byte;
725                self.utf8_len += 1;
726                if self.utf8_len == self.utf8_expected {
727                    let len = usize::from(self.utf8_len);
728                    let mut buf = [0u8; 4];
729                    buf[..len].copy_from_slice(&self.utf8_buf[..len]);
730                    self.utf8_len = 0;
731                    self.utf8_expected = 0;
732                    if let Ok(decoded) = std::str::from_utf8(&buf[..len]) {
733                        for ch in decoded.chars() {
734                            self.put_char(ch);
735                        }
736                    }
737                }
738            }
739            _ => {
740                // Invalid sequence or control char: reset UTF-8 accumulator
741                self.utf8_len = 0;
742                self.utf8_expected = 0;
743            }
744        }
745    }
746
747    fn escape(&mut self, byte: u8) {
748        match byte {
749            b'[' => {
750                self.parse_state = ParseState::Csi;
751                self.csi_params.clear();
752                self.csi_intermediate.clear();
753            }
754            b']' => {
755                self.parse_state = ParseState::Osc;
756                self.osc_data.clear();
757            }
758            b'7' => {
759                // DEC save cursor
760                if !(self.quirks.tmux_nested_cursor && self.alternate_screen) {
761                    self.saved_cursor = Some((self.cursor_x, self.cursor_y));
762                }
763                self.parse_state = ParseState::Ground;
764            }
765            b'8' => {
766                // DEC restore cursor
767                if !(self.quirks.tmux_nested_cursor && self.alternate_screen)
768                    && let Some((x, y)) = self.saved_cursor
769                {
770                    self.cursor_x = x.min(self.width.saturating_sub(1));
771                    self.cursor_y = y.min(self.height.saturating_sub(1));
772                }
773                self.parse_state = ParseState::Ground;
774            }
775            b'H' => {
776                // HTS: set tab stop at current cursor column
777                let col = usize::from(self.cursor_x);
778                if col < self.tab_stops.len() {
779                    self.tab_stops[col] = true;
780                }
781                self.parse_state = ParseState::Ground;
782            }
783            b'D' => {
784                // Index (scroll up)
785                self.linefeed();
786                self.parse_state = ParseState::Ground;
787            }
788            b'E' => {
789                // Next Line (NEL): CR + LF
790                self.cursor_x = 0;
791                self.linefeed();
792                self.parse_state = ParseState::Ground;
793            }
794            b'M' => {
795                // Reverse index (scroll down)
796                if self.cursor_y == self.scroll_top {
797                    self.scroll_down();
798                } else {
799                    self.cursor_y = self.cursor_y.saturating_sub(1);
800                }
801                self.parse_state = ParseState::Ground;
802            }
803            b'#' => {
804                // ESC # — enter hash sub-state for DECALN etc.
805                self.parse_state = ParseState::EscapeHash;
806            }
807            b'(' => self.parse_state = ParseState::EscapeCharset(0), // G0
808            b')' => self.parse_state = ParseState::EscapeCharset(1), // G1
809            b'*' => self.parse_state = ParseState::EscapeCharset(2), // G2
810            b'+' => self.parse_state = ParseState::EscapeCharset(3), // G3
811            b'N' => {
812                // Single Shift 2 (SS2): next char from G2
813                self.single_shift = Some(2);
814                self.parse_state = ParseState::Ground;
815            }
816            b'O' => {
817                // Single Shift 3 (SS3): next char from G3
818                self.single_shift = Some(3);
819                self.parse_state = ParseState::Ground;
820            }
821            b'n' => {
822                // Locking Shift 2 (LS2): invoke G2 into GL
823                self.active_charset = 2;
824                self.parse_state = ParseState::Ground;
825            }
826            b'o' => {
827                // Locking Shift 3 (LS3): invoke G3 into GL
828                self.active_charset = 3;
829                self.parse_state = ParseState::Ground;
830            }
831            b'c' => {
832                // Full reset (RIS)
833                self.reset();
834                self.parse_state = ParseState::Ground;
835            }
836            _ => {
837                // Unknown escape: return to ground
838                self.parse_state = ParseState::Ground;
839            }
840        }
841    }
842
843    fn escape_hash(&mut self, byte: u8) {
844        if byte == b'8' {
845            // DECALN: fill entire screen with 'E', reset scroll region, cursor to origin.
846            for cell in self.grid.iter_mut() {
847                *cell = VCell {
848                    ch: 'E',
849                    style: CellStyle::default(),
850                };
851            }
852            self.scroll_top = 0;
853            self.scroll_bottom = self.height.saturating_sub(1);
854            self.cursor_x = 0;
855            self.cursor_y = 0;
856        }
857        self.parse_state = ParseState::Ground;
858    }
859
860    fn escape_charset(&mut self, slot: u8, byte: u8) {
861        // The designator byte selects the charset: b'B' = ASCII, b'0' = DEC Special Graphics, etc.
862        let idx = (slot as usize).min(3);
863        self.charset_slots[idx] = byte;
864        self.parse_state = ParseState::Ground;
865    }
866
867    fn csi(&mut self, byte: u8) {
868        match byte {
869            b'0'..=b'9' => {
870                let digit = u16::from(byte - b'0');
871                if let Some(last) = self.csi_params.last_mut() {
872                    *last = last.saturating_mul(10).saturating_add(digit);
873                } else if self.csi_params.len() < 32 {
874                    self.csi_params.push(digit);
875                }
876            }
877            b';' => {
878                if self.csi_params.len() < 32 {
879                    if self.csi_params.is_empty() {
880                        self.csi_params.push(0);
881                    }
882                    self.csi_params.push(0);
883                }
884            }
885            b'?' | b'>' | b'!' | b' ' => {
886                if self.csi_intermediate.len() < 16 {
887                    self.csi_intermediate.push(byte);
888                }
889            }
890            0x40..=0x7e => {
891                // Final byte — dispatch
892                self.dispatch_csi(byte);
893                self.parse_state = ParseState::Ground;
894            }
895            _ => {
896                // Invalid: abort CSI
897                self.parse_state = ParseState::Ground;
898            }
899        }
900    }
901
902    fn osc(&mut self, byte: u8) {
903        match byte {
904            0x07 => {
905                // BEL terminates OSC
906                self.dispatch_osc();
907                self.parse_state = ParseState::Ground;
908            }
909            0x1b => {
910                // Could be ST (\x1b\\) — transition to intermediate state
911                // to wait for the backslash.
912                self.parse_state = ParseState::OscEscapeSeen;
913            }
914            _ => {
915                // Limit OSC data to prevent memory exhaustion
916                if self.osc_data.len() < 4096 {
917                    self.osc_data.push(byte);
918                }
919            }
920        }
921    }
922
923    /// Handle byte after ESC was seen inside an OSC sequence.
924    fn osc_escape_seen(&mut self, byte: u8) {
925        match byte {
926            b'\\' => {
927                // ESC + \ = ST (String Terminator) — end the OSC.
928                self.dispatch_osc();
929                self.parse_state = ParseState::Ground;
930            }
931            _ => {
932                // ESC followed by something other than \ — dispatch OSC
933                // and re-enter the Escape state to handle this byte.
934                self.dispatch_osc();
935                self.parse_state = ParseState::Escape;
936                self.escape(byte);
937            }
938        }
939    }
940
941    fn dispatch_csi(&mut self, final_byte: u8) {
942        let params = &self.csi_params;
943        let has_question = self.csi_intermediate.contains(&b'?');
944
945        match final_byte {
946            b'A' => {
947                // Cursor Up
948                let n = Self::param(params, 0, 1);
949                self.cursor_y = self.cursor_y.saturating_sub(n);
950            }
951            b'B' => {
952                // Cursor Down
953                let n = Self::param(params, 0, 1);
954                self.cursor_y = (self.cursor_y + n).min(self.height.saturating_sub(1));
955            }
956            b'C' => {
957                // Cursor Forward
958                let n = Self::param(params, 0, 1);
959                self.cursor_x = (self.cursor_x + n).min(self.width.saturating_sub(1));
960            }
961            b'D' => {
962                // Cursor Back
963                let n = Self::param(params, 0, 1);
964                self.cursor_x = self.cursor_x.saturating_sub(n);
965            }
966            b'E' => {
967                // Cursor Next Line
968                let n = Self::param(params, 0, 1);
969                self.cursor_y = (self.cursor_y + n).min(self.height.saturating_sub(1));
970                self.cursor_x = 0;
971            }
972            b'F' => {
973                // Cursor Previous Line
974                let n = Self::param(params, 0, 1);
975                self.cursor_y = self.cursor_y.saturating_sub(n);
976                self.cursor_x = 0;
977            }
978            b'G' => {
979                // Cursor Horizontal Absolute (1-indexed)
980                let col = Self::param(params, 0, 1).saturating_sub(1);
981                self.cursor_x = col.min(self.width.saturating_sub(1));
982            }
983            b'H' | b'f' => {
984                // Cursor Position (1-indexed)
985                let row = Self::param(params, 0, 1).saturating_sub(1);
986                let col = Self::param(params, 1, 1).saturating_sub(1);
987                if self.origin_mode {
988                    let abs_row = row.saturating_add(self.scroll_top);
989                    self.cursor_y = abs_row.min(self.scroll_bottom);
990                } else {
991                    self.cursor_y = row.min(self.height.saturating_sub(1));
992                }
993                self.cursor_x = col.min(self.width.saturating_sub(1));
994            }
995            b'J' => {
996                // Erase in Display
997                let mode = Self::param(params, 0, 0);
998                self.erase_display(mode);
999            }
1000            b'K' => {
1001                // Erase in Line
1002                let mode = Self::param(params, 0, 0);
1003                self.erase_line(mode);
1004            }
1005            b'L' => {
1006                // Insert Lines (IL) — insert blank lines at cursor row, pushing down
1007                let n = Self::param(params, 0, 1);
1008                if self.cursor_y >= self.scroll_top && self.cursor_y <= self.scroll_bottom {
1009                    let blank = self.styled_blank();
1010                    for _ in 0..n {
1011                        // Shift lines down from cursor_y to scroll_bottom
1012                        for row in (self.cursor_y + 1..=self.scroll_bottom).rev() {
1013                            let src_start = self.idx(0, row - 1);
1014                            let dst_start = self.idx(0, row);
1015                            let w = usize::from(self.width);
1016                            if src_start < dst_start {
1017                                let (left, right) = self.grid.split_at_mut(dst_start);
1018                                right[..w].clone_from_slice(&left[src_start..src_start + w]);
1019                            }
1020                        }
1021                        // Clear the line at cursor_y
1022                        let row_start = self.idx(0, self.cursor_y);
1023                        for i in 0..usize::from(self.width) {
1024                            self.grid[row_start + i] = blank.clone();
1025                        }
1026                    }
1027                }
1028            }
1029            b'M' => {
1030                // Delete Lines (DL) — delete lines at cursor row, pulling up
1031                let n = Self::param(params, 0, 1);
1032                if self.cursor_y >= self.scroll_top && self.cursor_y <= self.scroll_bottom {
1033                    let blank = self.styled_blank();
1034                    for _ in 0..n {
1035                        // Shift lines up from cursor_y to scroll_bottom
1036                        for row in self.cursor_y..self.scroll_bottom {
1037                            let src_start = self.idx(0, row + 1);
1038                            let dst_start = self.idx(0, row);
1039                            let w = usize::from(self.width);
1040                            let (left, right) = self.grid.split_at_mut(src_start);
1041                            left[dst_start..dst_start + w].clone_from_slice(&right[..w]);
1042                        }
1043                        // Clear the bottom line of scroll region
1044                        let bottom_start = self.idx(0, self.scroll_bottom);
1045                        for i in 0..usize::from(self.width) {
1046                            self.grid[bottom_start + i] = blank.clone();
1047                        }
1048                    }
1049                }
1050            }
1051            b'S' => {
1052                // Scroll Up
1053                let n = Self::param(params, 0, 1);
1054                for _ in 0..n {
1055                    self.scroll_up();
1056                }
1057            }
1058            b'T' => {
1059                // Scroll Down
1060                let n = Self::param(params, 0, 1);
1061                for _ in 0..n {
1062                    self.scroll_down();
1063                }
1064            }
1065            b'd' => {
1066                // Vertical Position Absolute (1-indexed)
1067                let row = Self::param(params, 0, 1).saturating_sub(1);
1068                if self.origin_mode {
1069                    let abs_row = row.saturating_add(self.scroll_top);
1070                    self.cursor_y = abs_row.min(self.scroll_bottom);
1071                } else {
1072                    self.cursor_y = row.min(self.height.saturating_sub(1));
1073                }
1074            }
1075            b'm' => {
1076                // SGR
1077                self.dispatch_sgr();
1078            }
1079            b'n' => {
1080                // Device Status Report (we track but don't auto-respond)
1081                // Response generated via cpr_response()
1082            }
1083            b'r' => {
1084                // Set Scrolling Region (DECSTBM, 1-indexed)
1085                let top = Self::param(params, 0, 1).saturating_sub(1);
1086                let bottom = Self::param(params, 1, self.height).saturating_sub(1);
1087                if top < bottom && bottom < self.height {
1088                    self.scroll_top = top;
1089                    self.scroll_bottom = bottom;
1090                }
1091                self.cursor_x = 0;
1092                if self.origin_mode {
1093                    self.cursor_y = self.scroll_top;
1094                } else {
1095                    self.cursor_y = 0;
1096                }
1097            }
1098            b'@' => {
1099                // Insert Characters (ICH) — shift chars right at cursor, insert blanks
1100                let n = Self::param(params, 0, 1);
1101                let n = n.min(self.width.saturating_sub(self.cursor_x));
1102                // Wide char fixup: clean up at cursor position before shift
1103                self.fixup_wide_erase_row(self.cursor_y, self.cursor_x, n);
1104                let row_start = self.idx(0, self.cursor_y);
1105                let w = usize::from(self.width);
1106                let cx = usize::from(self.cursor_x);
1107                let count = usize::from(n);
1108                // Wide char fixup: if the shift pushes a wide lead's continuation off-screen,
1109                // blank the lead (it would end up at w-count-1 after shift if continuation was at w-count)
1110                if count < w {
1111                    let cutoff = w - count;
1112                    if cutoff > 0
1113                        && cutoff < w
1114                        && self.grid[row_start + cutoff].ch == WIDE_CONTINUATION
1115                    {
1116                        self.grid[row_start + cutoff - 1] = VCell::default();
1117                    }
1118                }
1119                // Shift characters right within the row
1120                let blank = self.styled_blank();
1121                let row = &mut self.grid[row_start..row_start + w];
1122                row[cx..].rotate_right(count.min(w - cx));
1123                // Clear the inserted positions
1124                for cell in row.iter_mut().skip(cx).take(count.min(w - cx)) {
1125                    *cell = blank.clone();
1126                }
1127                // Post-shift fixup: if the cell right after the inserted blanks is
1128                // an orphaned WIDE_CONTINUATION (shifted from cursor pos), blank it
1129                if cx + count < w && row[cx + count].ch == WIDE_CONTINUATION {
1130                    row[cx + count] = blank.clone();
1131                }
1132            }
1133            b'P' => {
1134                // Delete Characters (DCH) — shift chars left at cursor, fill blanks at end
1135                let n = Self::param(params, 0, 1);
1136                let n = n.min(self.width.saturating_sub(self.cursor_x));
1137                // Wide char fixup at delete boundaries
1138                self.fixup_wide_erase_row(self.cursor_y, self.cursor_x, n);
1139                let blank = self.styled_blank();
1140                let row_start = self.idx(0, self.cursor_y);
1141                let w = usize::from(self.width);
1142                let cx = usize::from(self.cursor_x);
1143                let count = usize::from(n);
1144                // Shift characters left within the row
1145                let row = &mut self.grid[row_start..row_start + w];
1146                row[cx..].rotate_left(count.min(w - cx));
1147                // Clear the vacated positions at end
1148                for cell in row.iter_mut().skip(w - count.min(w - cx)) {
1149                    *cell = blank.clone();
1150                }
1151            }
1152            b'X' => {
1153                // Erase Characters (ECH) — erase N chars from cursor without moving cursor
1154                let n = Self::param(params, 0, 1);
1155                let n = n.min(self.width.saturating_sub(self.cursor_x));
1156                self.fixup_wide_erase_row(self.cursor_y, self.cursor_x, n);
1157                let blank = self.styled_blank();
1158                let start = self.idx(self.cursor_x, self.cursor_y);
1159                for i in 0..usize::from(n) {
1160                    self.grid[start + i] = blank.clone();
1161                }
1162            }
1163            b'b' => {
1164                // Repeat Character (REP) — repeat last printed character N times
1165                let n = Self::param(params, 0, 1);
1166                if let Some(ch) = self.last_char {
1167                    for _ in 0..n {
1168                        self.put_char(ch);
1169                    }
1170                }
1171            }
1172            b'Z' => {
1173                // CBT: Cursor Backward Tabulation — move to previous tab stop
1174                let n = Self::param(params, 0, 1);
1175                for _ in 0..n {
1176                    if self.cursor_x == 0 {
1177                        break;
1178                    }
1179                    let mut col = self.cursor_x - 1;
1180                    loop {
1181                        if self.tab_stops[usize::from(col)] {
1182                            break;
1183                        }
1184                        if col == 0 {
1185                            break;
1186                        }
1187                        col -= 1;
1188                    }
1189                    self.cursor_x = col;
1190                }
1191            }
1192            b'g' => {
1193                // TBC: Tab Clear
1194                let mode = Self::param(params, 0, 0);
1195                match mode {
1196                    0 => {
1197                        // Clear tab stop at current column
1198                        let col = usize::from(self.cursor_x);
1199                        if col < self.tab_stops.len() {
1200                            self.tab_stops[col] = false;
1201                        }
1202                    }
1203                    3 | 5 => {
1204                        // Clear all tab stops
1205                        self.tab_stops.fill(false);
1206                    }
1207                    _ => {}
1208                }
1209            }
1210            b'p' if self.csi_intermediate.contains(&b'!') => {
1211                // Soft Reset (DECSTR) — CSI ! p
1212                self.current_style = CellStyle::default();
1213                self.cursor_visible = true;
1214                self.origin_mode = false;
1215                self.scroll_top = 0;
1216                self.scroll_bottom = self.height.saturating_sub(1);
1217                self.insert_mode = false;
1218                self.autowrap = true;
1219                self.charset_slots = [b'B'; 4];
1220                self.active_charset = 0;
1221                self.single_shift = None;
1222            }
1223            b'h' if has_question => {
1224                // DEC Private Mode Set
1225                let modes: Vec<u16> = self.csi_params.clone();
1226                for p in modes {
1227                    self.set_dec_mode(p, true);
1228                }
1229            }
1230            b'l' if has_question => {
1231                // DEC Private Mode Reset
1232                let modes: Vec<u16> = self.csi_params.clone();
1233                for p in modes {
1234                    self.set_dec_mode(p, false);
1235                }
1236            }
1237            b'h' if !has_question => {
1238                // ANSI Mode Set
1239                let modes: Vec<u16> = self.csi_params.clone();
1240                for p in modes {
1241                    self.set_ansi_mode(p, true);
1242                }
1243            }
1244            b'l' if !has_question => {
1245                // ANSI Mode Reset
1246                let modes: Vec<u16> = self.csi_params.clone();
1247                for p in modes {
1248                    self.set_ansi_mode(p, false);
1249                }
1250            }
1251            _ => {
1252                // Unknown CSI: ignored
1253            }
1254        }
1255    }
1256
1257    fn dispatch_sgr(&mut self) {
1258        if self.csi_params.is_empty() {
1259            self.current_style.reset();
1260            return;
1261        }
1262
1263        let params = self.csi_params.clone();
1264        let mut i = 0;
1265        while i < params.len() {
1266            match params[i] {
1267                0 => self.current_style.reset(),
1268                1 => self.current_style.bold = true,
1269                2 => self.current_style.dim = true,
1270                3 => self.current_style.italic = true,
1271                4 => self.current_style.underline = true,
1272                5 => self.current_style.blink = true,
1273                7 => self.current_style.reverse = true,
1274                8 => self.current_style.hidden = true,
1275                9 => self.current_style.strikethrough = true,
1276                22 => {
1277                    self.current_style.bold = false;
1278                    self.current_style.dim = false;
1279                }
1280                23 => self.current_style.italic = false,
1281                24 => self.current_style.underline = false,
1282                25 => self.current_style.blink = false,
1283                27 => self.current_style.reverse = false,
1284                28 => self.current_style.hidden = false,
1285                29 => self.current_style.strikethrough = false,
1286                53 => self.current_style.overline = true,
1287                55 => self.current_style.overline = false,
1288                // Standard foreground colors
1289                30..=37 => {
1290                    self.current_style.fg = Some(ansi_color(params[i] - 30));
1291                }
1292                38 => {
1293                    // Extended foreground
1294                    if let Some(color) = parse_extended_color(&params, &mut i) {
1295                        self.current_style.fg = Some(color);
1296                    }
1297                }
1298                39 => self.current_style.fg = None,
1299                // Standard background colors
1300                40..=47 => {
1301                    self.current_style.bg = Some(ansi_color(params[i] - 40));
1302                }
1303                48 => {
1304                    // Extended background
1305                    if let Some(color) = parse_extended_color(&params, &mut i) {
1306                        self.current_style.bg = Some(color);
1307                    }
1308                }
1309                49 => self.current_style.bg = None,
1310                // Bright foreground colors
1311                90..=97 => {
1312                    self.current_style.fg = Some(ansi_bright_color(params[i] - 90));
1313                }
1314                // Bright background colors
1315                100..=107 => {
1316                    self.current_style.bg = Some(ansi_bright_color(params[i] - 100));
1317                }
1318                _ => {} // Unknown SGR param: ignored
1319            }
1320            i += 1;
1321        }
1322    }
1323
1324    fn dispatch_osc(&mut self) {
1325        let data = String::from_utf8_lossy(&self.osc_data).to_string();
1326        if let Some(rest) = data.strip_prefix("0;").or_else(|| data.strip_prefix("2;")) {
1327            self.title = rest.to_string();
1328        }
1329        // Other OSC codes (8 for hyperlinks, etc.) can be added later
1330    }
1331
1332    fn set_dec_mode(&mut self, mode: u16, enable: bool) {
1333        match mode {
1334            6 => {
1335                // DECOM: origin mode — cursor addressing relative to scroll region.
1336                self.origin_mode = enable;
1337                // Enabling DECOM homes cursor to top of scroll region;
1338                // disabling homes to (0,0).
1339                if enable {
1340                    self.cursor_x = 0;
1341                    self.cursor_y = self.scroll_top;
1342                } else {
1343                    self.cursor_x = 0;
1344                    self.cursor_y = 0;
1345                }
1346            }
1347            7 => self.autowrap = enable,
1348            25 => self.cursor_visible = enable,
1349            1049 => {
1350                // Alternate screen buffer
1351                if self.quirks.windows_no_alt_screen {
1352                    return;
1353                }
1354                if enable && !self.alternate_screen {
1355                    self.alternate_grid = Some(std::mem::replace(
1356                        &mut self.grid,
1357                        vec![VCell::default(); usize::from(self.width) * usize::from(self.height)],
1358                    ));
1359                    self.alternate_cursor = Some((self.cursor_x, self.cursor_y));
1360                    self.cursor_x = 0;
1361                    self.cursor_y = 0;
1362                    self.alternate_screen = true;
1363                } else if !enable && self.alternate_screen {
1364                    if let Some(main_grid) = self.alternate_grid.take() {
1365                        self.grid = main_grid;
1366                    }
1367                    if let Some((x, y)) = self.alternate_cursor.take() {
1368                        self.cursor_x = x;
1369                        self.cursor_y = y;
1370                    }
1371                    self.alternate_screen = false;
1372                }
1373            }
1374            1047 => {
1375                // Alternate screen (without save/restore cursor)
1376                if self.quirks.windows_no_alt_screen {
1377                    return;
1378                }
1379                if enable && !self.alternate_screen {
1380                    self.alternate_grid = Some(std::mem::replace(
1381                        &mut self.grid,
1382                        vec![VCell::default(); usize::from(self.width) * usize::from(self.height)],
1383                    ));
1384                    self.alternate_screen = true;
1385                } else if !enable && self.alternate_screen {
1386                    if let Some(main_grid) = self.alternate_grid.take() {
1387                        self.grid = main_grid;
1388                    }
1389                    self.alternate_screen = false;
1390                }
1391            }
1392            _ => {
1393                // Other DEC modes: ignored (1000/1002/1006 mouse, 2004 paste, etc.)
1394            }
1395        }
1396    }
1397
1398    fn set_ansi_mode(&mut self, mode: u16, enable: bool) {
1399        if mode == 4 {
1400            self.insert_mode = enable;
1401        }
1402    }
1403
1404    /// Place a single character at the current cursor position, applying all
1405    /// terminal output logic: charset translation, Unicode width detection,
1406    /// auto-wrap (DECAWM), wide-character handling, insert mode (IRM), and
1407    /// cursor advancement.
1408    ///
1409    /// This is the character-level entry point that [`feed`](Self::feed) and
1410    /// [`feed_str`](Self::feed_str) use internally after ANSI/UTF-8 parsing.
1411    /// Call it directly when you already have decoded characters and want the
1412    /// terminal to handle wrapping, widths, and cursor movement.
1413    ///
1414    /// Zero-width characters (combining marks, ZWJ) are silently skipped.
1415    ///
1416    /// # Example
1417    ///
1418    /// ```
1419    /// use ftui_pty::virtual_terminal::VirtualTerminal;
1420    ///
1421    /// let mut vt = VirtualTerminal::new(10, 3);
1422    /// vt.put_char('H');
1423    /// vt.put_char('i');
1424    /// assert_eq!(vt.row_text(0), "Hi");
1425    /// assert_eq!(vt.cursor(), (2, 0));
1426    /// ```
1427    pub fn put_char(&mut self, ch: char) {
1428        // Charset translation: resolve effective charset and translate
1429        let designator = if let Some(shift) = self.single_shift {
1430            let slot = (shift as usize).min(3);
1431            self.single_shift = None;
1432            self.charset_slots[slot]
1433        } else {
1434            self.charset_slots[(self.active_charset as usize) & 3]
1435        };
1436        let ch = translate_charset(ch, designator);
1437
1438        let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
1439        if char_width == 0 {
1440            return; // zero-width (combining marks, ZWJ): skip
1441        }
1442
1443        // Auto-wrap: if cursor is past right margin and autowrap is on, wrap
1444        if self.cursor_x >= self.width {
1445            if self.autowrap {
1446                self.cursor_x = 0;
1447                self.linefeed();
1448            } else {
1449                // No auto-wrap: clamp to last column, overwrite in place
1450                self.cursor_x = self.width.saturating_sub(1);
1451            }
1452        }
1453
1454        // Wide char at last column → wrap first (only if autowrap)
1455        if char_width == 2 && self.cursor_x + 1 >= self.width {
1456            if self.autowrap {
1457                let idx = self.idx(self.cursor_x, self.cursor_y);
1458                self.grid[idx] = VCell::default();
1459                self.cursor_x = 0;
1460                self.linefeed();
1461            } else {
1462                // No wrap: clamp to last column
1463                self.cursor_x = self.width.saturating_sub(1);
1464            }
1465        }
1466
1467        let last_col = self.width.saturating_sub(1);
1468        let immediate_wrap = self.quirks.screen_immediate_wrap && self.cursor_x == last_col;
1469        let idx = self.idx(self.cursor_x, self.cursor_y);
1470
1471        // IRM: insert mode — shift existing chars right before placing
1472        if self.insert_mode {
1473            let row_start = self.idx(0, self.cursor_y);
1474            let w = usize::from(self.width);
1475            let cx = usize::from(self.cursor_x);
1476            let shift = usize::from(u16::try_from(char_width).unwrap_or(1));
1477            let row = &mut self.grid[row_start..row_start + w];
1478            if cx + shift <= w {
1479                row[cx..].rotate_right(shift.min(w - cx));
1480            }
1481        }
1482
1483        // Fixup: overwriting a continuation → blank the lead
1484        if self.grid[idx].ch == WIDE_CONTINUATION && self.cursor_x > 0 {
1485            let lead_idx = self.idx(self.cursor_x - 1, self.cursor_y);
1486            self.grid[lead_idx] = VCell::default();
1487        }
1488        // Fixup: narrow char overwrites a wide lead → blank its continuation
1489        if char_width == 1 && self.cursor_x + 1 < self.width {
1490            let next_idx = idx + 1;
1491            if self.grid[next_idx].ch == WIDE_CONTINUATION {
1492                self.grid[next_idx] = VCell::default();
1493            }
1494        }
1495
1496        self.grid[idx] = VCell {
1497            ch,
1498            style: self.current_style.clone(),
1499        };
1500
1501        // Wide char: place continuation in next cell
1502        if char_width == 2 && self.cursor_x + 1 < self.width {
1503            let cont_idx = idx + 1;
1504            self.grid[cont_idx] = VCell {
1505                ch: WIDE_CONTINUATION,
1506                style: self.current_style.clone(),
1507            };
1508        }
1509
1510        self.last_char = Some(ch);
1511        let advance = u16::try_from(char_width).unwrap_or(1);
1512        if immediate_wrap {
1513            self.cursor_x = 0;
1514            self.linefeed();
1515        } else if self.autowrap {
1516            self.cursor_x += advance;
1517        } else {
1518            // No auto-wrap: clamp to last column
1519            self.cursor_x = (self.cursor_x + advance).min(self.width.saturating_sub(1));
1520        }
1521    }
1522
1523    fn linefeed(&mut self) {
1524        if self.cursor_y == self.scroll_bottom {
1525            self.scroll_up();
1526        } else if self.cursor_y < self.height.saturating_sub(1) {
1527            self.cursor_y += 1;
1528        }
1529    }
1530
1531    fn scroll_up(&mut self) {
1532        // Push the line scrolled out of the top of the active region into
1533        // scrollback, regardless of whether the region starts at row 0.
1534        let top_start = self.idx(0, self.scroll_top);
1535        let top_end = top_start + usize::from(self.width);
1536        let line: Vec<VCell> = self.grid[top_start..top_end].to_vec();
1537        self.scrollback.push_back(line);
1538        while self.scrollback.len() > self.max_scrollback {
1539            self.scrollback.pop_front();
1540        }
1541
1542        // Shift lines up within scroll region
1543        for row in self.scroll_top..self.scroll_bottom {
1544            let src_start = self.idx(0, row + 1);
1545            let dst_start = self.idx(0, row);
1546            let w = usize::from(self.width);
1547            // Copy within the same vec using split_at_mut pattern
1548            let (left, right) = self.grid.split_at_mut(src_start);
1549            left[dst_start..dst_start + w].clone_from_slice(&right[..w]);
1550        }
1551
1552        // Clear the bottom line of scroll region
1553        let blank = self.styled_blank();
1554        let bottom_start = self.idx(0, self.scroll_bottom);
1555        for i in 0..usize::from(self.width) {
1556            self.grid[bottom_start + i] = blank.clone();
1557        }
1558    }
1559
1560    fn scroll_down(&mut self) {
1561        // Shift lines down within scroll region
1562        for row in (self.scroll_top + 1..=self.scroll_bottom).rev() {
1563            let src_start = self.idx(0, row - 1);
1564            let dst_start = self.idx(0, row);
1565            let w = usize::from(self.width);
1566            if src_start < dst_start {
1567                let (left, right) = self.grid.split_at_mut(dst_start);
1568                right[..w].clone_from_slice(&left[src_start..src_start + w]);
1569            }
1570        }
1571
1572        // Clear the top line of scroll region
1573        let blank = self.styled_blank();
1574        let top_start = self.idx(0, self.scroll_top);
1575        for i in 0..usize::from(self.width) {
1576            self.grid[top_start + i] = blank.clone();
1577        }
1578    }
1579
1580    /// A blank cell carrying the current SGR attributes (bg color, etc.).
1581    /// Per VT spec, erase/edit operations fill blanks with the current style.
1582    fn styled_blank(&self) -> VCell {
1583        VCell {
1584            ch: ' ',
1585            style: self.current_style.clone(),
1586        }
1587    }
1588
1589    /// Clean up wide character boundaries before an erase/edit in a single row.
1590    /// Checks cells at the edges of the range `[start_col, start_col+count)` and
1591    /// blanks orphaned lead/continuation cells.
1592    fn fixup_wide_erase_row(&mut self, row_y: u16, start_col: u16, count: u16) {
1593        let w = self.width;
1594        let sc = start_col;
1595        let n = count;
1596        if n == 0 || sc >= w {
1597            return;
1598        }
1599        let row_start = self.idx(0, row_y);
1600        // If the first erased cell is a continuation, its lead is orphaned → blank lead
1601        if sc > 0 && self.grid[row_start + usize::from(sc)].ch == WIDE_CONTINUATION {
1602            self.grid[row_start + usize::from(sc - 1)] = VCell::default();
1603        }
1604        // If the cell just after the erased range is a continuation, it's orphaned → blank it
1605        let end_col = sc.saturating_add(n);
1606        if end_col < w && self.grid[row_start + usize::from(end_col)].ch == WIDE_CONTINUATION {
1607            self.grid[row_start + usize::from(end_col)] = VCell::default();
1608        }
1609    }
1610
1611    fn erase_display(&mut self, mode: u16) {
1612        let blank = self.styled_blank();
1613        match mode {
1614            0 => {
1615                // Erase from cursor to end
1616                let count = self.width.saturating_sub(self.cursor_x);
1617                self.fixup_wide_erase_row(self.cursor_y, self.cursor_x, count);
1618                let start = self.idx(self.cursor_x, self.cursor_y);
1619                for cell in &mut self.grid[start..] {
1620                    *cell = blank.clone();
1621                }
1622            }
1623            1 => {
1624                // Erase from start to cursor (inclusive)
1625                let count = self.cursor_x + 1;
1626                self.fixup_wide_erase_row(self.cursor_y, 0, count);
1627                let end = self.idx(self.cursor_x, self.cursor_y) + 1;
1628                for cell in &mut self.grid[..end] {
1629                    *cell = blank.clone();
1630                }
1631            }
1632            2 | 3 => {
1633                // Erase entire display (3 also clears scrollback)
1634                for cell in &mut self.grid {
1635                    *cell = blank.clone();
1636                }
1637                if mode == 3 {
1638                    self.scrollback.clear();
1639                }
1640            }
1641            _ => {}
1642        }
1643    }
1644
1645    fn erase_line(&mut self, mode: u16) {
1646        let y = self.cursor_y;
1647        let blank = self.styled_blank();
1648        let row_start = self.idx(0, y);
1649        match mode {
1650            0 => {
1651                // Erase from cursor to end of line
1652                let count = self.width.saturating_sub(self.cursor_x);
1653                self.fixup_wide_erase_row(y, self.cursor_x, count);
1654                let start = row_start + usize::from(self.cursor_x);
1655                let end = row_start + usize::from(self.width);
1656                for cell in &mut self.grid[start..end] {
1657                    *cell = blank.clone();
1658                }
1659            }
1660            1 => {
1661                // Erase from start to cursor (inclusive)
1662                let count = self.cursor_x + 1;
1663                self.fixup_wide_erase_row(y, 0, count);
1664                let end = row_start + usize::from(count);
1665                for cell in &mut self.grid[row_start..end] {
1666                    *cell = blank.clone();
1667                }
1668            }
1669            2 => {
1670                // Erase entire line (no boundary fixup needed — whole row)
1671                let end = row_start + usize::from(self.width);
1672                for cell in &mut self.grid[row_start..end] {
1673                    *cell = blank.clone();
1674                }
1675            }
1676            _ => {}
1677        }
1678    }
1679
1680    fn reset(&mut self) {
1681        self.grid = vec![VCell::default(); usize::from(self.width) * usize::from(self.height)];
1682        self.cursor_x = 0;
1683        self.cursor_y = 0;
1684        self.cursor_visible = true;
1685        self.current_style = CellStyle::default();
1686        self.scrollback.clear();
1687        self.saved_cursor = None;
1688        self.scroll_top = 0;
1689        self.scroll_bottom = self.height.saturating_sub(1);
1690        self.title.clear();
1691        self.alternate_screen = false;
1692        self.alternate_grid = None;
1693        self.alternate_cursor = None;
1694        self.last_char = None;
1695        self.utf8_len = 0;
1696        self.utf8_expected = 0;
1697        self.tab_stops = Self::default_tab_stops(self.width);
1698        self.insert_mode = false;
1699        self.autowrap = true;
1700        self.charset_slots = [b'B'; 4];
1701        self.active_charset = 0;
1702        self.single_shift = None;
1703    }
1704
1705    fn param(params: &[u16], idx: usize, default: u16) -> u16 {
1706        params
1707            .get(idx)
1708            .copied()
1709            .filter(|&v| v > 0)
1710            .unwrap_or(default)
1711    }
1712}
1713
1714// ── Color helpers ───────────────────────────────────────────────────
1715
1716fn ansi_color(idx: u16) -> Color {
1717    match idx {
1718        0 => Color::new(0, 0, 0),       // Black
1719        1 => Color::new(170, 0, 0),     // Red
1720        2 => Color::new(0, 170, 0),     // Green
1721        3 => Color::new(170, 170, 0),   // Yellow
1722        4 => Color::new(0, 0, 170),     // Blue
1723        5 => Color::new(170, 0, 170),   // Magenta
1724        6 => Color::new(0, 170, 170),   // Cyan
1725        7 => Color::new(170, 170, 170), // White
1726        _ => Color::default(),
1727    }
1728}
1729
1730fn ansi_bright_color(idx: u16) -> Color {
1731    match idx {
1732        0 => Color::new(85, 85, 85),    // Bright Black
1733        1 => Color::new(255, 85, 85),   // Bright Red
1734        2 => Color::new(85, 255, 85),   // Bright Green
1735        3 => Color::new(255, 255, 85),  // Bright Yellow
1736        4 => Color::new(85, 85, 255),   // Bright Blue
1737        5 => Color::new(255, 85, 255),  // Bright Magenta
1738        6 => Color::new(85, 255, 255),  // Bright Cyan
1739        7 => Color::new(255, 255, 255), // Bright White
1740        _ => Color::default(),
1741    }
1742}
1743
1744/// Parse extended color (38;2;r;g;b or 38;5;idx).
1745fn parse_extended_color(params: &[u16], i: &mut usize) -> Option<Color> {
1746    if *i + 1 >= params.len() {
1747        return None;
1748    }
1749    match params[*i + 1] {
1750        2 => {
1751            // Truecolor: 38;2;r;g;b
1752            if *i + 4 < params.len() {
1753                let r = params[*i + 2] as u8;
1754                let g = params[*i + 3] as u8;
1755                let b = params[*i + 4] as u8;
1756                *i += 4;
1757                Some(Color::new(r, g, b))
1758            } else {
1759                None
1760            }
1761        }
1762        5 => {
1763            // 256-color: 38;5;idx
1764            if *i + 2 < params.len() {
1765                let idx = params[*i + 2];
1766                *i += 2;
1767                Some(color_256(idx))
1768            } else {
1769                None
1770            }
1771        }
1772        _ => None,
1773    }
1774}
1775
1776/// Convert 256-color index to RGB.
1777fn color_256(idx: u16) -> Color {
1778    match idx {
1779        0..=7 => ansi_color(idx),
1780        8..=15 => ansi_bright_color(idx - 8),
1781        16..=231 => {
1782            // 6x6x6 color cube
1783            let n = idx - 16;
1784            let b = (n % 6) as u8;
1785            let g = ((n / 6) % 6) as u8;
1786            let r = (n / 36) as u8;
1787            let to_rgb = |v: u8| if v == 0 { 0u8 } else { 55 + 40 * v };
1788            Color::new(to_rgb(r), to_rgb(g), to_rgb(b))
1789        }
1790        232..=255 => {
1791            // Grayscale ramp
1792            let v = (8 + 10 * (idx - 232)) as u8;
1793            Color::new(v, v, v)
1794        }
1795        _ => Color::default(),
1796    }
1797}
1798
1799#[cfg(test)]
1800mod tests {
1801    use super::*;
1802
1803    fn assert_invariants(vt: &VirtualTerminal) {
1804        // cursor_x == width is valid: it's the "pending wrap" state
1805        assert!(vt.cursor_x <= vt.width);
1806        assert!(vt.cursor_y < vt.height);
1807        assert_eq!(vt.grid.len(), vt.width as usize * vt.height as usize);
1808        assert!(vt.scroll_top <= vt.scroll_bottom);
1809        assert!(vt.scroll_bottom < vt.height);
1810        for line in &vt.scrollback {
1811            assert_eq!(line.len(), vt.width as usize);
1812        }
1813    }
1814
1815    #[test]
1816    fn new_terminal_dimensions() {
1817        let vt = VirtualTerminal::new(80, 24);
1818        assert_eq!(vt.width(), 80);
1819        assert_eq!(vt.height(), 24);
1820        assert_eq!(vt.cursor(), (0, 0));
1821        assert!(vt.cursor_visible());
1822    }
1823
1824    #[test]
1825    #[should_panic(expected = "dimensions must be > 0")]
1826    fn zero_width_panics() {
1827        let _ = VirtualTerminal::new(0, 24);
1828    }
1829
1830    #[test]
1831    #[should_panic(expected = "dimensions must be > 0")]
1832    fn zero_height_panics() {
1833        let _ = VirtualTerminal::new(80, 0);
1834    }
1835
1836    #[test]
1837    fn invariants_hold_for_varied_inputs() {
1838        let inputs: [&[u8]; 6] = [
1839            b"",
1840            b"Hello",
1841            b"ABCDE\r\nFGHIJ",
1842            b"\x1b[2J",
1843            b"\x1b[1;1H\x1b[2;2H",
1844            b"\x1b[?1049hAlt\x1b[?1049l",
1845        ];
1846
1847        for width in 1..=6 {
1848            for height in 1..=4 {
1849                for input in inputs {
1850                    let mut vt = VirtualTerminal::new(width, height);
1851                    for chunk in input.chunks(3) {
1852                        vt.feed(chunk);
1853                        assert_invariants(&vt);
1854                    }
1855                }
1856            }
1857        }
1858    }
1859
1860    #[test]
1861    fn plain_text_output() {
1862        let mut vt = VirtualTerminal::new(80, 24);
1863        vt.feed(b"Hello, World!");
1864        assert_eq!(vt.char_at(0, 0), Some('H'));
1865        assert_eq!(vt.char_at(12, 0), Some('!'));
1866        assert_eq!(vt.cursor(), (13, 0));
1867        assert_eq!(vt.row_text(0), "Hello, World!");
1868    }
1869
1870    #[test]
1871    fn newline_advances_cursor() {
1872        let mut vt = VirtualTerminal::new(80, 24);
1873        vt.feed(b"Line 1\r\nLine 2");
1874        assert_eq!(vt.row_text(0), "Line 1");
1875        assert_eq!(vt.row_text(1), "Line 2");
1876        assert_eq!(vt.cursor(), (6, 1));
1877    }
1878
1879    #[test]
1880    fn carriage_return() {
1881        let mut vt = VirtualTerminal::new(80, 24);
1882        vt.feed(b"AAAA\rBB");
1883        assert_eq!(vt.row_text(0), "BBAA");
1884    }
1885
1886    #[test]
1887    fn auto_wrap() {
1888        let mut vt = VirtualTerminal::new(5, 3);
1889        vt.feed(b"ABCDEFGH");
1890        assert_eq!(vt.row_text(0), "ABCDE");
1891        assert_eq!(vt.row_text(1), "FGH");
1892        assert_eq!(vt.cursor(), (3, 1));
1893    }
1894
1895    #[test]
1896    fn screen_immediate_wrap_quirk_wraps_on_last_column() {
1897        let mut vt = VirtualTerminal::with_quirks(5, 3, QuirkSet::gnu_screen());
1898        vt.feed(b"ABCDE");
1899        assert_eq!(vt.row_text(0), "ABCDE");
1900        assert_eq!(vt.cursor(), (0, 1));
1901
1902        vt.feed(b"F");
1903        assert_eq!(vt.row_text(1), "F");
1904        assert_eq!(vt.cursor(), (1, 1));
1905    }
1906
1907    #[test]
1908    fn scroll_on_overflow() {
1909        let mut vt = VirtualTerminal::new(10, 3);
1910        vt.feed(b"AAA\r\nBBB\r\nCCC\r\nDDD");
1911        // AAA scrolled into scrollback, screen shows BBB, CCC, DDD
1912        assert_eq!(vt.row_text(0), "BBB");
1913        assert_eq!(vt.row_text(1), "CCC");
1914        assert_eq!(vt.row_text(2), "DDD");
1915        assert_eq!(vt.scrollback_len(), 1);
1916        assert_eq!(vt.scrollback_line(0), Some("AAA".to_string()));
1917    }
1918
1919    #[test]
1920    fn cursor_movement_csi() {
1921        let mut vt = VirtualTerminal::new(80, 24);
1922        // Move to (5, 3) — 1-indexed
1923        vt.feed(b"\x1b[4;6H");
1924        assert_eq!(vt.cursor(), (5, 3));
1925    }
1926
1927    #[test]
1928    fn cursor_up_down_forward_back() {
1929        let mut vt = VirtualTerminal::new(80, 24);
1930        vt.feed(b"\x1b[10;10H"); // Move to (9, 9)
1931        vt.feed(b"\x1b[3A"); // Up 3
1932        assert_eq!(vt.cursor(), (9, 6));
1933        vt.feed(b"\x1b[2B"); // Down 2
1934        assert_eq!(vt.cursor(), (9, 8));
1935        vt.feed(b"\x1b[5C"); // Forward 5
1936        assert_eq!(vt.cursor(), (14, 8));
1937        vt.feed(b"\x1b[3D"); // Back 3
1938        assert_eq!(vt.cursor(), (11, 8));
1939    }
1940
1941    #[test]
1942    fn cursor_clamps_to_bounds() {
1943        let mut vt = VirtualTerminal::new(10, 5);
1944        vt.feed(b"\x1b[100;100H");
1945        assert_eq!(vt.cursor(), (9, 4));
1946        vt.feed(b"\x1b[99A");
1947        assert_eq!(vt.cursor(), (9, 0));
1948    }
1949
1950    #[test]
1951    fn erase_to_end_of_line() {
1952        let mut vt = VirtualTerminal::new(80, 24);
1953        vt.feed(b"ABCDE");
1954        vt.feed(b"\x1b[1;6H"); // Move to column 5
1955        vt.feed(b"\x1b[K"); // Erase to end of line
1956        assert_eq!(vt.row_text(0), "ABCDE");
1957    }
1958
1959    #[test]
1960    fn erase_entire_line() {
1961        let mut vt = VirtualTerminal::new(80, 24);
1962        vt.feed(b"ABCDE");
1963        vt.feed(b"\x1b[2K"); // Erase entire line
1964        assert_eq!(vt.row_text(0), "");
1965    }
1966
1967    #[test]
1968    fn erase_display_from_cursor() {
1969        let mut vt = VirtualTerminal::new(10, 3);
1970        vt.feed(b"AAAAAAAAAA");
1971        vt.feed(b"BBBBBBBBBB");
1972        vt.feed(b"CCCCCCCCCC");
1973        vt.feed(b"\x1b[2;5H"); // Move to row 2, column 4
1974        vt.feed(b"\x1b[J"); // Erase from cursor to end
1975        assert_eq!(vt.row_text(0), "AAAAAAAAAA");
1976        assert_eq!(vt.row_text(1), "BBBB");
1977        assert_eq!(vt.row_text(2), "");
1978    }
1979
1980    #[test]
1981    fn sgr_bold_and_color() {
1982        let mut vt = VirtualTerminal::new(80, 24);
1983        vt.feed(b"\x1b[1;31mHello\x1b[0m World");
1984        // "Hello" should be bold + red
1985        let style = vt.style_at(0, 0).unwrap();
1986        assert!(style.bold);
1987        assert_eq!(style.fg, Some(Color::new(170, 0, 0)));
1988        // " World" should be reset
1989        let style2 = vt.style_at(6, 0).unwrap();
1990        assert!(!style2.bold);
1991        assert_eq!(style2.fg, None);
1992    }
1993
1994    #[test]
1995    fn sgr_truecolor() {
1996        let mut vt = VirtualTerminal::new(80, 24);
1997        vt.feed(b"\x1b[38;2;100;200;50mX");
1998        let style = vt.style_at(0, 0).unwrap();
1999        assert_eq!(style.fg, Some(Color::new(100, 200, 50)));
2000    }
2001
2002    #[test]
2003    fn sgr_256_color() {
2004        let mut vt = VirtualTerminal::new(80, 24);
2005        vt.feed(b"\x1b[48;5;196mX"); // Bright red-ish in 256 palette
2006        let style = vt.style_at(0, 0).unwrap();
2007        assert!(style.bg.is_some());
2008    }
2009
2010    #[test]
2011    fn dec_save_restore_cursor() {
2012        let mut vt = VirtualTerminal::new(80, 24);
2013        vt.feed(b"\x1b[5;10H"); // Move to (9, 4)
2014        vt.feed(b"\x1b7"); // Save
2015        vt.feed(b"\x1b[1;1H"); // Move to (0, 0)
2016        assert_eq!(vt.cursor(), (0, 0));
2017        vt.feed(b"\x1b8"); // Restore
2018        assert_eq!(vt.cursor(), (9, 4));
2019    }
2020
2021    #[test]
2022    fn tmux_nested_cursor_quirk_ignores_save_restore_in_alt_screen() {
2023        let mut vt = VirtualTerminal::with_quirks(80, 24, QuirkSet::tmux_nested());
2024        vt.feed(b"\x1b[?1049h"); // Enter alt screen
2025        vt.feed(b"\x1b[5;10H"); // Move to (9, 4)
2026        vt.feed(b"\x1b7"); // Save (ignored)
2027        vt.feed(b"\x1b[1;1H"); // Move to (0, 0)
2028        vt.feed(b"\x1b8"); // Restore (ignored)
2029        assert_eq!(vt.cursor(), (0, 0));
2030    }
2031
2032    #[test]
2033    fn combined_quirks_apply_independently() {
2034        let quirks = QuirkSet::empty()
2035            .with_screen_immediate_wrap(true)
2036            .with_tmux_nested_cursor(true);
2037        let mut vt = VirtualTerminal::with_quirks(5, 3, quirks);
2038
2039        vt.feed(b"\x1b[?1049h");
2040        vt.feed(b"ABCDE");
2041        assert_eq!(vt.cursor(), (0, 1));
2042
2043        vt.feed(b"\x1b[2;2H\x1b7\x1b[1;1H\x1b8");
2044        assert_eq!(vt.cursor(), (0, 0));
2045    }
2046
2047    #[test]
2048    fn cursor_visibility() {
2049        let mut vt = VirtualTerminal::new(80, 24);
2050        assert!(vt.cursor_visible());
2051        vt.feed(b"\x1b[?25l"); // Hide cursor
2052        assert!(!vt.cursor_visible());
2053        vt.feed(b"\x1b[?25h"); // Show cursor
2054        assert!(vt.cursor_visible());
2055    }
2056
2057    #[test]
2058    fn alternate_screen() {
2059        let mut vt = VirtualTerminal::new(10, 3);
2060        vt.feed(b"Main");
2061        assert_eq!(vt.row_text(0), "Main");
2062        assert!(!vt.is_alternate_screen());
2063
2064        vt.feed(b"\x1b[?1049h"); // Enter alt screen
2065        assert!(vt.is_alternate_screen());
2066        assert_eq!(vt.row_text(0), ""); // Alt screen is blank
2067        vt.feed(b"Alt");
2068        assert_eq!(vt.row_text(0), "Alt");
2069
2070        vt.feed(b"\x1b[?1049l"); // Exit alt screen
2071        assert!(!vt.is_alternate_screen());
2072        assert_eq!(vt.row_text(0), "Main"); // Main screen restored
2073    }
2074
2075    #[test]
2076    fn windows_no_alt_screen_quirk_ignores_alternate_buffer() {
2077        let mut vt = VirtualTerminal::with_quirks(10, 3, QuirkSet::windows_console());
2078        vt.feed(b"Main");
2079        vt.feed(b"\x1b[?1049h"); // Ignored
2080        vt.feed(b"Alt");
2081        vt.feed(b"\x1b[?1049l"); // Ignored
2082        assert!(!vt.is_alternate_screen());
2083        assert_eq!(vt.row_text(0), "MainAlt");
2084    }
2085
2086    #[test]
2087    fn osc_title() {
2088        let mut vt = VirtualTerminal::new(80, 24);
2089        vt.feed(b"\x1b]0;My Title\x07");
2090        assert_eq!(vt.title(), "My Title");
2091    }
2092
2093    #[test]
2094    fn full_reset() {
2095        let mut vt = VirtualTerminal::new(80, 24);
2096        vt.feed(b"Some text\x1b[1;31m");
2097        vt.feed(b"\x1bc"); // Full reset (RIS)
2098        assert_eq!(vt.cursor(), (0, 0));
2099        assert_eq!(vt.row_text(0), "");
2100        assert!(vt.cursor_visible());
2101    }
2102
2103    #[test]
2104    fn cpr_response_format() {
2105        let mut vt = VirtualTerminal::new(80, 24);
2106        vt.feed(b"\x1b[5;10H");
2107        let response = vt.cpr_response();
2108        assert_eq!(response, b"\x1b[5;10R");
2109    }
2110
2111    #[test]
2112    fn da1_response() {
2113        let vt = VirtualTerminal::new(80, 24);
2114        let response = vt.da1_response();
2115        assert_eq!(response, b"\x1b[?62;22c");
2116    }
2117
2118    #[test]
2119    fn scroll_region() {
2120        let mut vt = VirtualTerminal::new(10, 5);
2121        // Set scroll region to rows 2-4 (1-indexed)
2122        vt.feed(b"\x1b[2;4r");
2123        // Fill screen
2124        vt.feed(b"\x1b[1;1HROW1");
2125        vt.feed(b"\x1b[2;1HROW2");
2126        vt.feed(b"\x1b[3;1HROW3");
2127        vt.feed(b"\x1b[4;1HROW4");
2128        vt.feed(b"\x1b[5;1HROW5");
2129        assert_eq!(vt.row_text(0), "ROW1");
2130        assert_eq!(vt.row_text(4), "ROW5");
2131    }
2132
2133    #[test]
2134    fn tab_advances_to_stop() {
2135        let mut vt = VirtualTerminal::new(80, 24);
2136        vt.feed(b"AB\tC");
2137        assert_eq!(vt.char_at(8, 0), Some('C'));
2138    }
2139
2140    #[test]
2141    fn backspace() {
2142        let mut vt = VirtualTerminal::new(80, 24);
2143        vt.feed(b"ABC\x08X");
2144        assert_eq!(vt.row_text(0), "ABX");
2145    }
2146
2147    #[test]
2148    fn screen_text() {
2149        let mut vt = VirtualTerminal::new(10, 3);
2150        vt.feed(b"AAA\r\nBBB\r\nCCC");
2151        let text = vt.screen_text();
2152        assert_eq!(text, "AAA\nBBB\nCCC");
2153    }
2154
2155    #[test]
2156    fn scrollback_truncation() {
2157        let mut vt = VirtualTerminal::new(10, 2);
2158        vt.set_max_scrollback(3);
2159        // Push 5 lines, only last 3 remain in scrollback
2160        for i in 0..5 {
2161            vt.feed_str(&format!("Line{i}\n"));
2162        }
2163        assert!(vt.scrollback_len() <= 3);
2164    }
2165
2166    #[test]
2167    fn out_of_bounds_cell_returns_none() {
2168        let vt = VirtualTerminal::new(10, 5);
2169        assert_eq!(vt.char_at(10, 0), None);
2170        assert_eq!(vt.char_at(0, 5), None);
2171        assert!(vt.style_at(99, 99).is_none());
2172    }
2173
2174    #[test]
2175    fn reverse_index_at_scroll_top() {
2176        let mut vt = VirtualTerminal::new(10, 5);
2177        vt.feed(b"\x1b[2;4r"); // Scroll region 2-4
2178        vt.feed(b"\x1b[2;1H"); // Cursor at row 1 (within region)
2179        vt.feed(b"\x1bM"); // Reverse index
2180        // Should scroll down within region
2181        assert_eq!(vt.cursor(), (0, 1));
2182    }
2183
2184    #[test]
2185    fn cursor_horizontal_absolute() {
2186        let mut vt = VirtualTerminal::new(10, 3);
2187        vt.feed(b"\x1b[10G");
2188        assert_eq!(vt.cursor(), (9, 0));
2189    }
2190
2191    #[test]
2192    fn vertical_position_absolute() {
2193        let mut vt = VirtualTerminal::new(80, 24);
2194        vt.feed(b"\x1b[5d");
2195        assert_eq!(vt.cursor(), (0, 4));
2196    }
2197
2198    #[test]
2199    fn cursor_next_previous_line() {
2200        let mut vt = VirtualTerminal::new(80, 24);
2201        vt.feed(b"\x1b[5;10H"); // (9, 4)
2202        vt.feed(b"\x1b[2E"); // Next line x2
2203        assert_eq!(vt.cursor(), (0, 6));
2204        vt.feed(b"\x1b[1F"); // Previous line x1
2205        assert_eq!(vt.cursor(), (0, 5));
2206    }
2207
2208    #[test]
2209    fn bright_colors() {
2210        let mut vt = VirtualTerminal::new(80, 24);
2211        vt.feed(b"\x1b[91mX"); // Bright red fg
2212        let style = vt.style_at(0, 0).unwrap();
2213        assert_eq!(style.fg, Some(Color::new(255, 85, 85)));
2214    }
2215
2216    #[test]
2217    fn nel_next_line() {
2218        let mut vt = VirtualTerminal::new(10, 3);
2219        vt.feed(b"ABCDE\x1bEX");
2220        // ESC E = CR + LF: cursor goes to col 0, next row
2221        assert_eq!(vt.row_text(0), "ABCDE");
2222        assert_eq!(vt.row_text(1), "X");
2223        assert_eq!(vt.cursor(), (1, 1));
2224    }
2225
2226    #[test]
2227    fn nel_at_bottom_scrolls() {
2228        let mut vt = VirtualTerminal::new(5, 3);
2229        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC");
2230        vt.feed(b"\x1b[3;3H\x1bE"); // CUP(3,3) → (2,2), then NEL at bottom
2231        assert_eq!(vt.row_text(0), "BBBBB");
2232        assert_eq!(vt.row_text(1), "CCCCC");
2233        assert_eq!(vt.row_text(2), "");
2234        assert_eq!(vt.cursor(), (0, 2));
2235    }
2236
2237    #[test]
2238    fn decaln_fills_with_e() {
2239        let mut vt = VirtualTerminal::new(5, 3);
2240        vt.feed(b"ABC\x1b#8");
2241        assert_eq!(vt.row_text(0), "EEEEE");
2242        assert_eq!(vt.row_text(1), "EEEEE");
2243        assert_eq!(vt.row_text(2), "EEEEE");
2244        assert_eq!(vt.cursor(), (0, 0));
2245    }
2246
2247    #[test]
2248    fn decaln_resets_scroll_region() {
2249        let mut vt = VirtualTerminal::new(5, 3);
2250        vt.feed(b"\x1b[2;3r"); // Set scroll region rows 2-3
2251        vt.feed(b"\x1b#8"); // DECALN resets margins
2252        // After DECALN, writing + scroll should affect full screen (region reset)
2253        vt.feed(b"\x1b[3;1HZZZZZ\n"); // Write at bottom, LF → scroll
2254        assert_eq!(vt.row_text(0), "EEEEE");
2255        assert_eq!(vt.row_text(1), "ZZZZZ");
2256        assert_eq!(vt.row_text(2), "");
2257    }
2258
2259    #[test]
2260    fn utf8_basic_multibyte() {
2261        let mut vt = VirtualTerminal::new(10, 3);
2262        // "é" is 2 bytes: 0xc3 0xa9
2263        vt.feed("Aé B".as_bytes());
2264        assert_eq!(vt.row_text(0), "Aé B");
2265        assert_eq!(vt.cursor(), (4, 0));
2266    }
2267
2268    #[test]
2269    fn wide_char_basic() {
2270        let mut vt = VirtualTerminal::new(10, 3);
2271        // "中" (U+4E2D) = 3 bytes, display width 2
2272        vt.feed("A中B".as_bytes());
2273        assert_eq!(vt.row_text(0), "A中B");
2274        assert_eq!(vt.cursor(), (4, 0)); // A(1) + 中(2) + B(1) = col 4
2275    }
2276
2277    #[test]
2278    fn wide_char_wraps_at_last_column() {
2279        let mut vt = VirtualTerminal::new(5, 3);
2280        // 4 narrow chars + wide char: wide can't fit in col 4, wraps
2281        vt.feed("ABCD中".as_bytes());
2282        assert_eq!(vt.row_text(0), "ABCD");
2283        assert_eq!(vt.row_text(1), "中");
2284        assert_eq!(vt.cursor(), (2, 1));
2285    }
2286
2287    #[test]
2288    fn narrow_overwrites_wide_lead() {
2289        let mut vt = VirtualTerminal::new(10, 3);
2290        vt.feed("中".as_bytes()); // col 0-1
2291        vt.feed(b"\x1b[1;1HX"); // CUP(1,1), write 'X' at col 0 (overwrites wide lead)
2292        // Lead becomes 'X', continuation should be blanked
2293        assert_eq!(vt.row_text(0), "X");
2294        assert_eq!(vt.cursor(), (1, 0));
2295    }
2296
2297    #[test]
2298    fn narrow_overwrites_wide_continuation() {
2299        let mut vt = VirtualTerminal::new(10, 3);
2300        vt.feed("中".as_bytes()); // col 0-1
2301        vt.feed(b"\x1b[1;2HX"); // CUP(1,2), write 'X' at col 1 (overwrites continuation)
2302        // Lead blanked to space, col 1 becomes 'X'
2303        assert_eq!(vt.row_text(0), " X");
2304        assert_eq!(vt.cursor(), (2, 0));
2305    }
2306
2307    // ── Tab stop tests ─────────────────────────────────────────────
2308
2309    #[test]
2310    fn default_tab_stops_every_8() {
2311        let vt = VirtualTerminal::new(20, 3);
2312        // Default stops at 8, 16
2313        assert!(!vt.tab_stops[0]);
2314        assert!(vt.tab_stops[8]);
2315        assert!(vt.tab_stops[16]);
2316        assert!(!vt.tab_stops[1]);
2317        assert!(!vt.tab_stops[7]);
2318    }
2319
2320    #[test]
2321    fn hts_sets_custom_tab_stop() {
2322        let mut vt = VirtualTerminal::new(20, 3);
2323        // Move to col 5, ESC H to set tab stop
2324        vt.feed(b"\x1b[1;6H\x1bH");
2325        assert!(vt.tab_stops[5]);
2326        // Tab from col 0 should now stop at 5
2327        vt.feed(b"\x1b[1;1H\t");
2328        assert_eq!(vt.cursor(), (5, 0));
2329    }
2330
2331    #[test]
2332    fn tbc_clears_single_tab_stop() {
2333        let mut vt = VirtualTerminal::new(20, 3);
2334        // Clear tab stop at col 8
2335        vt.feed(b"\x1b[1;9H\x1b[0g");
2336        assert!(!vt.tab_stops[8]);
2337        // Tab from col 0 should now go to col 16
2338        vt.feed(b"\x1b[1;1H\t");
2339        assert_eq!(vt.cursor(), (16, 0));
2340    }
2341
2342    #[test]
2343    fn tbc_clears_all_tab_stops() {
2344        let mut vt = VirtualTerminal::new(20, 3);
2345        // Clear all tab stops
2346        vt.feed(b"\x1b[3g");
2347        // Tab from col 0 → clamps to last col (no stops)
2348        vt.feed(b"\x1b[1;1H\t");
2349        assert_eq!(vt.cursor(), (19, 0));
2350    }
2351
2352    #[test]
2353    fn cbt_moves_to_previous_tab_stop() {
2354        let mut vt = VirtualTerminal::new(20, 3);
2355        // Move to col 10, CBT → back to col 8
2356        vt.feed(b"\x1b[1;11H\x1b[Z");
2357        assert_eq!(vt.cursor(), (8, 0));
2358    }
2359
2360    #[test]
2361    fn cbt_at_col_zero() {
2362        let mut vt = VirtualTerminal::new(20, 3);
2363        // CBT at col 0 stays at col 0
2364        vt.feed(b"\x1b[Z");
2365        assert_eq!(vt.cursor(), (0, 0));
2366    }
2367
2368    #[test]
2369    fn reset_restores_default_tab_stops() {
2370        let mut vt = VirtualTerminal::new(20, 3);
2371        // Clear all, then reset
2372        vt.feed(b"\x1b[3g");
2373        assert!(!vt.tab_stops[8]);
2374        vt.feed(b"\x1bc"); // RIS (full reset)
2375        assert!(vt.tab_stops[8]);
2376    }
2377
2378    // ── IRM (Insert/Replace Mode) tests ────────────────────────────
2379
2380    #[test]
2381    fn irm_insert_mode_shifts_right() {
2382        let mut vt = VirtualTerminal::new(10, 3);
2383        vt.feed(b"ABCDE");
2384        // Enable insert mode (CSI 4 h), move to col 2, type "XY"
2385        vt.feed(b"\x1b[4h\x1b[1;3HXY");
2386        // "AB" + inserted "XY" + shifted "CDE" → "ABXYCDE"
2387        assert_eq!(vt.row_text(0), "ABXYCDE");
2388    }
2389
2390    #[test]
2391    fn irm_replace_mode_default() {
2392        let mut vt = VirtualTerminal::new(10, 3);
2393        vt.feed(b"ABCDE");
2394        // Replace mode (default), move to col 2, type "XY"
2395        vt.feed(b"\x1b[1;3HXY");
2396        assert_eq!(vt.row_text(0), "ABXYE");
2397    }
2398
2399    #[test]
2400    fn irm_disable_returns_to_replace() {
2401        let mut vt = VirtualTerminal::new(10, 3);
2402        vt.feed(b"ABCDE");
2403        // Enable insert, then disable
2404        vt.feed(b"\x1b[4h\x1b[4l\x1b[1;3HXY");
2405        // Should overwrite, not insert
2406        assert_eq!(vt.row_text(0), "ABXYE");
2407    }
2408
2409    #[test]
2410    fn irm_insert_pushes_off_right_edge() {
2411        let mut vt = VirtualTerminal::new(5, 3);
2412        vt.feed(b"ABCDE");
2413        // Insert mode, move to col 0, type "X"
2414        vt.feed(b"\x1b[4h\x1b[1;1HX");
2415        // "X" inserted at col 0, "ABCD" shifted right, "E" falls off
2416        assert_eq!(vt.row_text(0), "XABCD");
2417    }
2418
2419    // ── DECAWM (Auto-Wrap Mode) tests ──────────────────────────────
2420
2421    #[test]
2422    fn decawm_enabled_wraps_at_edge() {
2423        let mut vt = VirtualTerminal::new(5, 3);
2424        // Auto-wrap is on by default
2425        vt.feed(b"ABCDEF");
2426        assert_eq!(vt.row_text(0), "ABCDE");
2427        assert_eq!(vt.row_text(1), "F");
2428    }
2429
2430    #[test]
2431    fn decawm_disabled_no_wrap() {
2432        let mut vt = VirtualTerminal::new(5, 3);
2433        // Disable auto-wrap
2434        vt.feed(b"\x1b[?7l");
2435        vt.feed(b"ABCDEFGH");
2436        // All chars overwrite at last column, no wrap
2437        assert_eq!(vt.row_text(0), "ABCDH");
2438        assert_eq!(vt.row_text(1), "");
2439        assert_eq!(vt.cursor(), (4, 0));
2440    }
2441
2442    #[test]
2443    fn decawm_reenable_wraps_again() {
2444        let mut vt = VirtualTerminal::new(5, 3);
2445        // Disable, then re-enable
2446        vt.feed(b"\x1b[?7l\x1b[?7h");
2447        vt.feed(b"ABCDEF");
2448        assert_eq!(vt.row_text(0), "ABCDE");
2449        assert_eq!(vt.row_text(1), "F");
2450    }
2451
2452    // ── Charset tests ───────────────────────────────────────────────
2453
2454    #[test]
2455    fn dec_graphics_g0_designation() {
2456        let mut vt = VirtualTerminal::new(10, 3);
2457        // ESC ( 0 — designate G0 as DEC Special Graphics
2458        // 'q' = ─ (U+2500), 'x' = │ (U+2502)
2459        vt.feed(b"\x1b(0qqxx\x1b(B");
2460        assert_eq!(vt.char_at(0, 0).unwrap(), '\u{2500}'); // ─
2461        assert_eq!(vt.char_at(1, 0).unwrap(), '\u{2500}'); // ─
2462        assert_eq!(vt.char_at(2, 0).unwrap(), '\u{2502}'); // │
2463        assert_eq!(vt.char_at(3, 0).unwrap(), '\u{2502}'); // │
2464    }
2465
2466    #[test]
2467    fn dec_graphics_g1_with_so_si() {
2468        let mut vt = VirtualTerminal::new(10, 3);
2469        // ESC ) 0 — designate G1 as DEC Special Graphics
2470        // SO (0x0e) — activate G1, print 'l' = ┌ (U+250C)
2471        // SI (0x0f) — back to G0 (ASCII), print 'l' = literal 'l'
2472        vt.feed(b"\x1b)0\x0el\x0fl");
2473        assert_eq!(vt.char_at(0, 0).unwrap(), '\u{250C}'); // ┌
2474        assert_eq!(vt.char_at(1, 0).unwrap(), 'l');
2475    }
2476
2477    #[test]
2478    fn dec_graphics_box_chars() {
2479        let mut vt = VirtualTerminal::new(10, 3);
2480        // Test all four corners + crossing
2481        vt.feed(b"\x1b(0lkjmn\x1b(B");
2482        assert_eq!(vt.char_at(0, 0).unwrap(), '\u{250C}'); // l = ┌
2483        assert_eq!(vt.char_at(1, 0).unwrap(), '\u{2510}'); // k = ┐
2484        assert_eq!(vt.char_at(2, 0).unwrap(), '\u{2518}'); // j = ┘
2485        assert_eq!(vt.char_at(3, 0).unwrap(), '\u{2514}'); // m = └
2486        assert_eq!(vt.char_at(4, 0).unwrap(), '\u{253C}'); // n = ┼
2487    }
2488
2489    #[test]
2490    fn charset_reset_restores_ascii() {
2491        let mut vt = VirtualTerminal::new(10, 3);
2492        // Designate G0 as DEC graphics, then full reset
2493        vt.feed(b"\x1b(0");
2494        vt.feed(b"\x1bc"); // RIS (full reset)
2495        vt.feed(b"q");
2496        assert_eq!(vt.char_at(0, 0).unwrap(), 'q'); // Should be literal 'q', not ─
2497    }
2498
2499    #[test]
2500    fn charset_soft_reset_restores_ascii() {
2501        let mut vt = VirtualTerminal::new(10, 3);
2502        // Designate G0 as DEC graphics, then soft reset
2503        vt.feed(b"\x1b(0");
2504        vt.feed(b"\x1b[!p"); // DECSTR (soft reset)
2505        vt.feed(b"q");
2506        assert_eq!(vt.char_at(0, 0).unwrap(), 'q'); // Should be literal 'q', not ─
2507    }
2508
2509    #[test]
2510    fn so_si_toggle_charset() {
2511        let mut vt = VirtualTerminal::new(10, 3);
2512        // G1 = DEC graphics; toggle SO/SI multiple times
2513        vt.feed(b"\x1b)0");
2514        vt.feed(b"A"); // G0 ASCII: 'A'
2515        vt.feed(b"\x0e"); // SO: switch to G1
2516        vt.feed(b"q"); // DEC graphics: ─
2517        vt.feed(b"\x0f"); // SI: back to G0
2518        vt.feed(b"B"); // ASCII: 'B'
2519        assert_eq!(vt.char_at(0, 0).unwrap(), 'A');
2520        assert_eq!(vt.char_at(1, 0).unwrap(), '\u{2500}'); // ─
2521        assert_eq!(vt.char_at(2, 0).unwrap(), 'B');
2522    }
2523
2524    #[test]
2525    fn ascii_passthrough_in_dec_graphics() {
2526        let mut vt = VirtualTerminal::new(10, 3);
2527        // Characters outside 0x60-0x7e should pass through unchanged even in DEC graphics
2528        vt.feed(b"\x1b(0ABC\x1b(B");
2529        assert_eq!(vt.char_at(0, 0).unwrap(), 'A');
2530        assert_eq!(vt.char_at(1, 0).unwrap(), 'B');
2531        assert_eq!(vt.char_at(2, 0).unwrap(), 'C');
2532    }
2533
2534    // ── ICH (Insert Characters, CSI @) tests ──────────────────────────
2535
2536    #[test]
2537    fn ich_basic_insert() {
2538        let mut vt = VirtualTerminal::new(10, 3);
2539        vt.feed(b"ABCDE");
2540        vt.feed(b"\x1b[1;3H"); // cursor at col 2
2541        vt.feed(b"\x1b[2@"); // insert 2 blanks
2542        assert_eq!(vt.row_text(0), "AB  CDE");
2543        assert_eq!(vt.cursor(), (2, 0));
2544        assert_invariants(&vt);
2545    }
2546
2547    #[test]
2548    fn ich_pushes_off_right_edge() {
2549        let mut vt = VirtualTerminal::new(5, 3);
2550        vt.feed(b"ABCDE");
2551        vt.feed(b"\x1b[1;2H"); // cursor at col 1
2552        vt.feed(b"\x1b[2@"); // insert 2
2553        assert_eq!(vt.row_text(0), "A  BC");
2554        assert_invariants(&vt);
2555    }
2556
2557    #[test]
2558    fn ich_at_wide_char_continuation() {
2559        let mut vt = VirtualTerminal::new(10, 3);
2560        vt.feed("A中B".as_bytes()); // A at 0, 中 at 1-2, B at 3
2561        vt.feed(b"\x1b[1;3H"); // cursor at col 2 (continuation of 中)
2562        vt.feed(b"\x1b[2@"); // insert 2 blanks at continuation
2563        // Wide char lead at col 1 should be blanked (orphaned)
2564        assert_eq!(vt.row_text(0), "A    B");
2565        assert_invariants(&vt);
2566    }
2567
2568    // ── DCH (Delete Characters, CSI P) tests ──────────────────────────
2569
2570    #[test]
2571    fn dch_basic_delete() {
2572        let mut vt = VirtualTerminal::new(10, 3);
2573        vt.feed(b"ABCDE");
2574        vt.feed(b"\x1b[1;2H"); // cursor at col 1
2575        vt.feed(b"\x1b[2P"); // delete 2 chars
2576        assert_eq!(vt.row_text(0), "ADE");
2577        assert_eq!(vt.cursor(), (1, 0));
2578        assert_invariants(&vt);
2579    }
2580
2581    #[test]
2582    fn dch_fills_blanks_at_end() {
2583        let mut vt = VirtualTerminal::new(5, 3);
2584        vt.feed(b"ABCDE");
2585        vt.feed(b"\x1b[1;1H"); // cursor at col 0
2586        vt.feed(b"\x1b[3P"); // delete 3
2587        assert_eq!(vt.row_text(0), "DE");
2588        assert_invariants(&vt);
2589    }
2590
2591    #[test]
2592    fn dch_at_wide_char_boundary() {
2593        let mut vt = VirtualTerminal::new(10, 3);
2594        vt.feed("A中B".as_bytes()); // A at 0, 中 at 1-2, B at 3
2595        vt.feed(b"\x1b[1;2H"); // cursor at col 1 (lead of 中)
2596        vt.feed(b"\x1b[1P"); // delete 1 char at wide lead
2597        // Wide char lead at col 1 should be blanked (orphaned)
2598        assert_eq!(vt.row_text(0), "A B");
2599        assert_invariants(&vt);
2600    }
2601
2602    // ── ECH (Erase Characters, CSI X) tests ───────────────────────────
2603
2604    #[test]
2605    fn ech_basic_erase() {
2606        let mut vt = VirtualTerminal::new(10, 3);
2607        vt.feed(b"ABCDE");
2608        vt.feed(b"\x1b[1;2H"); // cursor at col 1
2609        vt.feed(b"\x1b[3X"); // erase 3 chars
2610        assert_eq!(vt.row_text(0), "A   E");
2611        assert_eq!(vt.cursor(), (1, 0)); // cursor doesn't move
2612        assert_invariants(&vt);
2613    }
2614
2615    #[test]
2616    fn ech_does_not_move_cursor() {
2617        let mut vt = VirtualTerminal::new(10, 3);
2618        vt.feed(b"ABCDE");
2619        vt.feed(b"\x1b[1;3H"); // cursor at col 2
2620        vt.feed(b"\x1b[1X");
2621        assert_eq!(vt.cursor(), (2, 0));
2622        assert_eq!(vt.char_at(2, 0), Some(' '));
2623        assert_eq!(vt.char_at(3, 0), Some('D'));
2624        assert_invariants(&vt);
2625    }
2626
2627    #[test]
2628    fn ech_at_wide_char_continuation() {
2629        let mut vt = VirtualTerminal::new(10, 3);
2630        vt.feed("X中Y".as_bytes()); // X at 0, 中 at 1-2, Y at 3
2631        vt.feed(b"\x1b[1;3H"); // cursor at col 2 (continuation of 中)
2632        vt.feed(b"\x1b[1X"); // erase 1 at continuation
2633        // Lead at col 1 should be blanked (orphaned)
2634        assert_eq!(vt.row_text(0), "X  Y");
2635        assert_invariants(&vt);
2636    }
2637
2638    #[test]
2639    fn ech_clamped_to_line_end() {
2640        let mut vt = VirtualTerminal::new(5, 3);
2641        vt.feed(b"ABCDE");
2642        vt.feed(b"\x1b[1;4H"); // cursor at col 3
2643        vt.feed(b"\x1b[99X"); // erase 99 (clamped to remaining 2)
2644        assert_eq!(vt.row_text(0), "ABC");
2645        assert_invariants(&vt);
2646    }
2647
2648    // ── IL (Insert Lines, CSI L) tests ────────────────────────────────
2649
2650    #[test]
2651    fn il_basic_insert_line() {
2652        let mut vt = VirtualTerminal::new(5, 5);
2653        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC\r\nDDDDD\r\nEEEEE");
2654        vt.feed(b"\x1b[2;1H"); // cursor at row 1
2655        vt.feed(b"\x1b[1L"); // insert 1 line
2656        assert_eq!(vt.row_text(0), "AAAAA");
2657        assert_eq!(vt.row_text(1), ""); // inserted blank
2658        assert_eq!(vt.row_text(2), "BBBBB");
2659        assert_eq!(vt.row_text(3), "CCCCC");
2660        assert_eq!(vt.row_text(4), "DDDDD");
2661        // EEEEE pushed off bottom
2662    }
2663
2664    #[test]
2665    fn il_within_scroll_region() {
2666        let mut vt = VirtualTerminal::new(5, 5);
2667        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC\r\nDDDDD\r\nEEEEE");
2668        vt.feed(b"\x1b[2;4r"); // scroll region rows 2-4 (0-indexed: 1-3)
2669        vt.feed(b"\x1b[2;1H"); // cursor at row 1 (within region)
2670        vt.feed(b"\x1b[1L"); // insert 1 line
2671        assert_eq!(vt.row_text(0), "AAAAA"); // outside region, untouched
2672        assert_eq!(vt.row_text(1), ""); // inserted blank
2673        assert_eq!(vt.row_text(2), "BBBBB"); // shifted down
2674        assert_eq!(vt.row_text(3), "CCCCC"); // shifted down (DDDDD pushed off bottom of region)
2675        assert_eq!(vt.row_text(4), "EEEEE"); // outside region, untouched
2676    }
2677
2678    #[test]
2679    fn il_outside_scroll_region_ignored() {
2680        let mut vt = VirtualTerminal::new(5, 5);
2681        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC\r\nDDDDD\r\nEEEEE");
2682        vt.feed(b"\x1b[2;4r"); // scroll region rows 2-4
2683        vt.feed(b"\x1b[1;1H"); // cursor at row 0 (outside region)
2684        vt.feed(b"\x1b[1L"); // insert: cursor outside region → no-op
2685        assert_eq!(vt.row_text(0), "AAAAA");
2686        assert_eq!(vt.row_text(1), "BBBBB");
2687        assert_invariants(&vt);
2688    }
2689
2690    // ── DL (Delete Lines, CSI M) tests ────────────────────────────────
2691
2692    #[test]
2693    fn dl_basic_delete_line() {
2694        let mut vt = VirtualTerminal::new(5, 5);
2695        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC\r\nDDDDD\r\nEEEEE");
2696        vt.feed(b"\x1b[2;1H"); // cursor at row 1
2697        vt.feed(b"\x1b[1M"); // delete 1 line
2698        assert_eq!(vt.row_text(0), "AAAAA");
2699        assert_eq!(vt.row_text(1), "CCCCC"); // pulled up
2700        assert_eq!(vt.row_text(2), "DDDDD");
2701        assert_eq!(vt.row_text(3), "EEEEE");
2702        assert_eq!(vt.row_text(4), ""); // blank at bottom
2703    }
2704
2705    #[test]
2706    fn dl_within_scroll_region() {
2707        let mut vt = VirtualTerminal::new(5, 5);
2708        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC\r\nDDDDD\r\nEEEEE");
2709        vt.feed(b"\x1b[2;4r"); // scroll region rows 2-4 (0-indexed: 1-3)
2710        vt.feed(b"\x1b[2;1H"); // cursor at row 1 (within region)
2711        vt.feed(b"\x1b[1M"); // delete 1 line
2712        assert_eq!(vt.row_text(0), "AAAAA"); // outside region
2713        assert_eq!(vt.row_text(1), "CCCCC"); // pulled up
2714        assert_eq!(vt.row_text(2), "DDDDD"); // pulled up
2715        assert_eq!(vt.row_text(3), ""); // blank at region bottom
2716        assert_eq!(vt.row_text(4), "EEEEE"); // outside region
2717    }
2718
2719    #[test]
2720    fn dl_outside_scroll_region_ignored() {
2721        let mut vt = VirtualTerminal::new(5, 5);
2722        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC\r\nDDDDD\r\nEEEEE");
2723        vt.feed(b"\x1b[2;4r"); // scroll region rows 2-4
2724        vt.feed(b"\x1b[5;1H"); // cursor at row 4 (outside region bottom)
2725        vt.feed(b"\x1b[1M"); // delete: cursor outside → no-op
2726        assert_eq!(vt.row_text(4), "EEEEE");
2727    }
2728
2729    // ── SU/SD (Scroll Up/Down, CSI S/T) tests ────────────────────────
2730
2731    #[test]
2732    fn su_scroll_up_within_region() {
2733        let mut vt = VirtualTerminal::new(5, 5);
2734        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC\r\nDDDDD\r\nEEEEE");
2735        vt.feed(b"\x1b[2;4r"); // scroll region rows 2-4 (0-indexed: 1-3)
2736        vt.feed(b"\x1b[1S"); // scroll up 1 within region
2737        assert_eq!(vt.row_text(0), "AAAAA"); // outside region, untouched
2738        assert_eq!(vt.row_text(1), "CCCCC"); // shifted up from row 2
2739        assert_eq!(vt.row_text(2), "DDDDD"); // shifted up from row 3
2740        assert_eq!(vt.row_text(3), ""); // blank at region bottom
2741        assert_eq!(vt.row_text(4), "EEEEE"); // outside region, untouched
2742        assert_eq!(vt.scrollback_len(), 1);
2743        assert_eq!(vt.scrollback_line(0), Some("BBBBB".to_string()));
2744    }
2745
2746    #[test]
2747    fn sd_scroll_down_within_region() {
2748        let mut vt = VirtualTerminal::new(5, 5);
2749        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC\r\nDDDDD\r\nEEEEE");
2750        vt.feed(b"\x1b[2;4r"); // scroll region rows 2-4 (0-indexed: 1-3)
2751        vt.feed(b"\x1b[1T"); // scroll down 1 within region
2752        assert_eq!(vt.row_text(0), "AAAAA"); // outside region, untouched
2753        assert_eq!(vt.row_text(1), ""); // blank at region top
2754        assert_eq!(vt.row_text(2), "BBBBB"); // shifted down from row 1
2755        assert_eq!(vt.row_text(3), "CCCCC"); // shifted down from row 2 (DDDDD pushed off)
2756        assert_eq!(vt.row_text(4), "EEEEE"); // outside region, untouched
2757    }
2758
2759    #[test]
2760    fn su_multiple_lines() {
2761        let mut vt = VirtualTerminal::new(5, 3);
2762        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC");
2763        vt.feed(b"\x1b[2S"); // scroll up 2
2764        assert_eq!(vt.row_text(0), "CCCCC");
2765        assert_eq!(vt.row_text(1), "");
2766        assert_eq!(vt.row_text(2), "");
2767        assert_eq!(vt.scrollback_len(), 2);
2768        assert_eq!(vt.scrollback_line(0), Some("AAAAA".to_string()));
2769        assert_eq!(vt.scrollback_line(1), Some("BBBBB".to_string()));
2770    }
2771
2772    // ── REP (Repeat Character, CSI b) tests ───────────────────────────
2773
2774    #[test]
2775    fn rep_basic_repeat() {
2776        let mut vt = VirtualTerminal::new(10, 3);
2777        vt.feed(b"X\x1b[3b"); // print X, then repeat 3 times
2778        assert_eq!(vt.row_text(0), "XXXX");
2779        assert_eq!(vt.cursor(), (4, 0));
2780    }
2781
2782    #[test]
2783    fn rep_no_previous_char() {
2784        let mut vt = VirtualTerminal::new(10, 3);
2785        vt.feed(b"\x1b[5b"); // repeat with no previous char → no-op
2786        assert_eq!(vt.row_text(0), "");
2787        assert_eq!(vt.cursor(), (0, 0));
2788    }
2789
2790    #[test]
2791    fn rep_wraps_across_lines() {
2792        let mut vt = VirtualTerminal::new(5, 3);
2793        vt.feed(b"A\x1b[6b"); // A + repeat 6 → 7 A's total → wraps
2794        assert_eq!(vt.row_text(0), "AAAAA");
2795        assert_eq!(vt.row_text(1), "AA");
2796        assert_eq!(vt.cursor(), (2, 1));
2797    }
2798
2799    // ── DECOM (Origin Mode, DEC mode 6) tests ────────────────────────
2800
2801    #[test]
2802    fn decom_cup_relative_to_scroll_region() {
2803        let mut vt = VirtualTerminal::new(10, 10);
2804        vt.feed(b"\x1b[3;7r"); // scroll region rows 3-7 (0-indexed: 2-6)
2805        vt.feed(b"\x1b[?6h"); // enable DECOM
2806        // CUP(1,1) should go to (0, scroll_top=2) in absolute coords
2807        vt.feed(b"\x1b[1;1H");
2808        assert_eq!(vt.cursor(), (0, 2));
2809        // CUP(3,5) should go to (4, scroll_top+2=4)
2810        vt.feed(b"\x1b[3;5H");
2811        assert_eq!(vt.cursor(), (4, 4));
2812    }
2813
2814    #[test]
2815    fn decom_clamps_to_scroll_region() {
2816        let mut vt = VirtualTerminal::new(10, 10);
2817        vt.feed(b"\x1b[3;7r"); // scroll region rows 3-7
2818        vt.feed(b"\x1b[?6h"); // enable DECOM
2819        // CUP with row beyond region should clamp to scroll_bottom
2820        vt.feed(b"\x1b[99;1H");
2821        assert_eq!(vt.cursor(), (0, 6)); // clamped to scroll_bottom=6
2822    }
2823
2824    #[test]
2825    fn decom_disable_homes_to_origin() {
2826        let mut vt = VirtualTerminal::new(10, 10);
2827        vt.feed(b"\x1b[3;7r"); // scroll region rows 3-7
2828        vt.feed(b"\x1b[?6h"); // enable DECOM
2829        vt.feed(b"\x1b[5;5H"); // move somewhere
2830        vt.feed(b"\x1b[?6l"); // disable → cursor to (0,0)
2831        assert_eq!(vt.cursor(), (0, 0));
2832    }
2833
2834    #[test]
2835    fn decom_vpa_relative_to_scroll_region() {
2836        let mut vt = VirtualTerminal::new(10, 10);
2837        vt.feed(b"\x1b[3;7r"); // scroll region rows 3-7
2838        vt.feed(b"\x1b[?6h"); // enable DECOM
2839        vt.feed(b"\x1b[2d"); // VPA row 2 → absolute row = scroll_top + 1 = 3
2840        assert_eq!(vt.cursor().1, 3);
2841    }
2842
2843    #[test]
2844    fn decom_decstbm_homes_cursor() {
2845        let mut vt = VirtualTerminal::new(10, 10);
2846        vt.feed(b"\x1b[?6h"); // enable DECOM
2847        vt.feed(b"\x1b[5;5H"); // move somewhere
2848        vt.feed(b"\x1b[3;7r"); // set new scroll region → homes cursor
2849        assert_eq!(vt.cursor(), (0, 2)); // cursor at scroll_top
2850    }
2851
2852    // ── Single-Shift SS2/SS3 tests ───────────────────────────────────
2853
2854    #[test]
2855    fn ss2_translates_one_char_from_g2() {
2856        let mut vt = VirtualTerminal::new(10, 3);
2857        // Designate G2 as DEC Special Graphics
2858        vt.feed(b"\x1b*0");
2859        // SS2 (ESC N) → next char from G2, then revert to G0
2860        vt.feed(b"\x1bNq"); // 'q' via G2 (DEC graphics) → ─
2861        vt.feed(b"q"); // 'q' via G0 (ASCII) → 'q'
2862        assert_eq!(vt.char_at(0, 0).unwrap(), '\u{2500}'); // ─
2863        assert_eq!(vt.char_at(1, 0).unwrap(), 'q');
2864    }
2865
2866    #[test]
2867    fn ss3_translates_one_char_from_g3() {
2868        let mut vt = VirtualTerminal::new(10, 3);
2869        // Designate G3 as DEC Special Graphics
2870        vt.feed(b"\x1b+0");
2871        // SS3 (ESC O) → next char from G3, then revert to G0
2872        vt.feed(b"\x1bOx"); // 'x' via G3 (DEC graphics) → │
2873        vt.feed(b"x"); // 'x' via G0 (ASCII) → 'x'
2874        assert_eq!(vt.char_at(0, 0).unwrap(), '\u{2502}'); // │
2875        assert_eq!(vt.char_at(1, 0).unwrap(), 'x');
2876    }
2877
2878    #[test]
2879    fn ss2_only_affects_one_character() {
2880        let mut vt = VirtualTerminal::new(10, 3);
2881        vt.feed(b"\x1b*0"); // G2 = DEC graphics
2882        vt.feed(b"\x1bNlk"); // SS2 + 'l' + 'k' (only 'l' via G2)
2883        assert_eq!(vt.char_at(0, 0).unwrap(), '\u{250C}'); // ┌ (l via DEC graphics)
2884        assert_eq!(vt.char_at(1, 0).unwrap(), 'k'); // literal k (back to G0)
2885    }
2886
2887    // ── Locking Shift LS2/LS3 tests ─────────────────────────────────
2888
2889    #[test]
2890    fn ls2_invokes_g2_into_gl() {
2891        let mut vt = VirtualTerminal::new(10, 3);
2892        vt.feed(b"\x1b*0"); // G2 = DEC graphics
2893        vt.feed(b"\x1bn"); // LS2: invoke G2 into GL
2894        vt.feed(b"jm"); // should use DEC graphics
2895        assert_eq!(vt.char_at(0, 0).unwrap(), '\u{2518}'); // j = ┘
2896        assert_eq!(vt.char_at(1, 0).unwrap(), '\u{2514}'); // m = └
2897    }
2898
2899    #[test]
2900    fn ls3_invokes_g3_into_gl() {
2901        let mut vt = VirtualTerminal::new(10, 3);
2902        vt.feed(b"\x1b+0"); // G3 = DEC graphics
2903        vt.feed(b"\x1bo"); // LS3: invoke G3 into GL
2904        vt.feed(b"n"); // should use DEC graphics
2905        assert_eq!(vt.char_at(0, 0).unwrap(), '\u{253C}'); // n = ┼
2906    }
2907
2908    #[test]
2909    fn ls2_persists_across_characters() {
2910        let mut vt = VirtualTerminal::new(10, 3);
2911        vt.feed(b"\x1b*0"); // G2 = DEC graphics
2912        vt.feed(b"\x1bn"); // LS2
2913        vt.feed(b"tuvw"); // all via G2 DEC graphics
2914        assert_eq!(vt.char_at(0, 0).unwrap(), '\u{251C}'); // t = ├
2915        assert_eq!(vt.char_at(1, 0).unwrap(), '\u{2524}'); // u = ┤
2916        assert_eq!(vt.char_at(2, 0).unwrap(), '\u{2534}'); // v = ┴
2917        assert_eq!(vt.char_at(3, 0).unwrap(), '\u{252C}'); // w = ┬
2918    }
2919
2920    // ── Alternate Screen Mode 1047 (without cursor save/restore) ─────
2921
2922    #[test]
2923    fn alt_screen_1047_no_cursor_save() {
2924        let mut vt = VirtualTerminal::new(10, 3);
2925        vt.feed(b"Main");
2926        vt.feed(b"\x1b[1;5H"); // cursor at col 4
2927        let (_cx, _cy) = vt.cursor();
2928        vt.feed(b"\x1b[?1047h"); // enter alt screen (no cursor save)
2929        assert!(vt.is_alternate_screen());
2930        assert_eq!(vt.row_text(0), ""); // blank alt screen
2931
2932        vt.feed(b"Alt");
2933        vt.feed(b"\x1b[?1047l"); // exit alt screen (no cursor restore)
2934        assert!(!vt.is_alternate_screen());
2935        assert_eq!(vt.row_text(0), "Main"); // main grid restored
2936        // Cursor position is NOT restored (unlike mode 1049)
2937        // It keeps whatever position it had in alt screen
2938        let (_, _) = vt.cursor();
2939    }
2940
2941    #[test]
2942    fn alt_screen_1047_double_enter_ignored() {
2943        let mut vt = VirtualTerminal::new(10, 3);
2944        vt.feed(b"Main");
2945        vt.feed(b"\x1b[?1047h"); // enter alt screen (cursor stays at current position)
2946        vt.feed(b"\x1b[?1047h"); // second enter → ignored
2947        assert!(vt.is_alternate_screen());
2948        assert_eq!(vt.row_text(0), ""); // still in the same blank alt screen
2949    }
2950
2951    // ── Scroll region with DECSTBM edge cases ────────────────────────
2952
2953    #[test]
2954    fn decstbm_invalid_range_ignored() {
2955        let mut vt = VirtualTerminal::new(10, 5);
2956        // top >= bottom → ignored
2957        vt.feed(b"\x1b[4;2r");
2958        assert_eq!(vt.scroll_top, 0);
2959        assert_eq!(vt.scroll_bottom, 4);
2960    }
2961
2962    #[test]
2963    fn decstbm_bottom_at_screen_edge() {
2964        let mut vt = VirtualTerminal::new(10, 5);
2965        vt.feed(b"\x1b[2;5r"); // rows 2-5 (0-indexed: 1-4)
2966        assert_eq!(vt.scroll_top, 1);
2967        assert_eq!(vt.scroll_bottom, 4);
2968    }
2969
2970    #[test]
2971    fn decstbm_homes_cursor_without_decom() {
2972        let mut vt = VirtualTerminal::new(10, 5);
2973        vt.feed(b"\x1b[3;3H"); // cursor at (2,2)
2974        vt.feed(b"\x1b[2;4r"); // set region → homes to (0,0)
2975        assert_eq!(vt.cursor(), (0, 0));
2976    }
2977
2978    // ── Erase Display edge cases ─────────────────────────────────────
2979
2980    #[test]
2981    fn erase_display_mode1_from_start_to_cursor() {
2982        let mut vt = VirtualTerminal::new(10, 3);
2983        vt.feed(b"AAAAAAAAAA");
2984        vt.feed(b"BBBBBBBBBB");
2985        vt.feed(b"CCCCCCCCCC");
2986        vt.feed(b"\x1b[2;5H"); // row 1, col 4 (1-indexed)
2987        vt.feed(b"\x1b[1J"); // erase from start to cursor (inclusive)
2988        assert_eq!(vt.row_text(0), ""); // all of row 0 erased
2989        assert_eq!(vt.row_text(1), "     BBBBB"); // first 5 cols of row 1 erased
2990        assert_eq!(vt.row_text(2), "CCCCCCCCCC"); // untouched
2991    }
2992
2993    #[test]
2994    fn erase_display_mode3_clears_scrollback() {
2995        let mut vt = VirtualTerminal::new(5, 2);
2996        vt.feed(b"AAAAA\r\nBBBBB\r\nCCCCC"); // scroll one line into scrollback
2997        assert!(vt.scrollback_len() > 0);
2998        vt.feed(b"\x1b[3J"); // mode 3: erase display + clear scrollback
2999        assert_eq!(vt.scrollback_len(), 0);
3000    }
3001
3002    // ── Erase Line mode 1 (from start to cursor) ────────────────────
3003
3004    #[test]
3005    fn erase_line_mode1() {
3006        let mut vt = VirtualTerminal::new(10, 3);
3007        vt.feed(b"ABCDEFGHIJ");
3008        vt.feed(b"\x1b[1;6H"); // cursor at col 5
3009        vt.feed(b"\x1b[1K"); // erase from start to cursor (inclusive)
3010        assert_eq!(vt.row_text(0), "      GHIJ");
3011    }
3012
3013    // ── Soft reset (DECSTR) ─────────────────────────────────────────
3014
3015    #[test]
3016    fn soft_reset_restores_defaults() {
3017        let mut vt = VirtualTerminal::new(10, 5);
3018        // Set various modes
3019        vt.feed(b"\x1b[?7l"); // disable autowrap
3020        vt.feed(b"\x1b[?25l"); // hide cursor
3021        vt.feed(b"\x1b[4h"); // enable insert mode
3022        vt.feed(b"\x1b[?6h"); // enable DECOM
3023        vt.feed(b"\x1b[2;4r"); // set scroll region
3024        // Soft reset
3025        vt.feed(b"\x1b[!p");
3026        assert!(vt.cursor_visible());
3027        assert_eq!(vt.scroll_top, 0);
3028        assert_eq!(vt.scroll_bottom, 4);
3029        // Verify autowrap restored by testing wrap behavior
3030        vt.feed(b"\x1b[1;1H");
3031        vt.feed(b"ABCDEFGHIJK");
3032        assert_eq!(vt.row_text(0), "ABCDEFGHIJ");
3033        assert_eq!(vt.row_text(1), "K"); // wraps → autowrap is on
3034    }
3035
3036    // ── Wide char erase edge cases ──────────────────────────────────
3037
3038    #[test]
3039    fn erase_line_splits_wide_char_at_boundary() {
3040        let mut vt = VirtualTerminal::new(10, 3);
3041        vt.feed("AB中DE".as_bytes()); // A(0) B(1) 中(2-3) D(4) E(5)
3042        vt.feed(b"\x1b[1;4H"); // cursor at col 3 (continuation of 中)
3043        vt.feed(b"\x1b[K"); // erase from cursor to end of line
3044        assert_invariants(&vt);
3045        // The lead of 中 at col 2 should be blanked (orphaned)
3046        assert_eq!(vt.char_at(2, 0), Some(' '));
3047    }
3048
3049    #[test]
3050    fn dch_wide_char_continuation_at_boundary() {
3051        let mut vt = VirtualTerminal::new(10, 3);
3052        vt.feed("AB中DE".as_bytes());
3053        vt.feed(b"\x1b[1;3H"); // cursor at col 2 (lead of 中)
3054        vt.feed(b"\x1b[2P"); // delete 2 chars (both cells of 中)
3055        assert_eq!(vt.row_text(0), "ABDE");
3056        assert_invariants(&vt);
3057    }
3058
3059    // ── Invariant checks on complex sequences ───────────────────────
3060
3061    #[test]
3062    fn invariants_after_insert_delete_scroll_sequence() {
3063        let mut vt = VirtualTerminal::new(8, 4);
3064        // Complex sequence: fill, set region, IL, DL, SU, SD, ICH, DCH
3065        vt.feed(b"AABBCCDD");
3066        vt.feed(b"EEFFGGHH");
3067        vt.feed(b"IIJJKKLL");
3068        vt.feed(b"MMNNOOPP");
3069        vt.feed(b"\x1b[2;3r"); // scroll region rows 2-3
3070        vt.feed(b"\x1b[2;1H"); // cursor in region
3071        vt.feed(b"\x1b[1L"); // insert line
3072        vt.feed(b"\x1b[1M"); // delete line
3073        vt.feed(b"\x1b[1S"); // scroll up
3074        vt.feed(b"\x1b[1T"); // scroll down
3075        vt.feed(b"\x1b[2;4H\x1b[2@"); // ICH at col 3
3076        vt.feed(b"\x1b[1P"); // DCH
3077        assert_invariants(&vt);
3078    }
3079
3080    #[test]
3081    fn invariants_after_wide_char_operations() {
3082        let mut vt = VirtualTerminal::new(6, 3);
3083        // Wide chars + editing operations
3084        vt.feed("中文字".as_bytes()); // A(0) B(1) 中(2-3) D(4) E(5)
3085        vt.feed(b"\x1b[1;1H\x1b[2@"); // ICH 2 at col 0
3086        assert_invariants(&vt);
3087
3088        let mut vt2 = VirtualTerminal::new(6, 3);
3089        vt2.feed("中文字".as_bytes());
3090        vt2.feed(b"\x1b[1;1H\x1b[2P"); // DCH 2 at col 0
3091        assert_invariants(&vt2);
3092
3093        let mut vt3 = VirtualTerminal::new(6, 3);
3094        vt3.feed("中文字".as_bytes());
3095        vt3.feed(b"\x1b[1;3H\x1b[2X"); // ECH 2 at col 2 (lead of 文)
3096        assert_invariants(&vt3);
3097    }
3098
3099    // ── put_char public API tests ──────────────────────────────────
3100
3101    #[test]
3102    fn put_char_basic_ascii() {
3103        let mut vt = VirtualTerminal::new(80, 24);
3104        vt.put_char('H');
3105        vt.put_char('e');
3106        vt.put_char('l');
3107        vt.put_char('l');
3108        vt.put_char('o');
3109        assert_eq!(vt.row_text(0), "Hello");
3110        assert_eq!(vt.cursor(), (5, 0));
3111        assert_invariants(&vt);
3112    }
3113
3114    #[test]
3115    fn put_char_matches_feed_str() {
3116        let mut vt_feed = VirtualTerminal::new(40, 10);
3117        vt_feed.feed_str("Hello!");
3118
3119        let mut vt_put = VirtualTerminal::new(40, 10);
3120        for ch in "Hello!".chars() {
3121            vt_put.put_char(ch);
3122        }
3123
3124        assert_eq!(vt_feed.screen_text(), vt_put.screen_text());
3125        assert_eq!(vt_feed.cursor(), vt_put.cursor());
3126    }
3127
3128    #[test]
3129    fn put_char_wide_characters() {
3130        let mut vt = VirtualTerminal::new(10, 3);
3131        vt.put_char('中');
3132        vt.put_char('文');
3133        assert_eq!(vt.row_text(0), "中文");
3134        assert_eq!(vt.cursor(), (4, 0)); // each CJK char is 2 columns wide
3135        assert_invariants(&vt);
3136    }
3137
3138    #[test]
3139    fn put_char_autowrap() {
3140        let mut vt = VirtualTerminal::new(5, 3);
3141        for ch in "ABCDE".chars() {
3142            vt.put_char(ch);
3143        }
3144        // Cursor is at pending-wrap position (col == width)
3145        assert_eq!(vt.row_text(0), "ABCDE");
3146        // Next character wraps to row 1
3147        vt.put_char('F');
3148        assert_eq!(vt.row_text(1), "F");
3149        assert_eq!(vt.cursor(), (1, 1));
3150        assert_invariants(&vt);
3151    }
3152
3153    #[test]
3154    fn put_char_wide_wrap_at_margin() {
3155        // Terminal 5 columns wide, place 4 narrow chars then a wide char.
3156        // The wide char needs 2 columns but only 1 remains, so it wraps.
3157        let mut vt = VirtualTerminal::new(5, 3);
3158        for ch in "ABCD".chars() {
3159            vt.put_char(ch);
3160        }
3161        vt.put_char('中'); // wide: needs 2 cols, only 1 left → wraps
3162        assert_eq!(vt.row_text(0), "ABCD");
3163        assert_eq!(vt.row_text(1), "中");
3164        assert_invariants(&vt);
3165    }
3166
3167    #[test]
3168    fn put_char_zero_width_skipped() {
3169        let mut vt = VirtualTerminal::new(10, 3);
3170        vt.put_char('A');
3171        vt.put_char('\u{0300}'); // combining grave accent (zero-width)
3172        assert_eq!(vt.cursor(), (1, 0)); // cursor didn't advance
3173        assert_eq!(vt.char_at(0, 0), Some('A'));
3174        assert_invariants(&vt);
3175    }
3176
3177    #[test]
3178    fn put_char_preserves_style() {
3179        let mut vt = VirtualTerminal::new(10, 3);
3180        // Set bold via ANSI escape
3181        vt.feed(b"\x1b[1m");
3182        vt.put_char('X');
3183        let style = vt.style_at(0, 0).unwrap();
3184        assert!(style.bold);
3185        assert_invariants(&vt);
3186    }
3187
3188    // ── put_str tests ─────────────────────────────────────────────
3189
3190    #[test]
3191    fn put_str_basic() {
3192        let mut vt = VirtualTerminal::new(20, 3);
3193        vt.put_str("Hello, world!");
3194        assert_eq!(vt.row_text(0), "Hello, world!");
3195        assert_eq!(vt.cursor(), (13, 0));
3196        assert_invariants(&vt);
3197    }
3198
3199    #[test]
3200    fn put_str_empty() {
3201        let mut vt = VirtualTerminal::new(10, 1);
3202        vt.put_str("");
3203        assert_eq!(vt.cursor(), (0, 0));
3204        assert_eq!(vt.row_text(0), "");
3205        assert_invariants(&vt);
3206    }
3207
3208    #[test]
3209    fn put_str_wraps_at_margin() {
3210        let mut vt = VirtualTerminal::new(5, 3);
3211        vt.put_str("ABCDEFGH");
3212        assert_eq!(vt.row_text(0), "ABCDE");
3213        assert_eq!(vt.row_text(1), "FGH");
3214        assert_eq!(vt.cursor(), (3, 1));
3215        assert_invariants(&vt);
3216    }
3217
3218    #[test]
3219    fn put_str_wide_chars() {
3220        let mut vt = VirtualTerminal::new(10, 1);
3221        vt.put_str("中文");
3222        assert_eq!(vt.row_text(0), "中文");
3223        assert_eq!(vt.cursor(), (4, 0));
3224        assert_invariants(&vt);
3225    }
3226
3227    #[test]
3228    fn put_str_matches_put_char_sequence() {
3229        let text = "Hi 😀!";
3230        let mut vt_str = VirtualTerminal::new(20, 3);
3231        vt_str.put_str(text);
3232
3233        let mut vt_char = VirtualTerminal::new(20, 3);
3234        for ch in text.chars() {
3235            vt_char.put_char(ch);
3236        }
3237
3238        assert_eq!(vt_str.screen_text(), vt_char.screen_text());
3239        assert_eq!(vt_str.cursor(), vt_char.cursor());
3240    }
3241
3242    #[test]
3243    fn put_str_does_not_interpret_escapes() {
3244        let mut vt = VirtualTerminal::new(20, 1);
3245        // ESC[1m would enable bold if processed as escape sequence
3246        vt.put_str("\x1b[1mBold");
3247        // The ESC char should be skipped (zero-width), but '[', '1', 'm'
3248        // should appear as literal characters
3249        let text = vt.row_text(0);
3250        assert!(text.contains("[1mBold"), "got: {text:?}");
3251        // Style should NOT be bold (no escape processing)
3252        let style = vt.style_at(0, 0).unwrap();
3253        assert!(!style.bold);
3254        assert_invariants(&vt);
3255    }
3256
3257    // ── set_cursor_position tests ────────────────────────────────
3258
3259    #[test]
3260    fn set_cursor_position_basic() {
3261        let mut vt = VirtualTerminal::new(80, 24);
3262        vt.set_cursor_position(10, 5);
3263        assert_eq!(vt.cursor(), (10, 5));
3264        assert_invariants(&vt);
3265    }
3266
3267    #[test]
3268    fn set_cursor_position_clamps_x() {
3269        let mut vt = VirtualTerminal::new(80, 24);
3270        vt.set_cursor_position(200, 5);
3271        assert_eq!(vt.cursor(), (79, 5));
3272        assert_invariants(&vt);
3273    }
3274
3275    #[test]
3276    fn set_cursor_position_clamps_y() {
3277        let mut vt = VirtualTerminal::new(80, 24);
3278        vt.set_cursor_position(10, 100);
3279        assert_eq!(vt.cursor(), (10, 23));
3280        assert_invariants(&vt);
3281    }
3282
3283    #[test]
3284    fn set_cursor_position_origin() {
3285        let mut vt = VirtualTerminal::new(80, 24);
3286        vt.put_str("test");
3287        vt.set_cursor_position(0, 0);
3288        assert_eq!(vt.cursor(), (0, 0));
3289        assert_invariants(&vt);
3290    }
3291
3292    #[test]
3293    fn set_cursor_position_then_put_char() {
3294        let mut vt = VirtualTerminal::new(10, 3);
3295        vt.set_cursor_position(3, 1);
3296        vt.put_char('X');
3297        assert_eq!(vt.char_at(3, 1), Some('X'));
3298        assert_eq!(vt.cursor(), (4, 1));
3299        assert_invariants(&vt);
3300    }
3301
3302    // ── clear tests ─────────────────────────────────────────────
3303
3304    #[test]
3305    fn clear_empties_display() {
3306        let mut vt = VirtualTerminal::new(10, 3);
3307        vt.put_str("Hello");
3308        vt.set_cursor_position(0, 1);
3309        vt.put_str("World");
3310        vt.clear();
3311        assert_eq!(vt.row_text(0), "");
3312        assert_eq!(vt.row_text(1), "");
3313        assert_invariants(&vt);
3314    }
3315
3316    #[test]
3317    fn clear_preserves_cursor_position() {
3318        let mut vt = VirtualTerminal::new(10, 3);
3319        vt.set_cursor_position(5, 2);
3320        vt.clear();
3321        assert_eq!(vt.cursor(), (5, 2));
3322        assert_invariants(&vt);
3323    }
3324
3325    #[test]
3326    fn clear_does_not_affect_scrollback() {
3327        let mut vt = VirtualTerminal::new(5, 2);
3328        // Fill enough text to push lines into scrollback
3329        vt.put_str("AAAAABBBBBCCCCC");
3330        let sb_before = vt.scrollback_len();
3331        assert!(sb_before > 0);
3332        vt.clear();
3333        assert_eq!(vt.scrollback_len(), sb_before);
3334        assert_invariants(&vt);
3335    }
3336
3337    // ── clear_scrollback tests ──────────────────────────────────
3338
3339    #[test]
3340    fn clear_scrollback_empties_history() {
3341        let mut vt = VirtualTerminal::new(5, 2);
3342        vt.put_str("AAAAABBBBBCCCCC");
3343        assert!(vt.scrollback_len() > 0);
3344        vt.clear_scrollback();
3345        assert_eq!(vt.scrollback_len(), 0);
3346        assert_invariants(&vt);
3347    }
3348
3349    #[test]
3350    fn clear_scrollback_preserves_display() {
3351        let mut vt = VirtualTerminal::new(10, 2);
3352        vt.put_str("Hello");
3353        vt.set_cursor_position(0, 1);
3354        vt.put_str("World");
3355        vt.clear_scrollback();
3356        assert_eq!(vt.row_text(0), "Hello");
3357        assert_eq!(vt.row_text(1), "World");
3358        assert_invariants(&vt);
3359    }
3360}