Skip to main content

vterm/
term.rs

1//! The terminal emulator: bytes in, a live [`ansi::Screen`] out.
2//!
3//! [`Term`] holds a primary and an alternate [`ansi::Screen`] (the active one
4//! owns the cursor — there is no second copy to keep in sync), the
5//! current SGR [`ansi::Style`], a scroll region, tab stops, a saved-cursor
6//! slot, and the flags a VT100 tracks (autowrap, cursor visibility, and the
7//! pending-wrap latch at the right edge). [`Term::feed`] runs the child's
8//! output through an internal [`ansi::Parser`] and applies each token.
9//!
10//! Coordinates are 0-based internally; CSI parameters are 1-based on the wire
11//! and converted on the way in. Where VT defaults a missing parameter to 1
12//! (cursor motion, CUP), we treat a 0 from the parser as that default; where
13//! it defaults to 0 (erase/SGR selectors), 0 is used directly.
14
15use ansi::{Cell, Cursor, Parser, Screen, Style, Token};
16
17/// A minimal VT100/ECMA-48 terminal emulator over an [`ansi::Screen`].
18pub struct Term {
19    /// The primary (normal) screen buffer.
20    primary: Screen,
21    /// The alternate screen buffer, used while an app enters alt-screen mode
22    /// (`?1049h`/`?47h`/`?1047h`). Kept the same size as `primary`.
23    alt: Screen,
24    /// True while the alternate buffer is active.
25    in_alt: bool,
26    /// Current SGR style applied to newly written cells.
27    style: Style,
28    /// Scroll region, 0-based inclusive `[top, bottom]` (DECSTBM).
29    scroll_top: usize,
30    scroll_bottom: usize,
31    /// Saved cursor state (DECSC/`ESC 7` and CSI `s`): position and style.
32    saved: Option<(Cursor, Style)>,
33    /// Tab stops: `tabs[c]` true means a tab stop at column `c`.
34    tabs: Vec<bool>,
35    /// Pending-wrap latch: set after writing the last column so the *next*
36    /// printable wraps to the next line first (DECAWM deferred-wrap).
37    wrap_pending: bool,
38    /// Autowrap mode (DECAWM). Default on.
39    autowrap: bool,
40    /// Cursor visibility (DECTCEM, `?25h`/`?25l`). Tracked for the host; the
41    /// screen cursor position is always synced regardless.
42    cursor_visible: bool,
43    /// The parser driving `feed`; one per stream so mid-sequence chunk splits
44    /// are handled by `ansi`.
45    parser: Parser,
46    /// Set to `true` by `feed` (non-empty bytes) and `resize`; reset and
47    /// returned by `take_dirty`. Lets callers skip a composite when nothing
48    /// has changed since the last rendered frame.
49    dirty: bool,
50    /// Synchronized-output depth (DEC private mode 2026, `?2026h`/`?2026l`).
51    /// While an app is inside a synchronized update it is redrawing the frame;
52    /// a compositor that samples the screen mid-update sees a half-drawn frame
53    /// (leftover/overlapping text). While `in_sync`, [`Term::screen`] hands back
54    /// `sync_frame`, a clone of the last *complete* frame captured when the
55    /// update opened, so external readers only ever see whole frames. Writes
56    /// still land on the live buffer; the new frame is revealed on `?2026l`.
57    in_sync: bool,
58    /// The last complete frame, snapshotted when a synchronized update opens.
59    /// `Some` exactly while `in_sync`; `None` otherwise.
60    sync_frame: Option<Screen>,
61    /// Scratch for [`Term::print_text`]'s blit, kept so a run of text costs no
62    /// allocation. Only ever used within one call.
63    blit: Vec<Cell>,
64}
65
66impl Term {
67    /// A blank emulator of `rows x cols` (both clamped to at least 1), cursor
68    /// at the origin, full-height scroll region, default tab stops every 8
69    /// columns, autowrap on, cursor visible.
70    pub fn new(rows: usize, cols: usize) -> Self {
71        let rows = rows.max(1);
72        let cols = cols.max(1);
73        Term {
74            primary: Screen::new(rows, cols),
75            alt: Screen::new(rows, cols),
76            in_alt: false,
77            style: Style::default(),
78            scroll_top: 0,
79            scroll_bottom: rows - 1,
80            saved: None,
81            tabs: default_tabs(cols),
82            wrap_pending: false,
83            autowrap: true,
84            cursor_visible: true,
85            parser: Parser::new(),
86            dirty: false,
87            in_sync: false,
88            sync_frame: None,
89            blit: Vec::with_capacity(cols),
90        }
91    }
92
93    /// Feed a chunk of child output. Chunks may split at any byte boundary
94    /// (mid-escape, mid-UTF-8); the internal parser holds partial state across
95    /// calls, so the final screen is independent of how bytes were chunked.
96    pub fn feed(&mut self, bytes: &[u8]) {
97        if bytes.is_empty() {
98            return;
99        }
100        self.dirty = true;
101        let tokens = self.parser.feed(bytes);
102        for tok in tokens {
103            self.apply(&tok);
104        }
105    }
106
107    /// Returns `true` if any bytes have been fed (or a resize has occurred)
108    /// since the last call to `take_dirty`, and resets the flag. Call once
109    /// per frame decision to avoid redundant compositing.
110    pub fn take_dirty(&mut self) -> bool {
111        std::mem::replace(&mut self.dirty, false)
112    }
113
114    /// The active buffer (alternate while in alt-screen, else primary). Its
115    /// cursor is the emulator's cursor, so a compositor / `Screen::diff` parks
116    /// the terminal cursor correctly.
117    pub fn screen(&self) -> &Screen {
118        // The active buffer owns the cursor, so it is always current. While a
119        // synchronized update (`?2026h`) is open we hand back the last complete
120        // frame instead of the live, half-drawn buffer (double-buffering).
121        match &self.sync_frame {
122            Some(frame) => frame,
123            None => self.active(),
124        }
125    }
126
127    /// True while the child is inside a synchronized update (DEC mode 2026):
128    /// it has emitted `?2026h` and not yet the matching `?2026l`. During this
129    /// window [`Term::screen`] returns the last complete frame. Exposed so a
130    /// host can also gate its own compositing on it if desired.
131    pub fn in_sync(&self) -> bool {
132        self.in_sync
133    }
134
135    /// Resize to `rows x cols` (clamped to at least 1). For 0.1 this rebuilds
136    /// fresh buffers of the new size (the caller repaints); the cursor and
137    /// scroll region are clamped, tab stops rebuilt, and pending wrap cleared.
138    pub fn resize(&mut self, rows: usize, cols: usize) {
139        let rows = rows.max(1);
140        let cols = cols.max(1);
141        let cursor = self.cursor();
142        let old_primary = std::mem::replace(&mut self.primary, Screen::new(rows, cols));
143        let old_alt = std::mem::replace(&mut self.alt, Screen::new(rows, cols));
144        copy_top_left(&old_primary, &mut self.primary);
145        copy_top_left(&old_alt, &mut self.alt);
146        self.scroll_top = self.scroll_top.min(rows - 1);
147        self.scroll_bottom = rows - 1;
148        // The fresh active buffer takes the cursor, clamped onto the new size.
149        self.active_mut().set_cursor(cursor);
150        self.tabs = default_tabs(cols);
151        self.wrap_pending = false;
152        // A resize rebuilds the buffers; any held sync frame is now the wrong
153        // size and stale. Drop it and reveal the freshly-sized live buffer.
154        self.in_sync = false;
155        self.sync_frame = None;
156        self.dirty = true;
157    }
158
159    // --- internals --------------------------------------------------------
160
161    fn active(&self) -> &Screen {
162        if self.in_alt {
163            &self.alt
164        } else {
165            &self.primary
166        }
167    }
168
169    fn active_mut(&mut self) -> &mut Screen {
170        if self.in_alt {
171            &mut self.alt
172        } else {
173            &mut self.primary
174        }
175    }
176
177    fn rows(&self) -> usize {
178        self.active().rows()
179    }
180
181    fn cols(&self) -> usize {
182        self.active().cols()
183    }
184
185    /// The emulator cursor: the active buffer's.
186    fn cursor(&self) -> Cursor {
187        self.active().cursor()
188    }
189
190    fn set_row(&mut self, row: usize) {
191        let col = self.cursor().col;
192        self.active_mut().set_cursor(Cursor { row, col });
193    }
194
195    fn set_col(&mut self, col: usize) {
196        let row = self.cursor().row;
197        self.active_mut().set_cursor(Cursor { row, col });
198    }
199
200    fn apply(&mut self, tok: &Token) {
201        match tok {
202            Token::Text(s) => self.print_text(s),
203            Token::Control(b) => self.control(*b),
204            Token::Csi {
205                private,
206                params,
207                final_byte,
208                ..
209            } => self.csi(*private, params, *final_byte),
210            Token::Esc { final_byte, .. } => self.esc(*final_byte),
211            // Titles, DCS/SOS/PM/APC strings: nothing to render.
212            Token::Osc(_) | Token::Other { .. } => {}
213        }
214    }
215
216    /// Write a run of text at the cursor.
217    ///
218    /// Terminal output is overwhelmingly plain ASCII, and going through
219    /// [`Term::print`] for each character re-reads the cursor, re-borrows the
220    /// active buffer, looks up a width and bounds-checks a cell — per character.
221    /// So a run of ordinary single-width ASCII that fits on the current row is
222    /// blitted in one [`Screen::copy_cells`] with the cursor moved once.
223    ///
224    /// The fast path is deliberately narrow, and anything it does not cover
225    /// falls through to `print` unchanged: no wrap is pending, the characters
226    /// are printable ASCII (never control, never wide, never zero-width), and
227    /// the run stops one column short of the edge so the pending-wrap latch is
228    /// still decided by `print`.
229    fn print_text(&mut self, s: &str) {
230        let mut rest = s;
231        while !rest.is_empty() {
232            let n = self.plain_run(rest);
233            if n == 0 {
234                // Not the fast path: one character the careful way.
235                let ch = rest.chars().next().expect("non-empty");
236                self.print(ch);
237                rest = &rest[ch.len_utf8()..];
238                continue;
239            }
240            let style = self.style;
241            self.blit.clear();
242            self.blit.extend(
243                rest.as_bytes()[..n]
244                    .iter()
245                    .map(|&b| Cell::new(b as char, style)),
246            );
247            let screen = if self.in_alt {
248                &mut self.alt
249            } else {
250                &mut self.primary
251            };
252            let Cursor { row, col } = screen.cursor();
253            screen.copy_cells(row, col, &self.blit);
254            screen.set_cursor(Cursor { row, col: col + n });
255            rest = &rest[n..];
256        }
257    }
258
259    /// How many leading bytes of `s` the fast path in [`Term::print_text`] can
260    /// blit: printable ASCII, stopping before the last column of the row.
261    fn plain_run(&self, s: &str) -> usize {
262        if self.wrap_pending {
263            return 0;
264        }
265        let screen = if self.in_alt {
266            &self.alt
267        } else {
268            &self.primary
269        };
270        let col = screen.cursor().col;
271        // Leave the final column to `print`, which owns the pending-wrap latch.
272        let room = screen.cols().saturating_sub(col + 1);
273        s.as_bytes()
274            .iter()
275            .take(room)
276            .take_while(|&&b| (0x20..0x7f).contains(&b))
277            .count()
278    }
279
280    /// Write one printable character at the cursor, honoring autowrap via the
281    /// pending-wrap latch and East Asian double-width via [`uwidth`].
282    ///
283    /// A width-2 glyph (CJK, fullwidth, emoji) occupies two columns: its left
284    /// half is a [`Cell::wide`] carrying the character, its right half a
285    /// [`Cell::continuation`] carrying no glyph. The cursor advances by two. A
286    /// wide glyph that would straddle the right edge is not split — the whole
287    /// glyph wraps to the next line (autowrap on) or is dropped (autowrap off),
288    /// matching real terminals. Zero-width characters (combining marks, joiners,
289    /// variation selectors) are dropped: this grid stores one character per
290    /// cell and cannot compose them, and dropping avoids the column drift that
291    /// giving them a cell of their own would cause.
292    fn print(&mut self, ch: char) {
293        let w = uwidth::char_width(ch) as usize;
294
295        // Zero-width: no cell, no cursor movement (see doc comment).
296        if w == 0 {
297            return;
298        }
299
300        if self.wrap_pending && self.autowrap {
301            // The previous write filled the last column; wrap now.
302            self.set_col(0);
303            self.line_feed();
304            self.wrap_pending = false;
305        }
306
307        // A double-width glyph needs two columns; if only one remains it cannot
308        // be split. Wrap the whole glyph to the next line (or drop it when
309        // autowrap is off, rather than overwrite half a cell at the edge).
310        if w == 2 && self.cursor().col + 1 >= self.cols() {
311            if !self.autowrap {
312                return;
313            }
314            self.set_col(0);
315            self.line_feed();
316            self.wrap_pending = false;
317        }
318
319        // The per-character hot path borrows the active buffer once for the
320        // cells and the cursor together (field-disjoint, so `self.wrap_pending`
321        // stays writable below).
322        let (autowrap, style) = (self.autowrap, self.style);
323        let screen = if self.in_alt {
324            &mut self.alt
325        } else {
326            &mut self.primary
327        };
328        let cols = screen.cols();
329        let Cursor { row: r, col: c } = screen.cursor();
330        if w == 2 {
331            screen.set(r, c, Cell::wide(ch, style));
332            screen.set(r, c + 1, Cell::continuation(style));
333        } else {
334            screen.set(r, c, Cell::new(ch, style));
335        }
336        // Advance by the glyph's width. If that lands at or past the last
337        // column, stay at the last column and latch a pending wrap (deferred to
338        // the next printable), matching real terminals and the `ansi` diff's
339        // pending-wrap comment. A width-1 glyph advances exactly as before.
340        let at_edge = c + w >= cols;
341        let next = if at_edge { cols - 1 } else { c + w };
342        screen.set_cursor(Cursor { row: r, col: next });
343        if at_edge && autowrap {
344            self.wrap_pending = true;
345        }
346    }
347
348    fn control(&mut self, b: u8) {
349        match b {
350            b'\r' => {
351                self.set_col(0);
352                self.wrap_pending = false;
353            }
354            b'\n' | 0x0b | 0x0c => {
355                // LF, VT, FF: move down one line, scrolling at the margin.
356                self.line_feed();
357                self.wrap_pending = false;
358            }
359            0x08 => {
360                // BS: cursor left one column, clamped at column 0.
361                if self.cursor().col > 0 {
362                    self.set_col(self.cursor().col - 1);
363                }
364                self.wrap_pending = false;
365            }
366            b'\t' => {
367                self.set_col(self.next_tab_stop());
368                self.wrap_pending = false;
369            }
370            _ => {} // BEL and other C0 controls: nothing to render.
371        }
372    }
373
374    /// Move the cursor down one line; if it is at the bottom margin, scroll the
375    /// scroll region up by one instead.
376    fn line_feed(&mut self) {
377        if self.cursor().row == self.scroll_bottom {
378            self.scroll_up(1);
379        } else if self.cursor().row + 1 < self.rows() {
380            self.set_row(self.cursor().row + 1);
381        }
382    }
383
384    /// Next tab stop strictly right of the cursor, or the last column.
385    fn next_tab_stop(&self) -> usize {
386        let cols = self.cols();
387        let mut c = self.cursor().col + 1;
388        while c < cols {
389            if self.tabs.get(c).copied().unwrap_or(false) {
390                return c;
391            }
392            c += 1;
393        }
394        cols - 1
395    }
396
397    // --- CSI dispatch -----------------------------------------------------
398
399    fn csi(&mut self, private: Option<char>, params: &[u16], final_byte: char) {
400        // Private-marker sequences (`?...`) are the mode toggles.
401        if private == Some('?') {
402            match final_byte {
403                'h' => self.set_mode(params, true),
404                'l' => self.set_mode(params, false),
405                _ => {} // other private CSI: ignore quietly
406            }
407            return;
408        }
409        if private.is_some() {
410            return; // `>`/`<`/`=` device sequences: ignore quietly
411        }
412
413        // Helpers: VT motion params default to 1 (0 from the parser means the
414        // param was absent → use 1). Erase/line selectors default to 0.
415        let n1 = |i: usize| -> usize { at1(params, i) };
416
417        match final_byte {
418            // Cursor absolute position: CUP / HVP (row;col, 1-based).
419            'H' | 'f' => {
420                let r = n1(0).saturating_sub(1);
421                let c = n1(1).saturating_sub(1);
422                self.move_to(r, c);
423            }
424            'A' => self.move_up(n1(0)),
425            'B' => self.move_down(n1(0)),
426            'C' => self.move_right(n1(0)),
427            'D' => self.move_left(n1(0)),
428            'E' => {
429                // CNL: cursor next line, column 0.
430                self.move_down(n1(0));
431                self.set_col(0);
432                self.wrap_pending = false;
433            }
434            'F' => {
435                // CPL: cursor previous line, column 0.
436                self.move_up(n1(0));
437                self.set_col(0);
438                self.wrap_pending = false;
439            }
440            'G' => {
441                // CHA: cursor to absolute column (1-based).
442                let c = n1(0).saturating_sub(1);
443                self.set_col(c.min(self.cols() - 1));
444                self.wrap_pending = false;
445            }
446            'd' => {
447                // VPA: cursor to absolute row (1-based).
448                let r = n1(0).saturating_sub(1);
449                self.set_row(r.min(self.rows() - 1));
450                self.wrap_pending = false;
451            }
452            'J' => self.erase_display(at0(params, 0)),
453            'K' => self.erase_line(at0(params, 0)),
454            'X' => self.erase_chars(n1(0)),
455            'm' => self.style.apply_sgr(params),
456            'r' => self.set_scroll_region(params),
457            'L' => self.insert_lines(n1(0)),
458            'M' => self.delete_lines(n1(0)),
459            'S' => self.scroll_up(n1(0)),
460            'T' => self.scroll_down(n1(0)),
461            '@' => self.insert_chars(n1(0)),
462            'P' => self.delete_chars(n1(0)),
463            's' => self.save_cursor(),
464            'u' => self.restore_cursor(),
465            _ => {} // unimplemented CSI: ignore quietly
466        }
467    }
468
469    fn set_mode(&mut self, params: &[u16], set: bool) {
470        for &p in params {
471            match p {
472                25 => self.cursor_visible = set,
473                7 => self.autowrap = set, // DECAWM
474                47 | 1047 | 1049 => self.set_alt(set),
475                2026 => self.set_sync(set), // synchronized output
476                _ => {}                     // other private modes: ignore quietly
477            }
478        }
479    }
480
481    fn set_alt(&mut self, enter: bool) {
482        // One logical cursor across both buffers: it moves with the switch.
483        let cursor = self.cursor();
484        if enter && !self.in_alt {
485            // Entering: switch to a cleared alternate buffer.
486            self.alt = Screen::new(self.rows(), self.cols());
487            self.in_alt = true;
488        } else if !enter && self.in_alt {
489            // Leaving: restore the primary buffer as-is.
490            self.in_alt = false;
491        }
492        self.active_mut().set_cursor(cursor);
493        self.wrap_pending = false;
494    }
495
496    /// Enter/leave a synchronized update (DEC mode 2026). On open we snapshot
497    /// the current active buffer as the last complete frame and serve that from
498    /// `screen()` until close, so a mid-redraw frame is never exposed. Nested
499    /// opens keep the first (good) snapshot rather than capturing a half-drawn
500    /// one. On close we drop the snapshot, revealing the now-complete buffer.
501    fn set_sync(&mut self, open: bool) {
502        if open {
503            if !self.in_sync {
504                self.sync_frame = Some(self.active().clone());
505                self.in_sync = true;
506            }
507        } else {
508            self.in_sync = false;
509            self.sync_frame = None;
510        }
511    }
512
513    // --- cursor motion (clamped) -----------------------------------------
514
515    fn move_to(&mut self, r: usize, c: usize) {
516        self.active_mut().set_cursor(Cursor { row: r, col: c });
517        self.wrap_pending = false;
518    }
519
520    fn move_up(&mut self, n: usize) {
521        self.set_row(self.cursor().row.saturating_sub(n));
522        self.wrap_pending = false;
523    }
524
525    fn move_down(&mut self, n: usize) {
526        self.set_row((self.cursor().row + n).min(self.rows() - 1));
527        self.wrap_pending = false;
528    }
529
530    fn move_right(&mut self, n: usize) {
531        self.set_col((self.cursor().col + n).min(self.cols() - 1));
532        self.wrap_pending = false;
533    }
534
535    fn move_left(&mut self, n: usize) {
536        self.set_col(self.cursor().col.saturating_sub(n));
537        self.wrap_pending = false;
538    }
539
540    // --- erase ------------------------------------------------------------
541
542    /// ED: 0 = cursor to end of screen, 1 = start of screen to cursor, 2 = all.
543    fn erase_display(&mut self, mode: u16) {
544        let (rows, cols) = (self.rows(), self.cols());
545        let Cursor { row: cr, col: cc } = self.cursor();
546        let blank = Cell::default();
547        match mode {
548            0 => {
549                for c in cc..cols {
550                    self.active_mut().set(cr, c, blank);
551                }
552                for r in (cr + 1)..rows {
553                    for c in 0..cols {
554                        self.active_mut().set(r, c, blank);
555                    }
556                }
557            }
558            1 => {
559                for r in 0..cr {
560                    for c in 0..cols {
561                        self.active_mut().set(r, c, blank);
562                    }
563                }
564                for c in 0..=cc {
565                    self.active_mut().set(cr, c, blank);
566                }
567            }
568            2 => {
569                for r in 0..rows {
570                    for c in 0..cols {
571                        self.active_mut().set(r, c, blank);
572                    }
573                }
574            }
575            _ => {}
576        }
577    }
578
579    /// EL: 0 = cursor to end of line, 1 = start of line to cursor, 2 = whole line.
580    fn erase_line(&mut self, mode: u16) {
581        let cols = self.cols();
582        let Cursor { row: cr, col: cc } = self.cursor();
583        let blank = Cell::default();
584        let range = match mode {
585            0 => cc..cols,
586            1 => 0..(cc + 1).min(cols),
587            2 => 0..cols,
588            _ => return,
589        };
590        for c in range {
591            self.active_mut().set(cr, c, blank);
592        }
593    }
594
595    /// ECH: erase (blank) `n` cells from the cursor rightward, **without** moving
596    /// the cursor or shifting the rest of the line (unlike DCH, which closes the
597    /// gap). This is a common way to clear a run of cells (e.g. a status line or
598    /// trailing content), so dropping it leaves stale text behind. Clamped to the
599    /// row's right edge.
600    fn erase_chars(&mut self, n: usize) {
601        let cols = self.cols();
602        let Cursor { row: cr, col: cc } = self.cursor();
603        let end = (cc + n.max(1)).min(cols);
604        let blank = Cell::default();
605        for c in cc..end {
606            self.active_mut().set(cr, c, blank);
607        }
608        self.wrap_pending = false;
609    }
610
611    // --- scroll region + line/char insert/delete --------------------------
612
613    /// DECSTBM: set scroll region `top;bottom` (1-based, clamped); resets the
614    /// cursor to the origin. An empty/`0` bottom means the last row.
615    fn set_scroll_region(&mut self, params: &[u16]) {
616        let rows = self.rows();
617        let top = at1(params, 0).saturating_sub(1);
618        let bottom = match at0(params, 1) {
619            0 => rows - 1,
620            b => (b as usize - 1).min(rows - 1),
621        };
622        // Ignore an inverted/empty region, as VT100 does.
623        if top < bottom {
624            self.scroll_top = top;
625            self.scroll_bottom = bottom;
626        }
627        self.active_mut().set_cursor(Cursor::default());
628        self.wrap_pending = false;
629    }
630
631    /// Scroll the scroll region up by `n` lines: top lines lost, blanks in at
632    /// the bottom margin.
633    ///
634    /// One block move in the screen ([`Screen::scroll_rows_up`]), not a copy per
635    /// cell: this runs on every line of output, and the per-cell loop held the
636    /// emulator to ~11 MB/s on a 40x160 grid.
637    fn scroll_up(&mut self, n: usize) {
638        let (top, bottom) = (self.scroll_top, self.scroll_bottom);
639        self.active_mut()
640            .scroll_rows_up(top, bottom, n, Cell::default());
641    }
642
643    /// Scroll the scroll region down by `n` lines: bottom lines lost, blanks in
644    /// at the top margin.
645    fn scroll_down(&mut self, n: usize) {
646        let (top, bottom) = (self.scroll_top, self.scroll_bottom);
647        self.active_mut()
648            .scroll_rows_down(top, bottom, n, Cell::default());
649    }
650
651    /// IL: insert `n` blank lines at the cursor row, within the scroll region;
652    /// lines below shift down and fall off the bottom margin. No-op outside the
653    /// region.
654    fn insert_lines(&mut self, n: usize) {
655        if self.cursor().row < self.scroll_top || self.cursor().row > self.scroll_bottom {
656            return;
657        }
658        let saved_top = self.scroll_top;
659        self.scroll_top = self.cursor().row;
660        self.scroll_down(n);
661        self.scroll_top = saved_top;
662        self.set_col(0);
663        self.wrap_pending = false;
664    }
665
666    /// DL: delete `n` lines at the cursor row, within the scroll region; lines
667    /// below shift up and blanks fill the bottom margin. No-op outside region.
668    fn delete_lines(&mut self, n: usize) {
669        if self.cursor().row < self.scroll_top || self.cursor().row > self.scroll_bottom {
670            return;
671        }
672        let saved_top = self.scroll_top;
673        self.scroll_top = self.cursor().row;
674        self.scroll_up(n);
675        self.scroll_top = saved_top;
676        self.set_col(0);
677        self.wrap_pending = false;
678    }
679
680    /// ICH: insert `n` blank cells at the cursor, shifting the rest of the line
681    /// right off the edge.
682    fn insert_chars(&mut self, n: usize) {
683        let cols = self.cols();
684        let Cursor { row: r, col: start } = self.cursor();
685        let n = n.min(cols - start);
686        for c in (start..cols).rev() {
687            let cell = if c >= start + n {
688                self.active().cell(r, c - n)
689            } else {
690                Cell::default()
691            };
692            self.active_mut().set(r, c, cell);
693        }
694        self.wrap_pending = false;
695    }
696
697    /// DCH: delete `n` cells at the cursor, shifting the rest of the line left
698    /// and filling the tail with blanks.
699    fn delete_chars(&mut self, n: usize) {
700        let cols = self.cols();
701        let Cursor { row: r, col: start } = self.cursor();
702        let n = n.min(cols - start);
703        for c in start..cols {
704            let cell = if c + n < cols {
705                self.active().cell(r, c + n)
706            } else {
707                Cell::default()
708            };
709            self.active_mut().set(r, c, cell);
710        }
711        self.wrap_pending = false;
712    }
713
714    // --- save / restore + ESC --------------------------------------------
715
716    fn save_cursor(&mut self) {
717        self.saved = Some((self.cursor(), self.style));
718    }
719
720    fn restore_cursor(&mut self) {
721        if let Some((cursor, style)) = self.saved {
722            self.active_mut().set_cursor(cursor);
723            self.style = style;
724        }
725        self.wrap_pending = false;
726    }
727
728    fn esc(&mut self, final_byte: u8) {
729        match final_byte {
730            b'7' => self.save_cursor(),    // DECSC
731            b'8' => self.restore_cursor(), // DECRC
732            _ => {}                        // charset selection etc.: ignore quietly
733        }
734    }
735}
736
737/// Tab stops every 8 columns (VT default), with a stop at column 0.
738fn default_tabs(cols: usize) -> Vec<bool> {
739    (0..cols).map(|c| c % 8 == 0).collect()
740}
741
742/// Read a param defaulting a missing/zero value to 1 (VT motion default).
743fn at1(params: &[u16], i: usize) -> usize {
744    match params.get(i).copied().unwrap_or(0) {
745        0 => 1,
746        v => v as usize,
747    }
748}
749
750/// Read a param defaulting a missing value to 0 (erase/selector default).
751fn at0(params: &[u16], i: usize) -> u16 {
752    params.get(i).copied().unwrap_or(0)
753}
754
755/// Copy the overlapping top-left region of `src` into `dst` (for resize).
756fn copy_top_left(src: &Screen, dst: &mut Screen) {
757    let rows = src.rows().min(dst.rows());
758    let cols = src.cols().min(dst.cols());
759    for r in 0..rows {
760        for c in 0..cols {
761            dst.set(r, c, src.cell(r, c));
762        }
763    }
764}