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