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