From bed3a85fb0ec37de351f876b6f586aa2264c76b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:10:48 +0000 Subject: [PATCH 1/4] chore(deps): bump vt100 from 0.16.2 to 0.18.1 Bumps [vt100](https://github.com/doy/vt100-rust) from 0.16.2 to 0.18.1. - [Changelog](https://github.com/doy/vt100-rust/blob/main/CHANGELOG.md) - [Commits](https://github.com/doy/vt100-rust/compare/v0.16.2...v0.18.1) --- updated-dependencies: - dependency-name: vt100 dependency-version: 0.18.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3c5c23..4843855 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1477,7 +1477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -1741,9 +1741,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vt100" -version = "0.16.2" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +checksum = "eb05cdd19ae8707bf6a0a269972c07e6358103a7079faec749dec318a5b3dc23" dependencies = [ "itoa", "unicode-width", diff --git a/Cargo.toml b/Cargo.toml index d33b8cf..127a00c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ authors = ["Vyncint Ng "] [workspace.dependencies] portable-pty = "0.9" -vt100 = "0.16" +vt100 = "0.18" thiserror = "2" unicode-width = "0.2" # Needle matching folds both sides to NFC. Terminals draw NFC and NFD From 561fd1f276225fc3ef7f26a02486cbbb5add1a4a Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:08:28 +0700 Subject: [PATCH 2/4] fix(emu): adopt vt100 0.18 and keep history right across resizes vt100 0.18 takes and returns sizes as NonZeroU16; termlens never asks for a zero dimension, so the adapter converts at the edges. Upstream also implements REP, HPA, HVP, DECSCUSR and ED 3, fixes the wide-character resize panic, and resizes as xterm does: a shorter screen pushes rows into history and a taller one pulls them back. The history capture assumed history only grows below its cap, so: - the attribute shadow keeps the primary's history whether styled history is on or not; a taller screen pulls rows back onto both grids, and with none the shadow's came back blank and the lockstep assertion fired; - a shorter history is rows that came back onto the grid, and they leave the copy, newest first; - RIS and ED 3 are announced by the tracker and fed alone, so the copy restarts its mark explicitly. Inferred from the length, an ED 3 followed by more rows than the mark held lost the first of them; - nothing is captured while the alternate screen is up: its grid's zero length read as an emptied history, and every return copied the whole history again (#576). Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- crates/termlens/src/emu/seq.rs | 8 + crates/termlens/src/emu/shadow.rs | 22 +- crates/termlens/src/emu/unhandled.rs | 9 +- crates/termlens/src/emu/vt100.rs | 288 +++++++++++++++++++++++---- crates/termlens/src/terminal.rs | 6 + crates/termlens/tests/record.rs | 3 +- 6 files changed, 290 insertions(+), 46 deletions(-) diff --git a/crates/termlens/src/emu/seq.rs b/crates/termlens/src/emu/seq.rs index 193a6ff..a1e95dc 100644 --- a/crates/termlens/src/emu/seq.rs +++ b/crates/termlens/src/emu/seq.rs @@ -401,6 +401,11 @@ pub(crate) enum SeqEvent { /// scrolled before this byte, because the backend's history is about /// to be rebuilt empty (#391). HardReset, + /// `ED 3` (`CSI 3 J`) completed: erase the saved lines. vt100 empties + /// the active grid's history for it, so this is RIS's case without the + /// reset: the emulator saves the rows that scrolled before this byte, + /// and the history it reads next starts over. + ScrollbackErased, /// The application asked the terminal a question. Query(Query), /// An inline graphics payload completed. Carried out of the tracker @@ -1212,6 +1217,9 @@ impl SeqTracker { b'Z' if self.csi_param_count <= 1 => { return SeqEvent::Tabs(TabOp::Back(count)); } + // ED 3, erase the saved lines. vt100 reads the first + // parameter alone, and so does this. + b'J' if ps == 3 => return SeqEvent::ScrollbackErased, _ => {} } } diff --git a/crates/termlens/src/emu/shadow.rs b/crates/termlens/src/emu/shadow.rs index 8c676e7..4279da5 100644 --- a/crates/termlens/src/emu/shadow.rs +++ b/crates/termlens/src/emu/shadow.rs @@ -94,13 +94,18 @@ pub(super) struct AttrShadow { } impl AttrShadow { - /// `scrollback` is zero unless the terminal retains styled history: - /// text-only history needs the shadow for the visible grid alone, while - /// styled history reads the shadow's scrolled rows in lockstep with the - /// primary's (#146). + /// `scrollback` is the primary's. Styled history reads the shadow's + /// scrolled rows in lockstep with the primary's (#146), and even without + /// it the shadow's grid needs the same history: a resize that makes the + /// screen taller pulls rows back out of history onto the grid, and the + /// shadow must get the same rows back or the two grids diverge. pub(super) fn new(rows: u16, cols: u16, scrollback: usize) -> Self { Self { - parser: ::vt100::Parser::new(rows, cols, scrollback), + parser: ::vt100::Parser::new( + super::vt100::nz(rows), + super::vt100::nz(cols), + scrollback, + ), state: State::Ground, pending: Vec::new(), } @@ -113,7 +118,9 @@ impl AttrShadow { } pub(super) fn set_size(&mut self, rows: u16, cols: u16) { - self.parser.screen_mut().set_size(rows, cols); + self.parser + .screen_mut() + .set_size(super::vt100::nz(rows), super::vt100::nz(cols)); } /// The shadow cell at `(row, col)`, whose bold/italic/underline flags are @@ -462,7 +469,8 @@ mod tests { &b"\x1b]0;title\x1b[31mA\x1b[5mB\x1b[0mC"[..], &b"\x1b[1;2\x1b[31mA"[..], ] { - let mut primary = ::vt100::Parser::new(4, 20, 0); + let mut primary = + ::vt100::Parser::new(super::super::vt100::nz(4), super::super::vt100::nz(20), 0); let mut shadow = AttrShadow::new(4, 20, 0); primary.process(payload); shadow.feed(payload); diff --git a/crates/termlens/src/emu/unhandled.rs b/crates/termlens/src/emu/unhandled.rs index ad3ef7b..0937772 100644 --- a/crates/termlens/src/emu/unhandled.rs +++ b/crates/termlens/src/emu/unhandled.rs @@ -259,7 +259,12 @@ mod tests { use super::*; fn fed(bytes: &[u8]) -> vt100::Parser { - let mut parser = vt100::Parser::new_with_callbacks(4, 20, 0, Unhandled::default()); + let mut parser = vt100::Parser::new_with_callbacks( + super::super::vt100::nz(4), + super::super::vt100::nz(20), + 0, + Unhandled::default(), + ); parser.process(bytes); parser } @@ -374,7 +379,7 @@ mod tests { ); assert_eq!( parser.screen().size(), - (4, 20), + (super::super::vt100::nz(4), super::super::vt100::nz(20)), "the request is recorded, not honoured" ); } diff --git a/crates/termlens/src/emu/vt100.rs b/crates/termlens/src/emu/vt100.rs index 52d0bca..3cb4b29 100644 --- a/crates/termlens/src/emu/vt100.rs +++ b/crates/termlens/src/emu/vt100.rs @@ -3,6 +3,7 @@ use std::borrow::Cow; use std::collections::VecDeque; +use std::num::NonZeroU16; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -44,6 +45,11 @@ pub(crate) struct Vt100Emulator { /// Rows of vt100's own scrollback already copied into `history`, so a /// read that scrolled nothing costs one length check. captured: usize, + /// Set just before the byte that empties vt100's history — RIS, or ED 3 + /// on the main screen — so the capture that byte's feed runs starts the + /// mark over instead of reading the shorter history as rows that came + /// back onto the screen. See `capture_scrolled_rows`. + history_reset: bool, /// Inline graphics payloads, oldest first, behind an `Arc` so a /// snapshot costs one refcount rather than a copy of every image. graphics: Arc>, @@ -84,20 +90,24 @@ impl Vt100Emulator { ) -> Self { Self { parser: ::vt100::Parser::new_with_callbacks( - rows, - cols, + nz(rows), + nz(cols), scrollback_len, Unhandled::default(), ), tracker: SeqTracker::new(capture, cols), - // The shadow keeps history only when its cells will be read. - shadow: AttrShadow::new(rows, cols, if styled_history { scrollback_len } else { 0 }), + // The shadow keeps the same history as the primary, styled or + // not: a resize that makes the screen taller pulls rows back out + // of history onto the grid, and the shadow's grid has to get the + // same rows back to stay in lockstep. + shadow: AttrShadow::new(rows, cols, scrollback_len), history_cells: styled_history.then(VecDeque::new), colors: ColorNormalizer::new(), scrollback_len, frame_started: None, history: VecDeque::new(), captured: 0, + history_reset: false, graphics: Arc::new(Vec::new()), graphics_bytes: 0, capture, @@ -200,6 +210,23 @@ impl Vt100Emulator { } } + /// Feed `before`, then `last` — the byte that completes RIS (`hard`) or + /// ED 3 — on its own. + /// + /// Rows that scrolled since the last capture exist only in vt100's + /// history, and the sequence is about to discard it. The feed is split + /// at its final byte: everything before goes first — one write can carry + /// lines and the reset together — and the capture that feed runs takes + /// those rows. The final byte follows alone, and the capture its feed + /// runs is told the history starts over (#391). ED 3 on the alternate + /// screen empties only that grid's history, which is always empty, so + /// the main screen's mark stands. + fn feed_history_reset(&mut self, before: &[u8], last: &[u8], hard: bool) { + self.feed_staged(before); + self.history_reset = hard || !self.parser.screen().alternate_screen(); + self.feed_staged(last); + } + /// Copy any rows that have scrolled off since the last call. /// /// vt100 models scrollback as a *stateful view*: `set_scrollback(n)` @@ -213,22 +240,41 @@ impl Vt100Emulator { if self.scrollback_len == 0 { return; } - let (rows, cols) = self.parser.screen().size(); + // The alternate screen's grid keeps no history, and every accessor + // reads the grid on display: its length of zero is not the main + // screen's history emptied. Reading it as that restarted the mark, + // and leaving the alternate screen then copied the whole history a + // second time (#576). The main screen's history waits, untouched, + // until the main screen is back. + if self.parser.screen().alternate_screen() { + return; + } + let (rows, cols) = size(self.parser.screen()); let screen = self.parser.screen_mut(); - - // `set_scrollback` clamps to the real history length, so asking for - // more than exists is how we learn how much exists. - screen.set_scrollback(usize::MAX); - let len = screen.scrollback(); - - // Below the cap, history only grows: the new rows are exactly - // `captured..len`, and an unchanged length means nothing scrolled. - // RIS (`ESC c`) is the one write that breaks that monotonicity: - // it rebuilds the screen and empties the history, while the rows - // already copied here survive. A length below the mark is that - // rebuild — the rows now at the front are new and have to be read - // from the start, or everything under the stale mark is skipped - // (#391). + let len = screen.scrollback_len(); + + // Rows only scroll *into* history, so below the cap the new rows + // are exactly `captured..len`, and an unchanged length means nothing + // scrolled. Three writes shorten it instead. RIS and ED 3 empty it, + // while the rows already copied here survive: the mark starts over, + // and the rows now at the front are read from the start (#391). A + // resize that makes the screen taller pulls the newest rows back + // out of history onto the grid (vt100 0.18, as xterm does): those + // rows are on screen again, so they leave the copy too, newest + // first, and the mark follows them down. Nothing else shortens it, + // and a resize applied while the alternate screen was up is settled + // here, the first time the main screen is read again. + if std::mem::take(&mut self.history_reset) { + self.captured = 0; + } else if len < self.captured { + for _ in len..self.captured { + self.history.pop_back(); + if let Some(cells) = &mut self.history_cells { + cells.pop_back(); + } + } + self.captured = len; + } // // At the cap, vt100 evicts from the front and the length stops // changing, so it no longer reveals growth — and an unchanged @@ -243,9 +289,6 @@ impl Vt100Emulator { // 352ms with retention off, 327ms below the cap (free, within // noise), 639ms on this path — under 2x, for a workload well past // what a test drives. - if len < self.captured { - self.captured = 0; - } let at_cap = len == self.scrollback_len; if !at_cap && len == self.captured { screen.set_scrollback(0); @@ -320,7 +363,7 @@ impl Vt100Emulator { fn cursor_position(&self) -> (u16, u16) { let screen = self.parser.screen(); let (row, col) = screen.cursor_position(); - (row, col.min(screen.size().1.saturating_sub(1))) + (row, col.min(size(screen).1.saturating_sub(1))) } } @@ -357,7 +400,7 @@ impl Emulator for Vt100Emulator { fed = i; let screen = self.parser.screen(); let (_, col) = screen.cursor_position(); - let room = usize::from(screen.size().1.saturating_sub(col)); + let room = usize::from(size(screen).1.saturating_sub(col)); let reserve = width.min(room); if reserve > 0 { self.feed(format!("\x1b[{reserve}@").as_bytes()); @@ -392,17 +435,13 @@ impl Emulator for Vt100Emulator { None } SeqEvent::HardReset => { - // Rows that scrolled since the last capture exist only - // in vt100's history, and the reset is about to discard - // it. The feed is split at the reset: everything before - // it goes first — one write can carry lines and the - // reset together — and the capture that feed runs takes - // those rows. The reset then follows alone, and the - // capture its feed runs sees an emptied history, which - // is how the mark learns to start over (#391). - self.feed_staged(&bytes[fed..i]); + self.feed_history_reset(&bytes[fed..i], &bytes[i..=i], true); + fed = i + 1; + None + } + SeqEvent::ScrollbackErased => { + self.feed_history_reset(&bytes[fed..i], &bytes[i..=i], false); fed = i + 1; - self.feed_staged(&bytes[i..=i]); None } SeqEvent::Tabs(op) => { @@ -440,7 +479,7 @@ impl Emulator for Vt100Emulator { let unsupported_overflow = self.parser.callbacks().overflow(); let visual_bells = self.parser.callbacks().visual_bells(); let screen = self.parser.screen(); - let (rows, cols) = screen.size(); + let (rows, cols) = size(screen); let mut cells: Vec = Vec::with_capacity(usize::from(rows) * usize::from(cols)); // The shadow grid is the same shape as the primary — vt100's // attributes never influence geometry, and the two streams differ @@ -587,7 +626,7 @@ impl Emulator for Vt100Emulator { } fn set_size(&mut self, rows: u16, cols: u16) { - self.parser.screen_mut().set_size(rows, cols); + self.parser.screen_mut().set_size(nz(rows), nz(cols)); self.shadow.set_size(rows, cols); self.tracker.set_cols(cols); // A resize can push rows into history on its own. @@ -696,6 +735,19 @@ fn convert_color(color: ::vt100::Color) -> Color { } } +/// vt100 0.18 takes and returns sizes as `NonZeroU16`. termlens never asks +/// for a zero dimension (the builder's floor is 2, a parsed screen's 1), so +/// the clamp to 1 never fires; it keeps the conversion total. +pub(super) fn nz(n: u16) -> NonZeroU16 { + NonZeroU16::new(n).unwrap_or(NonZeroU16::MIN) +} + +/// The screen's `(rows, cols)` as plain numbers. +fn size(screen: &::vt100::Screen) -> (u16, u16) { + let (rows, cols) = screen.size(); + (rows.get(), cols.get()) +} + #[cfg(test)] mod tests { use super::*; @@ -1147,6 +1199,170 @@ mod tests { assert_eq!(emu.snapshot().scrollback_rows(), 0); } + #[test] + fn leaving_the_alternate_screen_copies_no_history_twice() { + // The alternate grid has no history, and its length of zero read as + // the main screen's history emptied: the mark restarted, and every + // return copied the whole history again (#576). Separate feeds, as + // a shell, a full-screen program and the shell again write them. + let mut emu = Vt100Emulator::new(4, 20, 100, crate::graphics::DEFAULT_CAPTURE, false); + feed_all(&mut emu, b"line1\r\nline2\r\nline3\r\nline4\r\nline5\r\n"); + for _ in 0..2 { + feed_all(&mut emu, b"\x1b[?1049halt"); + // The history stays on record while the program runs, too. + assert_eq!(emu.snapshot().scrollback_text(), "line1\nline2"); + feed_all(&mut emu, b"\x1b[?1049l"); + } + let s = emu.snapshot(); + assert_eq!(s.scrollback_text(), "line1\nline2"); + assert_eq!(s.text(), "line3\nline4\nline5\n"); + } + + #[test] + fn a_taller_screen_takes_its_rows_back_from_history() { + // vt100 0.18 resizes as xterm does: a shorter screen with the cursor + // on its last row pushes rows into history, and a taller one pulls + // them back. A row back on the grid leaves the history, or it would + // be on record twice. + let mut emu = Vt100Emulator::new(5, 10, 100, crate::graphics::DEFAULT_CAPTURE, false); + feed_all(&mut emu, b"1\r\n2\r\n3\r\n4\r\nend"); + emu.set_size(3, 10); + let s = emu.snapshot(); + assert_eq!(s.scrollback_text(), "1\n2"); + assert_eq!(s.text(), "3\n4\nend"); + emu.set_size(5, 10); + let s = emu.snapshot(); + assert_eq!(s.scrollback_text(), ""); + assert_eq!(s.text(), "1\n2\n3\n4\nend"); + // Scrolling afterwards copies each row once. + feed_all(&mut emu, b"\r\nf\r\ng"); + assert_eq!(emu.snapshot().full_text(), "1\n2\n3\n4\nend\nf\ng"); + } + + #[test] + fn rows_back_from_history_keep_the_attributes_the_shadow_carries() { + // Blink lives in the attribute shadow, a second parser. Its grid + // must get the same rows back from history as the primary's, or a + // snapshot after the resize trips the shadow's lockstep assertion + // and reads attributes off the wrong rows. Styled history off, the + // default: the shadow used to keep no history then. + for styled in [false, true] { + let mut emu = Vt100Emulator::new(5, 10, 100, crate::graphics::DEFAULT_CAPTURE, styled); + feed_all(&mut emu, b"\x1b[5m1\x1b[0m\r\n2\r\n3\r\n4\r\nend"); + emu.set_size(3, 10); + emu.set_size(5, 10); + let s = emu.snapshot(); + assert_eq!(s.text(), "1\n2\n3\n4\nend", "styled history {styled}"); + assert!( + s.cell(0, 0).is_some_and(|c| c.style().blink), + "styled history {styled}" + ); + assert!( + s.cell(1, 0).is_some_and(|c| !c.style().blink), + "styled history {styled}" + ); + } + } + + #[test] + fn a_resize_while_the_alternate_screen_is_up_is_settled_on_return() { + // The resize reaches the main screen's grid too, out of sight: rows + // come back out of its history while the alternate screen is shown, + // and the copy catches up when the main screen is read again. + let mut emu = Vt100Emulator::new(5, 10, 100, crate::graphics::DEFAULT_CAPTURE, false); + feed_all(&mut emu, b"1\r\n2\r\n3\r\n4\r\nend"); + emu.set_size(3, 10); + assert_eq!(emu.snapshot().scrollback_text(), "1\n2"); + feed_all(&mut emu, b"\x1b[?1049hfull screen"); + emu.set_size(5, 10); + feed_all(&mut emu, b"\x1b[?1049l"); + let s = emu.snapshot(); + assert_eq!(s.scrollback_text(), ""); + assert_eq!(s.full_text(), "1\n2\n3\n4\nend"); + } + + #[test] + fn erasing_the_saved_lines_restarts_the_mark_and_keeps_the_copy() { + // ED 3 (`CSI 3 J`, which `clear` sends) empties vt100's history. More + // rows scrolling in the same write than the mark held must all be + // copied: read by length alone, the history looked like it had + // grown past the mark, and the first rows after the erase were + // skipped. The rows copied before the erase are kept, as for RIS. + let stream = b"a\r\nb\r\nc\r\nd\r\ne\r\n\x1b[3J1\r\n2\r\n3\r\n4\r\n5\r\n6"; + let erase = stream.iter().position(|&b| b == 0x1b).unwrap(); + // One feed, split before the erase, inside it, and after it. + for split in [stream.len(), erase, erase + 2, erase + 4, erase + 6] { + let mut emu = Vt100Emulator::new(4, 10, 100, crate::graphics::DEFAULT_CAPTURE, false); + feed_all(&mut emu, &stream[..split]); + feed_all(&mut emu, &stream[split..]); + let s = emu.snapshot(); + assert_eq!( + s.scrollback_text(), + "a\nb\nc\nd\ne\n1\n2", + "split at {split}" + ); + assert_eq!(s.text(), "3\n4\n5\n6", "split at {split}"); + } + } + + #[test] + fn erasing_saved_lines_on_the_alternate_screen_leaves_the_main_history() { + // ED 3 empties the history of the grid on display, and the alternate + // grid has none: the main screen's history and its mark are untouched. + let mut emu = Vt100Emulator::new(4, 10, 100, crate::graphics::DEFAULT_CAPTURE, false); + feed_all(&mut emu, b"a\r\nb\r\nc\r\nd\r\ne\r\n"); + feed_all(&mut emu, b"\x1b[?1049h\x1b[3Jtui\x1b[?1049l"); + let s = emu.snapshot(); + assert_eq!(s.scrollback_text(), "a\nb"); + assert_eq!(s.text(), "c\nd\ne\n"); + } + + #[test] + fn narrowing_through_a_wide_character_leaves_a_row_that_can_be_erased() { + // vt100 0.16 kept the cut character marked wide after a resize took + // its second column, and the next write that cleared the row read + // past its end and panicked the emulator (#577). Each eraser is + // a separate way of reaching that cell. + for eraser in [ + &b"\r\x1b[K"[..], + b"\x1b[J", + b"\x1b[1;3H\x1b[P", + b"\x1b[1;3H\x1b[X", + b"\x1b[1;3Hx", + ] { + let mut emu = Vt100Emulator::new(3, 4, 0, crate::graphics::DEFAULT_CAPTURE, false); + feed_all(&mut emu, "ab\u{6f22}".as_bytes()); + emu.set_size(3, 3); + feed_all(&mut emu, eraser); + let s = emu.snapshot(); + assert_eq!(s.cols(), 3, "{eraser:?}"); + assert!( + !s.row_text(0).contains('\u{6f22}'), + "{eraser:?}: {:?}", + s.row_text(0) + ); + } + } + + #[test] + fn rep_repeats_the_last_character_drawn() { + // REP (`CSI n b`), which the xterm-256color terminfo advertises and + // ncurses uses for runs outside a UTF-8 locale (#578): vt100 0.17 + // implements it. The character repeated is the one drawn, so a + // DEC Special Graphics line repeats as the line. + let emu = emu_with(b"x\x1b[5bEND"); + let s = emu.snapshot(); + assert_eq!(s.row_text(0), "xxxxxxEND "); + assert_eq!(s.unsupported(), [] as [&str; 0]); + let emu = emu_with(b"\x1b(0q\x1b[3b\x1b(B|"); + assert_eq!( + emu.snapshot().row_text(0), + "\u{2500}\u{2500}\u{2500}\u{2500}| " + ); + let emu = emu_with("\u{6f22}\x1b[2b".as_bytes()); + assert_eq!(emu.snapshot().row_text(0), "\u{6f22}\u{6f22}\u{6f22} "); + } + #[test] fn history_rows_keep_the_width_they_were_captured_at() { // Documented decision (`Terminal::resize`): history is not reflowed diff --git a/crates/termlens/src/terminal.rs b/crates/termlens/src/terminal.rs index af78807..c5730ef 100644 --- a/crates/termlens/src/terminal.rs +++ b/crates/termlens/src/terminal.rs @@ -4098,6 +4098,12 @@ impl Terminal { /// on. The visible grid is not reflowed either: the backend clips or pads /// each row to the new width, which is the stale-frame trap above. /// + /// A change of *height* moves rows the way xterm does. A shorter screen + /// drops the rows below the cursor first, then pushes rows off the top + /// into history; a taller one pulls the newest history rows back onto + /// the grid, and they leave the history again, so no row is on record + /// twice. On the alternate screen nothing moves into history at all. + /// /// # After the child exits /// /// Refused, the same way [`send`](Self::send) is. Once the child has diff --git a/crates/termlens/tests/record.rs b/crates/termlens/tests/record.rs index a1df3a4..5c0546c 100644 --- a/crates/termlens/tests/record.rs +++ b/crates/termlens/tests/record.rs @@ -261,7 +261,8 @@ fn the_asciicast_replays_every_row_of_every_frame() -> termlens::Result<()> { // Replay it into a terminal of the recorded size, exactly as a // player would, and hold the result against the frame it came from. let (cols, rows) = frame.size(); - let mut replay = vt100::Parser::new(rows, cols, 0); + let nz = |n: u16| std::num::NonZeroU16::new(n).expect("a recorded size is never zero"); + let mut replay = vt100::Parser::new(nz(rows), nz(cols), 0); replay.process(data.as_bytes()); let played: Vec = replay .screen() From 73316f20364ffb9695f0033a40f5de843cacf278 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:08:28 +0700 Subject: [PATCH 3/4] docs: describe vt100 0.18 in the backend notes and on resize BACKENDS.md measures 0.18.1, DESIGN.md says how height changes, RIS and ED 3 move history, and LIMITATIONS.md that history outlives ED 3. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- docs/BACKENDS.md | 12 ++++++------ docs/DESIGN.md | 13 ++++++++++--- docs/LIMITATIONS.md | 4 +++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index a944de2..64775ed 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -11,13 +11,13 @@ clusters rendering however the backend renders them. ## The candidates -| | `vt100` 0.16.2 | `alacritty_terminal` 0.26.0 | `wezterm-term` | +| | `vt100` 0.18.1 | `alacritty_terminal` 0.26.0 | `wezterm-term` | |---|---|---|---| | On crates.io | yes | yes | **no** — git-only in the wezterm workspace; the `tattoy-wezterm-term` fork is a third party's | | Licence | MIT | Apache-2.0 only | MIT | -| MSRV | 1.70 | 1.85.0, moves with Alacritty releases | wezterm's, moves with wezterm | -| Source size | 3,950 lines | 11,739 lines | larger; a workspace of crates (`wezterm-cell`, `-escape-parser`, `-surface`, `termwiz`) | -| Dependency tree (`default-features = false`) | 9 crates: `vte`, `unicode-width`, `itoa`, and theirs | 27 crates: `vte`, `polling`, `rustix`, `rustix-openpty`, `signal-hook`, `parking_lot`, `regex-automata`, `base64`, `home`… — the tty/event-loop half is not feature-gated | `image`, `serde`, `terminfo`, `url`, `lru`, `miniz_oxide`, `unicode-normalization`, `wezterm-bidi`… | +| MSRV | 1.85 | 1.85.0, moves with Alacritty releases | wezterm's, moves with wezterm | +| Source size | 5,111 lines | 11,739 lines | larger; a workspace of crates (`wezterm-cell`, `-escape-parser`, `-surface`, `termwiz`) | +| Dependency tree (`default-features = false`) | 5 crates: `vte`, `unicode-width`, `itoa`, `arrayvec`, `memchr` | 27 crates: `vte`, `polling`, `rustix`, `rustix-openpty`, `signal-hook`, `parking_lot`, `regex-automata`, `base64`, `home`… — the tty/event-loop half is not feature-gated | `image`, `serde`, `terminfo`, `url`, `lru`, `miniz_oxide`, `unicode-normalization`, `wezterm-bidi`… | | Headless driving | `Parser::process(bytes)`; `Callbacks` trait for what it does not handle | `Term::new(config, dims, EventListener)` + `vte::ansi::Processor::advance`; `VoidListener` exists | `Terminal::new` with a `TerminalConfiguration` and a writer; designed around a renderer | | SGR blink (`5`/`6`) | dropped | **dropped** — no `Flags::BLINK`; `Attr::Blink` is not handled | tracked | | SGR conceal (`8`) | dropped | tracked (`HIDDEN`) | tracked | @@ -25,10 +25,10 @@ clusters rendering however the backend renders them. | Extra attributes | — | double/curly/dotted/dashed underline, underline colour | the same and more | | Mouse modes | one value (the last set) | distinct flags: `MOUSE_REPORT_CLICK`, `MOUSE_DRAG`, `MOUSE_MOTION`, `SGR_MOUSE`; no 1005 | distinct | | Kitty keyboard, focus, bracketed paste, alt screen, insert, origin, LNM | partial (see below) | all as `TermMode` flags | all | -| Grapheme clusters | one `char` + width; zero-width chars appended to the previous cell | one `char` + `zerowidth: Vec`, same shape | `finl_unicode` segmentation; the strongest of the three | +| Grapheme clusters | one `char` + width; zero-width chars appended to the previous cell, and VS16 widens an emoji to two columns | one `char` + `zerowidth: Vec`, same shape | `finl_unicode` segmentation; the strongest of the three | | Scrollback reflow on resize | no | yes (`grid/resize.rs`) | yes | | Unhandled-sequence hook | `Callbacks::unhandled_*` — the basis of `Screen::unsupported` | none; unknown sequences are silently ignored | none | -| Release cadence | slow: 0.16.2 (July 2025) is the newest | with Alacritty, several a year, breaking freely | with wezterm | +| Release cadence | slow: 0.16.2 (July 2025), then 0.17.0 and 0.18.0 together (October 2026) | with Alacritty, several a year, breaking freely | with wezterm | ## What termlens already does in front of the backend diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b4c5272..be4dd6d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -586,7 +586,14 @@ resize is a cost no assertion has asked for; the other honest option, discarding history on resize, would cost a suite that exercises a responsive layout everything it had asserted on. Rows captured after a resize have the new width, so `full_text()` can hold both -geometries, and says so where a caller meets it. The alternate screen +geometries, and says so where a caller meets it. A change of *height* +moves rows the way xterm does (vt100 0.18): a shorter screen with the +cursor on its last row pushes its top rows into history, and a taller one +pulls the newest back onto the grid — where they leave the history, so no +row is on record twice. RIS and ED 3 (`CSI 3 J`, which `clear` sends) +empty the terminal's own history; the rows already retained survive both, +since a record that forgets what a test may still assert on is worse than +one a real terminal would have cleared. The alternate screen accumulates no history at all (vt100 gives the alternate grid none), which is what makes retention safe to default on for full-screen TUIs. @@ -962,7 +969,7 @@ But it is an implementation detail: ### The attribute shadow -vt100 0.16's `Attrs` is `{ fgcolor, bgcolor, mode: u8 }` and its SGR +vt100's `Attrs` (0.16 through 0.18) is `{ fgcolor, bgcolor, mode: u8 }` and its SGR dispatch handles only `0 1 2 3 4 7 22 23 24 27` plus the colour params. `5`/`6` (blink), `8` (conceal), `9` (strikethrough) and their resets never reach a cell. That is not a missing nicety: **a test asserting that a @@ -972,7 +979,7 @@ secret in clear**, because the two renderings are identical in the grid and crate where a green test certifies the bug it was written to catch. Upstream is the right home for the fix — three spare bits are sitting in -`mode: u8` — but 0.16.2 (July 2025) is the newest release, `Attrs` has no +`mode: u8` — but 0.18.1 still drops all three, `Attrs` has no public setter reachable from `Callbacks::unhandled_csi`, and a published crate cannot carry a `[patch.crates.io]`. So the three attributes are recovered from **a second `vt100::Parser` fed the same byte stream with diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 7e80777..a6aad81 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -27,7 +27,9 @@ CHANGELOG entry. scrolled-off row has no styles and no cell addressing — and is **not reflowed** by a `resize`: rows keep the width they were captured at, by decision (`Terminal::resize` says why). The visible grid stays the - fully-featured surface. + fully-featured surface. History also outlives what clears a terminal's + own: RIS and ED 3 (`CSI 3 J`, which `clear` sends) empty a terminal's + scrollback, and termlens keeps the rows it had already retained. ## Character sets, modes and tab stops From 57a8cba788fd8435e555ffe134f6d345ddc916d1 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:10:57 +0700 Subject: [PATCH 4/4] docs: log the vt100 0.18 changes and the REP insert-mode limit CHANGELOG entries for #576, #577, #578 and the backend move, the insert-mode exception for REP in LIMITATIONS.md, and the two comments that named vt100 0.16 as the version whose behaviour they rely on. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ crates/termlens/src/emu/shadow.rs | 5 +++-- crates/termlens/src/emu/vt100.rs | 2 +- docs/LIMITATIONS.md | 10 ++++++---- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a41c64..698e438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,39 @@ reads that marker. ## [Unreleased] +### Changed + +- The terminal emulator is vt100 0.18.1, up from 0.16.2 (#608), which brings + the screen a test sees closer to xterm's in ways a snapshot can show. A + change of height moves rows as xterm does: a shorter screen drops the rows + below the cursor, then pushes rows off the top into history, and a taller + one pulls the newest back onto the grid. 0.16 cut rows off the bottom, so + after a shrink the line the cursor was on could vanish. A VS16 + emoji-presentation sequence (`⚠️`, `❤️`) is two columns wide, as + `unicode-width` and ratatui lay it out; it was one, which shifted the rest + of the row against the application's own layout. HVP (`CSI f`) and HPA + (`` CSI ` ``) move the cursor, which they silently did not; ED 3 + (`CSI 3 J`) empties the terminal's history, and termlens keeps the rows it + had already retained, as it does across RIS; IL and DL with the cursor + outside the scroll region do nothing, where they used to edit rows outside + it. None of these is listed by `unsupported()` any more. The MSRV stays + 1.85. + ### Fixed +- Leaving the alternate screen no longer copies the whole retained history + a second time (#576). The alternate screen's grid has no history of its + own, and its empty one was read as the main screen's history emptied, so + every trip through a full-screen program repeated every scrolled-off row + in `scrollback_text()`, `full_text()` and the styled history. +- Narrowing the terminal through a wide character and then erasing that row + no longer panics the emulator (#577); vt100 0.18 keeps the row consistent. +- REP (`CSI n b`) repeats the last character drawn (#578). The + `xterm-256color` terminfo termlens sets advertises it, and ncurses uses it + for runs of one character outside a UTF-8 locale, so a horizontal line or + a row of `=` drew as a single character. Under insert mode the repeated + characters still overwrite rather than insert, as `docs/LIMITATIONS.md` + says. - `termlens` no longer panics with exit code 101 (#594). A closed or full stderr (`2>&1 | true`, `2>/dev/full`) made every diagnostic and the `inspect` trailer panic; the write is now checked, and a trailer that cannot diff --git a/crates/termlens/src/emu/shadow.rs b/crates/termlens/src/emu/shadow.rs index 4279da5..db355f3 100644 --- a/crates/termlens/src/emu/shadow.rs +++ b/crates/termlens/src/emu/shadow.rs @@ -1,7 +1,8 @@ //! Recovering the three SGR attributes `vt100` drops. //! -//! `vt100` 0.16's `Attrs` is `{ fgcolor, bgcolor, mode: u8 }` and its SGR -//! dispatch handles only `0 1 2 3 4 7 22 23 24 27` plus the colour params. +//! `vt100`'s `Attrs` (0.16 through 0.18) is `{ fgcolor, bgcolor, mode: u8 }` +//! and its SGR dispatch handles only `0 1 2 3 4 7 22 23 24 27` plus the +//! colour params. //! `5`/`6` (blink), `8` (conceal) and `9` (strikethrough), and their resets //! `25`/`28`/`29`, never reach a cell — so three distinct renderings collapse //! into one value, and a test asserting that a password field is masked diff --git a/crates/termlens/src/emu/vt100.rs b/crates/termlens/src/emu/vt100.rs index 3cb4b29..ebcbcb3 100644 --- a/crates/termlens/src/emu/vt100.rs +++ b/crates/termlens/src/emu/vt100.rs @@ -688,7 +688,7 @@ fn is_printable(b: u8) -> bool { /// What the reader substitutes for a byte it could not decode (`utf8.rs`). const REPLACEMENT: &str = "\u{FFFD}"; /// What the grid is fed in its place. vt100's `print` drops U+FFFD outright -/// (0.16, `perform.rs`), so the replacement would vanish exactly as the +/// (0.16 through 0.18, `perform.rs`), so the replacement would vanish as the /// invalid byte did (#217). U+FDD0 is a Unicode noncharacter — reserved for /// internal use like this, never for interchange, so no application has any /// business emitting one — and vt100 draws it as an ordinary width-1 cell. diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index a6aad81..b6c2557 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -44,10 +44,12 @@ CHANGELOG entry. - **Insert mode (`IRM`, `CSI 4 h`) pushes the rest of the row right**, as ncurses's `insch` expects on a terminal advertising `smir`; `RIS` and `DECSTR` clear it, and `Screen::insert_mode()` reports an application - that left it on. Other ANSI modes the backend drops (`LNM` and the rest) - are not modelled — and *say so*: `Screen::unsupported()` lists every - sequence the emulator did not implement, in the form `^[[20h`, so a test - can tell a plausible-looking wrong grid from a right one. + that left it on. One exception: the characters REP (`CSI n b`) repeats + overwrite the row even in insert mode, where xterm inserts them. Other + ANSI modes the backend drops (`LNM` and the rest) are not modelled — and + *say so*: `Screen::unsupported()` lists every sequence the emulator did + not implement, in the form `^[[20h`, so a test can tell a + plausible-looking wrong grid from a right one. - **A soft-wrapped line is two rows.** `contains` and `find` read the grid row by row and do not span the wrap; `Screen::row_wrapped(row)` reports the backend's record of where a line wrapped, and