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