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