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}