From 345699b736c7040605b70af2314d73c54eba8db7 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:48:25 +0700 Subject: [PATCH] fix: honour custom tab stops (HTS, TBC, CHT, CBT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab stops were the vt100 backend's hardcoded eight, and `HTS` (`ESC H`), `TBC` (`CSI g`, `CSI 3 g`), `CHT` (`CSI I`) and `CBT` (`CSI Z`) all reached a dispatch table with no entry for them and vanished — though `hts`, `tbc` and `cbt` sit in the terminfo entry termlens hands every child. An application laying a table out with its own stops drew every column in the wrong place, and did it silently: every character was still present, so `contains` passed and only a column assertion or a whole-screen snapshot could catch it. The stop set now lives in the sequence tracker beside the character sets, one flag per column. The cursor column every one of these operations is relative to belongs to the grid, so the tracker classifies and the emulator resolves, rewriting a motion as `CHA` (`CSI n G`) — a sequence the backend does dispatch, the same way DEC Special Graphics became a glyph substitution. Plain `HT` is rewritten too, or the backend's eight and a custom stop would disagree the moment an application set one. `RIS` and `DECSTR` restore the every-eighth default, and a resize extends the set into its new columns with that same pattern while leaving the stops it already had alone. Closes #262 Co-authored-by: Alexis Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- CHANGELOG.md | 17 ++ README.md | 8 + crates/termlens/src/emu/seq.rs | 445 ++++++++++++++++++++++++++++--- crates/termlens/src/emu/vt100.rs | 142 +++++++++- crates/termlens/tests/tabs.rs | 239 +++++++++++++++++ docs/DESIGN.md | 22 ++ 6 files changed, 840 insertions(+), 33 deletions(-) create mode 100644 crates/termlens/tests/tabs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 391ec8b..95c18de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,23 @@ listed under a **Changed** or **Removed** heading. ### Fixed +- **Custom tab stops are honoured: `HTS`, `TBC`, `CHT` and `CBT` do what + they say.** Stops were fixed at every eighth column — the backend's + hardcoded value — and all four escapes reached a dispatch table with no + entry for them and vanished, though `hts`, `tbc` and `cbt` are all in the + terminfo entry termlens hands every child. An application that laid a + table out by setting its own stops and tabbing between them drew every + column in the wrong place, and did it *silently*: the characters were all + present, so `contains` still passed and only a column assertion or a + whole-screen snapshot could catch it. The stop set now lives in the + sequence tracker beside the character sets, and the emulator rewrites each + motion as a `CHA` the backend does dispatch. Plain `\t` is rewritten too, + or the backend's eight and a custom stop would disagree the moment one was + set. `RIS` and `DECSTR` restore the every-eighth default; a resize extends + the set into its new columns with that same pattern and leaves the stops + it already had alone. `CBT` is also what `Shift-Tab` sends, so an + application echoing one now moves. (#262) + - **docs.rs labels APIs gated by the `decode` and `insta` features.** Optional items no longer appear to be available in every build. (#258) diff --git a/README.md b/README.md index 6c92657..e58bc0a 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,14 @@ design. termlens's position: ROMs, the other national sets — is acknowledged and reads as ASCII. `DECSC`/`DECRC` save and restore this state with the cursor. Locking shifts remain G0/G1 only (`LS2`/`LS3` are not modelled). +- **Tab stops are the application's to set.** `HTS` (`ESC H`), `TBC` + (`CSI g`, `CSI 3 g`), `CHT` (`CSI I`) and `CBT` (`CSI Z`) all work, and a + plain `\t` honours whatever stops are set rather than a fixed eight. + `RIS` and `DECSTR` restore the every-eighth default, and a resize extends + the set into its new columns with that pattern while leaving existing + stops alone. Back-tab moves to the nearest stop *strictly* left of the + cursor, as xterm does. Only `TBC 0` and `TBC 3` are modelled; the rest of + that family clears *line* tab stops, which this crate has no notion of. - `wait_frame` needs the application to bracket its repaints in DEC 2026 synchronized updates, and only the last 8 completed frames are retained; everything else waits with `wait_until`, under the three rules in diff --git a/crates/termlens/src/emu/seq.rs b/crates/termlens/src/emu/seq.rs index 0a5a28c..8d3874c 100644 --- a/crates/termlens/src/emu/seq.rs +++ b/crates/termlens/src/emu/seq.rs @@ -38,6 +38,19 @@ //! emulator rewrites: the bytes the //! parsers see are then a translated stream rather than a sub-slice of the //! read, which `emu/vt100.rs` stages. +//! +//! The same split holds the **tab stops** (see [`TabStops`]). vt100 has a +//! hardcoded eight and no way to be told otherwise, so `HTS`, `TBC`, `CHT` +//! and `CBT` reached its dispatch table and vanished — and an application +//! that lays a table out by setting its own stops, which is what the +//! capabilities are for, drew every column in the wrong place. The set +//! lives here; the *cursor column* every one of those operations needs +//! lives in the grid, so the tracker emits a [`TabOp`] and the emulator +//! resolves it against the position it holds, rewriting a motion as `CHA` +//! (`CSI n G`) — a sequence the backend does dispatch, the same way DEC +//! Special Graphics became a glyph substitution. Plain `HT` is rewritten +//! too, or vt100's fixed eight and these stops would disagree the moment an +//! application set one. use std::sync::Arc; @@ -120,6 +133,161 @@ pub(crate) fn decode_base64(input: &[u8]) -> Option> { Some(out) } +/// Columns between the tab stops a terminal powers on with. Eight is the +/// value every terminfo, every shell and vt100's own hardcoded `col_tab` is +/// written against. +const TAB_INTERVAL: u16 = 8; + +/// Whether the power-on layout has a stop at `col`. +/// +/// Column 0 is one of them, as it is in xterm's `TabReset` and alacritty's +/// `TabStops::new`. Forward motion scans strictly right of the cursor and so +/// can never land there; it matters only to `CBT`, which would clamp to 0 +/// anyway. Keeping it makes the set the plain "every eighth column" the +/// resize rule extends, with no column that has to be special-cased. +fn default_stop(col: u16) -> bool { + col % TAB_INTERVAL == 0 +} + +/// Where the tab stops are: one flag per column, `cols` wide. +/// +/// The whole set lives in the tracker because vt100 holds no tab state at +/// all — its `HT` is a fixed eight — so there is nothing here to keep in +/// step with the backend, only a column to hand back to it. +#[derive(Debug)] +struct TabStops { + stops: Vec, +} + +impl TabStops { + fn new(cols: u16) -> Self { + Self { + stops: (0..cols).map(default_stop).collect(), + } + } + + /// The rightmost column, which every motion clamps to. + /// + /// Zero for an empty set, which makes both motions no-ops rather than + /// panics; the builder floors a terminal at two columns, so an empty set + /// only ever arises in a unit test. + fn last_column(&self) -> u16 { + u16::try_from(self.stops.len()) + .unwrap_or(u16::MAX) + .saturating_sub(1) + } + + /// Grow or shrink to `cols`. + /// + /// **Decision:** columns the grid did not have before get the power-on + /// every-eighth pattern, and stops inside the old width are left exactly + /// as they were. A resize is not a reset — an application that set its + /// own stops and then had its window widened would otherwise find them + /// gone — and there is no better answer for territory that never + /// existed than the layout the terminal would have powered on with. + /// Narrowing drops the columns that no longer exist; widening again does + /// not bring their stops back, since the set no longer holds them. This + /// is what alacritty does, and it is the simple end of the trade. + fn set_cols(&mut self, cols: u16) { + let old = u16::try_from(self.stops.len()).unwrap_or(u16::MAX); + self.stops.resize(usize::from(cols), false); + for col in old..cols { + self.stops[usize::from(col)] = default_stop(col); + } + } + + /// Back to the power-on layout, for `RIS` and `DECSTR`. + fn reset(&mut self) { + let cols = u16::try_from(self.stops.len()).unwrap_or(u16::MAX); + for col in 0..cols { + self.stops[usize::from(col)] = default_stop(col); + } + } + + /// `HTS`: a stop at `col`. Out-of-range columns are dropped rather than + /// clamped — clamping would set a stop the application did not ask for. + fn set(&mut self, col: u16) { + if let Some(stop) = self.stops.get_mut(usize::from(col)) { + *stop = true; + } + } + + /// `TBC 0`: clear the stop at `col`. + fn clear(&mut self, col: u16) { + if let Some(stop) = self.stops.get_mut(usize::from(col)) { + *stop = false; + } + } + + /// `TBC 3`: clear every stop. Forward motion then runs to the last + /// column and back-tab to column 0, which is what a terminal with no + /// stops does. + fn clear_all(&mut self) { + self.stops.fill(false); + } + + /// The most steps a motion can usefully take: one per column. + fn steps_bound(&self) -> u16 { + u16::try_from(self.stops.len()).unwrap_or(u16::MAX) + } + + /// The column `count` stops right of `col`, or the last column if the + /// stops run out first. + fn forward(&self, col: u16, count: u16) -> u16 { + let last = self.last_column(); + // vt100 reports a column past the end while a wrap is pending, and + // clamps it in `col_tab`; clamping here keeps the rewrite agreeing + // with the move it replaces. + let mut at = col.min(last); + // Every step moves at least one column, so more steps than there are + // columns cannot move further — and `CSI 65535 I` is a parameter an + // application is free to send. + for _ in 0..count.min(self.steps_bound()) { + match ((at.saturating_add(1))..=last).find(|&c| self.stops[usize::from(c)]) { + Some(next) => at = next, + None => return last, + } + } + at + } + + /// The column `count` stops left of `col`, or column 0 if the stops run + /// out first. + /// + /// Each step goes to the nearest stop *strictly* left of where it + /// started, which is what xterm and alacritty both do: a cursor sitting + /// one past a stop it just tabbed to and then wrote over moves to that + /// stop, not to the one before it. + fn back(&self, col: u16, count: u16) -> u16 { + let mut at = col.min(self.last_column()); + for _ in 0..count.min(self.steps_bound()) { + match (0..at).rev().find(|&c| self.stops[usize::from(c)]) { + Some(prev) => at = prev, + None => return 0, + } + } + at + } +} + +/// A tab-stop operation the tracker recognized but cannot carry out alone: +/// every one of them is relative to the cursor column, which lives in the +/// grid. The emulator resolves it against the position it holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TabOp { + /// `HTS` (`ESC H`): set a stop at the cursor column. + Set, + /// `TBC` / `TBC 0` (`CSI g`): clear the stop at the cursor column. + ClearAtCursor, + /// `TBC 3` (`CSI 3 g`): clear every stop. + ClearAll, + /// `HT` (`\t`) or `CHT` (`CSI n I`): forward `n` stops. + Forward(u16), + /// `CBT` (`CSI n Z`): back `n` stops. Also what an application echoing + /// `Shift-Tab` emits on its output side. + Back(u16), +} + /// Which glyph set a G0–G3 designation currently names. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Charset { @@ -234,6 +402,9 @@ pub(crate) enum SeqEvent { /// rather than stored in it because the placement — where the cursor /// stood — is a fact about the grid, which only the emulator holds. Graphics(Box), + /// A tab-stop operation completed, and needs the cursor column to + /// finish — carried out for the same reason a graphics placement is. + Tabs(TabOp), } /// A terminal query the application issued. The tracker classifies; @@ -487,10 +658,14 @@ pub(crate) struct SeqTracker { /// the whole payload or the start of one. dcs_body: Vec, dcs_body_full: bool, + /// Where the tab stops are. Sized to the grid, so `set_cols` follows + /// every resize. + tabs: TabStops, } impl SeqTracker { - pub(crate) fn new(capture: usize) -> Self { + /// `cols` sizes the tab-stop set; everything else here is width-agnostic. + pub(crate) fn new(capture: usize, cols: u16) -> Self { Self { state: State::Ground, utf8_remaining: 0, @@ -540,6 +715,35 @@ impl SeqTracker { dcs_head_len: 0, dcs_body: Vec::new(), dcs_body_full: true, + tabs: TabStops::new(cols), + } + } + + /// Resize the tab-stop set with the grid. See [`TabStops::set_cols`] for + /// what happens to the columns on either side of the change. + pub(crate) fn set_cols(&mut self, cols: u16) { + self.tabs.set_cols(cols); + } + + /// Carry out a [`TabOp`] at cursor column `col`, returning the column the + /// cursor must move to when the operation is a motion and `None` when it + /// only edits the set. + pub(crate) fn tab_op(&mut self, op: TabOp, col: u16) -> Option { + match op { + TabOp::Set => { + self.tabs.set(col); + None + } + TabOp::ClearAtCursor => { + self.tabs.clear(col); + None + } + TabOp::ClearAll => { + self.tabs.clear_all(); + None + } + TabOp::Forward(count) => Some(self.tabs.forward(col, count)), + TabOp::Back(count) => Some(self.tabs.back(col, count)), } } @@ -715,6 +919,7 @@ impl SeqTracker { self.cursor_style = None; self.focus_events = false; self.mouse_tracking = 0; + self.tabs.reset(); } /// Apply the final byte of an `ESC ( ) * + Ps` designation. @@ -939,6 +1144,40 @@ impl SeqTracker { } } + // Tab stops: `TBC` (`CSI g`), `CHT` (`CSI n I`) and `CBT` + // (`CSI n Z`). All three are relative to the cursor column, so they + // leave as requests rather than as changes. + if self.csi_prefix == 0 { + // An omitted parameter is 0, and a 0 count means 1 — vt100's own + // `canonicalize_params_1`, so a rewritten move and the move it + // replaces read their parameter the same way. + let ps = if params_empty { + 0 + } else { + self.csi_first_param + }; + let count = u16::try_from(ps).unwrap_or(u16::MAX).max(1); + match b { + // Only `0` (this column) and `3` (all) are modelled. The + // rest of the family clears *line* tab stops, which this + // crate has no notion of; ignoring them beats inventing one. + b'g' if self.csi_param_count <= 1 => { + return match ps { + 0 => SeqEvent::Tabs(TabOp::ClearAtCursor), + 3 => SeqEvent::Tabs(TabOp::ClearAll), + _ => SeqEvent::None, + }; + } + b'I' if self.csi_param_count <= 1 => { + return SeqEvent::Tabs(TabOp::Forward(count)); + } + b'Z' if self.csi_param_count <= 1 => { + return SeqEvent::Tabs(TabOp::Back(count)); + } + _ => {} + } + } + // Queries. Classification only — answering policy lives upstream. let query = match (self.csi_prefix, b) { (0, b'n') if single(6) => Some(Query::CursorPosition { private: false }), @@ -1242,6 +1481,7 @@ impl SeqTracker { const CAN: u8 = 0x18; const SUB: u8 = 0x1a; const BEL: u8 = 0x07; + const HT: u8 = 0x09; const SO: u8 = 0x0e; const SI: u8 = 0x0f; @@ -1266,6 +1506,12 @@ impl SeqTracker { match b { SO => self.shifted_out = true, SI => self.shifted_out = false, + // Plain `HT` has to come through here as well as + // `CHT`, or vt100's fixed eight would keep answering + // it and the two would disagree the moment an + // application set a stop of its own — a table drawn + // half from each. + HT => event = SeqEvent::Tabs(TabOp::Forward(1)), _ => {} } // Anything that is not a C0 control or DEL. Named once @@ -1360,6 +1606,7 @@ impl SeqTracker { self.reset_charsets(); self.saved_charsets = None; self.mouse_tracking = 0; + self.tabs.reset(); self.close_link(); State::Ground } @@ -1394,6 +1641,14 @@ impl SeqTracker { } State::Ground } + // HTS (`ESC H`): a tab stop at the cursor column, which the + // emulator supplies. `hts` is in the terminfo entry every + // child is handed, so an application laying out a table is + // entitled to expect it. + b'H' => { + event = SeqEvent::Tabs(TabOp::Set); + State::Ground + } // SS2 / SS3: invoke G2 / G3 for the next character only. // These are two-character escapes in the *output* stream; // `ESC O A` as a DECCKM cursor key is what we *send*, a @@ -1507,8 +1762,13 @@ mod tests { use super::*; use crate::graphics::GraphicsFormat; + /// The width these tests construct a tracker at. Only the tab-stop set + /// is sized, and only the tab tests care what the size is; the rest is + /// width-agnostic and takes the default terminal's 80. + const TEST_COLS: u16 = 80; + fn fed(bytes: &[u8]) -> SeqTracker { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(bytes); t } @@ -1540,7 +1800,7 @@ mod tests { /// which is the whole of what the knob is for. #[test] fn the_capture_bound_holds_across_a_chunked_transmission() { - let mut tracker = SeqTracker::new(40); + let mut tracker = SeqTracker::new(40, TEST_COLS); // Ten chunks of eight data bytes: every escape fits the bound // comfortably, and the ten together do not. let mut wire: Vec = b"\x1b_Ga=T,f=32,s=4,v=4,m=1;AAAAAAAA\x1b\\".to_vec(); @@ -1567,7 +1827,7 @@ mod tests { /// Every payload a feed produced, in order. fn payloads(bytes: &[u8]) -> Vec { - let mut tracker = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut tracker = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); let mut out = Vec::new(); for &byte in bytes { if let SeqEvent::Graphics(payload) = tracker.step(byte) { @@ -1646,7 +1906,7 @@ mod tests { /// plausible-looking wrong picture. #[test] fn a_payload_past_the_capture_bound_is_counted_and_not_kept() { - let mut tracker = SeqTracker::new(8); + let mut tracker = SeqTracker::new(8, TEST_COLS); let mut seen = None; for &byte in b"\x1b_Ga=T,f=32,s=4,v=4;AAAABBBBCCCCDDDD\x1b\\" { if let SeqEvent::Graphics(payload) = tracker.step(byte) { @@ -1678,7 +1938,7 @@ mod tests { /// it and the timeout said nothing. #[test] fn the_kitty_graphics_query_is_classified() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); let mut events = Vec::new(); for &b in b"\x1b_Gi=1,a=q;\x1b\\" { events.push(t.step(b)); @@ -1701,7 +1961,7 @@ mod tests { /// next timeout of every application that draws. #[test] fn a_kitty_transmission_is_not_treated_as_a_query() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); let mut events = Vec::new(); for &b in b"\x1b_Gf=24,a=T;QUJD\x1b\\" { events.push(t.step(b)); @@ -2003,7 +2263,7 @@ mod tests { #[test] fn hostile_input_cannot_panic_or_grow_a_buffer_past_its_bound() { let capture = crate::graphics::DEFAULT_CAPTURE; - let mut tracker = SeqTracker::new(capture); + let mut tracker = SeqTracker::new(capture, TEST_COLS); // (links, label, osc, dcs_body) high-water marks. let mut peak = (0usize, 0usize, 0usize, 0usize); @@ -2081,7 +2341,7 @@ mod tests { /// with them, driven long enough to exercise eviction many times over. #[test] fn a_long_well_formed_stream_stays_bounded_and_consistent() { - let mut tracker = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut tracker = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); for n in 0..5_000u32 { let chunk = format!( "\x1b]8;id={n};http://example.invalid/{n}\x1b\\label{n}\x1b]8;;\x1b\\\ @@ -2102,7 +2362,7 @@ mod tests { /// The glyphs a fed tracker would hand the grid for `text`, byte by /// byte: the translation where one applies, the byte itself otherwise. fn drawn(bytes: &[u8]) -> String { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); let mut out = String::new(); for &b in bytes { match t.charset_glyph(b) { @@ -2202,7 +2462,7 @@ mod tests { #[test] fn a_multibyte_character_consumes_a_single_shift() { for ch in ["汉", "🦀", "é"] { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b*0\x1bN"); assert_eq!( t.charset_glyph(b'l'), @@ -2235,7 +2495,7 @@ mod tests { /// OSC title, a CSI parameter or a DCS payload must pass untouched. #[test] fn bytes_inside_sequences_are_never_translated() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); for &b in b"\x1b(0" { t.step(b); } @@ -2281,13 +2541,13 @@ mod tests { #[test] fn a_designation_split_across_feeds_still_applies() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b("); assert!(t.mid_sequence()); t.feed(b"0"); assert_eq!(t.charset_glyph(b'l'), Some("\u{250c}")); - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b*"); assert!(t.mid_sequence()); t.feed(b"0\x1bN"); @@ -2302,7 +2562,7 @@ mod tests { #[test] fn split_csi_is_mid_sequence_until_final_byte() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b[3"); assert!(t.mid_sequence()); t.feed(b"1"); @@ -2358,7 +2618,7 @@ mod tests { #[test] fn sync_update_events_fire_on_2026_set_and_reset() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); let events: Vec = b"\x1b[?2026h".iter().map(|&b| t.step(b)).collect(); assert_eq!(*events.last().unwrap(), SeqEvent::SyncBegin); assert!(t.in_sync_update()); @@ -2369,17 +2629,17 @@ mod tests { #[test] fn sync_2026_is_recognized_anywhere_in_a_multi_mode_list() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b[?2026;25h"); assert!(t.in_sync_update()); - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b[?25;2026h"); assert!(t.in_sync_update()); } #[test] fn lookalike_sequences_do_not_toggle_sync() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b[2026h"); // not private (no '?') assert!(!t.in_sync_update()); t.feed(b"\x1b[?2026m"); // wrong final byte @@ -2466,7 +2726,7 @@ mod tests { #[test] fn a_clipboard_read_is_a_query_not_a_write() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); let events: Vec = b"\x1b]52;c;?\x07".iter().map(|&b| t.step(b)).collect(); assert!(matches!( events.last(), @@ -2480,19 +2740,19 @@ mod tests { // Applications reset terminal modes defensively at startup and on // crash, and such a reset string contains `?2026l`. It must not end // a frame that never began. - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); let events: Vec = b"\x1b[?2026l".iter().map(|&b| t.step(b)).collect(); assert!(!events.contains(&SeqEvent::SyncEnd)); assert!(!t.in_sync_update()); // Taken verbatim from a real crash handler. let reset = b"\x1b[?2026l\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?2004l\x1b[?1049l"; - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); let events: Vec = reset.iter().map(|&b| t.step(b)).collect(); assert!(!events.contains(&SeqEvent::SyncEnd)); // And the End of a real frame still ends it, once only. - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); let events: Vec = b"\x1b[?2026h\x1b[?2026l\x1b[?2026l" .iter() .map(|&b| t.step(b)) @@ -2505,7 +2765,7 @@ mod tests { #[test] fn sync_survives_an_aborted_csi_inside_the_update() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b[?2026h\x1b[31\x18"); // CAN aborts the SGR, not the frame assert!(t.in_sync_update()); t.feed(b"\x1b[?2026l"); @@ -2513,7 +2773,7 @@ mod tests { } fn queries_of(bytes: &[u8]) -> Vec { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); bytes .iter() .filter_map(|&b| match t.step(b) { @@ -2666,7 +2926,7 @@ mod tests { #[test] fn osc_0_and_2_set_the_title_via_bel_or_st() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); assert_eq!(&*t.title(), ""); t.feed(b"\x1b]2;hello world\x07"); assert_eq!(&*t.title(), "hello world"); @@ -2676,7 +2936,7 @@ mod tests { #[test] fn titles_longer_than_the_diagnostic_capture_are_kept_whole() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); let title = "t".repeat(80); // seq_buf truncates at 24; titles must not t.feed(format!("\x1b]2;{title}\x07").as_bytes()); assert_eq!(&*t.title(), title.as_str()); @@ -2684,7 +2944,7 @@ mod tests { #[test] fn title_survives_chunked_delivery() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b]2;split"); t.feed(b" title\x07"); assert_eq!(&*t.title(), "split title"); @@ -2692,14 +2952,14 @@ mod tests { #[test] fn title_keeps_embedded_semicolons() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b]0;a;b;c\x07"); assert_eq!(&*t.title(), "a;b;c"); } #[test] fn icon_only_and_aborted_titles_do_not_change_the_title() { - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(b"\x1b]2;kept\x07"); t.feed(b"\x1b]1;icon only\x07"); // OSC 1: icon name, not the title assert_eq!(&*t.title(), "kept"); @@ -2714,7 +2974,7 @@ mod tests { #[test] fn split_utf8_is_mid_sequence() { let bytes = "汉".as_bytes(); // 3 bytes - let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE); + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); t.feed(&bytes[..1]); assert!(t.mid_sequence()); t.feed(&bytes[1..2]); @@ -2722,4 +2982,127 @@ mod tests { t.feed(&bytes[2..]); assert!(!t.mid_sequence()); } + + /// Every [`TabOp`] a stream can produce, so the recognition is pinned + /// apart from the rewrite that acts on it. + fn tab_ops(bytes: &[u8]) -> Vec { + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS); + let mut out = Vec::new(); + for &byte in bytes { + if let SeqEvent::Tabs(op) = t.step(byte) { + out.push(op); + } + } + out + } + + #[test] + fn the_five_tab_sequences_are_recognized() { + assert_eq!(tab_ops(b"\x1bH"), vec![TabOp::Set]); + assert_eq!(tab_ops(b"\x1b[g"), vec![TabOp::ClearAtCursor]); + assert_eq!(tab_ops(b"\x1b[0g"), vec![TabOp::ClearAtCursor]); + assert_eq!(tab_ops(b"\x1b[3g"), vec![TabOp::ClearAll]); + assert_eq!(tab_ops(b"\t"), vec![TabOp::Forward(1)]); + // An omitted or zero count is one, as it is for every other CSI + // motion; anything else is taken as written. + assert_eq!(tab_ops(b"\x1b[I"), vec![TabOp::Forward(1)]); + assert_eq!(tab_ops(b"\x1b[0I"), vec![TabOp::Forward(1)]); + assert_eq!(tab_ops(b"\x1b[3I"), vec![TabOp::Forward(3)]); + assert_eq!(tab_ops(b"\x1b[Z"), vec![TabOp::Back(1)]); + assert_eq!(tab_ops(b"\x1b[2Z"), vec![TabOp::Back(2)]); + } + + #[test] + fn sequences_that_only_look_like_tab_operations_are_left_alone() { + // `TBC 1`, `2`, `4` and `5` clear *line* tab stops, which this crate + // has no notion of. Recognized as not ours rather than guessed at. + assert!(tab_ops(b"\x1b[1g").is_empty()); + assert!(tab_ops(b"\x1b[2g").is_empty()); + // A private prefix is a different sequence entirely. + assert!(tab_ops(b"\x1b[?3g").is_empty()); + assert!(tab_ops(b"\x1b[?1I").is_empty()); + // `CSI H` is CUP, and only the bare `ESC H` is HTS. + assert!(tab_ops(b"\x1b[H").is_empty()); + assert!(tab_ops(b"\x1b[1;1H").is_empty()); + // A tab inside a string sequence is payload, not a motion — the + // same rule that keeps a BEL there from being a bell. + assert!(tab_ops(b"\x1b]0;a\tb\x07").is_empty()); + assert!(tab_ops(b"\x1bPq\t\x1b\\").is_empty()); + } + + #[test] + fn a_stop_set_at_the_cursor_is_where_the_next_tab_lands() { + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, 24); + // The default eight, until the application says otherwise. + assert_eq!(t.tab_op(TabOp::Forward(1), 1), Some(8)); + assert_eq!(t.tab_op(TabOp::Set, 3), None); + assert_eq!(t.tab_op(TabOp::Forward(1), 1), Some(3)); + // Setting one adds to the set rather than replacing it. + assert_eq!(t.tab_op(TabOp::Forward(1), 3), Some(8)); + // And clearing it takes only that one away. + assert_eq!(t.tab_op(TabOp::ClearAtCursor, 3), None); + assert_eq!(t.tab_op(TabOp::Forward(1), 1), Some(8)); + } + + #[test] + fn motions_move_by_whole_stops_and_saturate_at_the_edges() { + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, 24); + assert_eq!(t.tab_op(TabOp::Forward(2), 0), Some(16)); + // Past the last stop there is nowhere left to go but the last + // column, and asking for more stops than exist does not overshoot. + assert_eq!(t.tab_op(TabOp::Forward(9), 0), Some(23)); + assert_eq!(t.tab_op(TabOp::Forward(u16::MAX), 0), Some(23)); + // Back-tab goes to the nearest stop strictly left of the cursor — + // so a cursor one past the stop it just wrote over returns to it. + assert_eq!(t.tab_op(TabOp::Back(1), 17), Some(16)); + assert_eq!(t.tab_op(TabOp::Back(1), 16), Some(8)); + assert_eq!(t.tab_op(TabOp::Back(2), 17), Some(8)); + assert_eq!(t.tab_op(TabOp::Back(u16::MAX), 17), Some(0)); + } + + #[test] + fn clearing_every_stop_leaves_the_two_edges() { + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, 24); + assert_eq!(t.tab_op(TabOp::ClearAll, 0), None); + assert_eq!(t.tab_op(TabOp::Forward(1), 0), Some(23)); + assert_eq!(t.tab_op(TabOp::Back(1), 20), Some(0)); + // A stop set afterwards is the only one there is. + assert_eq!(t.tab_op(TabOp::Set, 5), None); + assert_eq!(t.tab_op(TabOp::Forward(1), 0), Some(5)); + } + + #[test] + fn a_reset_restores_the_default_every_eighth_set() { + for reset in [&b"\x1bc"[..], b"\x1b[!p"] { + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, 24); + assert_eq!(t.tab_op(TabOp::ClearAll, 0), None); + assert_eq!(t.tab_op(TabOp::Set, 3), None); + assert_eq!(t.tab_op(TabOp::Forward(1), 0), Some(3)); + t.feed(reset); + assert_eq!( + t.tab_op(TabOp::Forward(1), 0), + Some(8), + "the custom stop must not survive {}", + printable(reset) + ); + assert_eq!(t.tab_op(TabOp::Forward(1), 8), Some(16)); + } + } + + #[test] + fn a_resize_extends_the_set_without_disturbing_the_stops_it_had() { + let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, 24); + assert_eq!(t.tab_op(TabOp::Set, 3), None); + assert_eq!(t.tab_op(TabOp::ClearAtCursor, 16), None); + t.set_cols(40); + // The documented rule: new columns get the every-eighth pattern, + // and both edits inside the old width survive. + assert_eq!(t.tab_op(TabOp::Forward(1), 0), Some(3)); + assert_eq!(t.tab_op(TabOp::Forward(1), 8), Some(24), "16 stays cleared"); + assert_eq!(t.tab_op(TabOp::Forward(1), 24), Some(32), "new territory"); + // Narrowing drops the columns that no longer exist, and motion + // clamps to the width that does. + t.set_cols(10); + assert_eq!(t.tab_op(TabOp::Forward(1), 8), Some(9)); + } } diff --git a/crates/termlens/src/emu/vt100.rs b/crates/termlens/src/emu/vt100.rs index 3e7048b..ebc4378 100644 --- a/crates/termlens/src/emu/vt100.rs +++ b/crates/termlens/src/emu/vt100.rs @@ -6,7 +6,7 @@ use std::collections::VecDeque; use std::sync::Arc; use std::time::{Duration, Instant}; -use super::seq::{SeqEvent, SeqTracker}; +use super::seq::{SeqEvent, SeqTracker, TabOp}; use super::shadow::{AttrShadow, ColorNormalizer}; use super::{Emulator, FrameSpan, InputModes, ModeState, MouseEncoding, Processed, Stop}; use crate::graphics::{GraphicsPayload, GraphicsSeen, HISTORY}; @@ -69,7 +69,7 @@ impl Vt100Emulator { pub(crate) fn new(rows: u16, cols: u16, scrollback_len: usize, capture: usize) -> Self { Self { parser: ::vt100::Parser::new(rows, cols, scrollback_len), - tracker: SeqTracker::new(capture), + tracker: SeqTracker::new(capture, cols), shadow: AttrShadow::new(rows, cols), colors: ColorNormalizer::new(), scrollback_len, @@ -118,6 +118,41 @@ impl Vt100Emulator { self.staged = staged; } + /// Carry out a tab-stop operation, given the bytes still owed to the + /// grid: `before` is everything ahead of the one that completed the + /// sequence, and `last` is that byte itself. + /// + /// Every one of the five is relative to the cursor column, so `before` + /// has to reach the parser first — the position read otherwise belongs + /// to wherever the last feed stopped, which on a chatty stream is an + /// arbitrary number of characters back. + /// + /// The split is at the completing byte and not past it, which matters + /// for plain `HT` alone: vt100 ignores the four escapes, but it acts on + /// `HT`, so feeding that byte before the column is read would move the + /// cursor to vt100's fixed eight and leave the computation working from + /// the wrong base. Reading first is correct for all five, since no + /// escape prefix moves the cursor either. + /// + /// The sequence is then fed rather than dropped. vt100 ignores `HTS`, + /// `TBC`, `CHT` and `CBT` outright, and dropping a final byte would + /// leave its parser sitting in a half-consumed escape that swallows + /// whatever came next. `HT` it does act on, moving by its own fixed + /// eight — which the `CHA` below then overrides, since `HT` draws + /// nothing and only moves the cursor. + fn apply_tabs(&mut self, op: TabOp, before: &[u8], last: &[u8]) { + self.feed_staged(before); + let col = self.parser.screen().cursor_position().1; + let target = self.tracker.tab_op(op, col); + self.feed_staged(last); + if let Some(target) = target { + // CHA counts from one. Both parsers are fed it, so the + // attribute shadow moves with the primary grid and the two + // stay the same shape — the invariant `snapshot` asserts. + self.feed(format!("\x1b[{}G", target.saturating_add(1)).as_bytes()); + } + } + /// File a completed payload, stamped with where it landed. /// /// Neither protocol's escape moves the cursor, so the position once the @@ -247,6 +282,11 @@ impl Emulator for Vt100Emulator { self.feed(SOFT_RESET_REPLAY); None } + SeqEvent::Tabs(op) => { + self.apply_tabs(op, &bytes[fed..i], &bytes[i..=i]); + fed = i + 1; + None + } SeqEvent::None => None, SeqEvent::SyncBegin => { // Stamped here, at the byte that opened the update, @@ -413,6 +453,7 @@ impl Emulator for Vt100Emulator { fn set_size(&mut self, rows: u16, cols: u16) { self.parser.screen_mut().set_size(rows, cols); self.shadow.set_size(rows, cols); + self.tracker.set_cols(cols); // A resize can push rows into history on its own. self.capture_scrolled_rows(); } @@ -1027,4 +1068,101 @@ mod tests { assert_eq!(screen.size(), (5, 2)); assert_eq!(screen.text(), "hello\n"); } + + /// A 24-column emulator, the width the issue's reproductions use. + fn wide_emu(bytes: &[u8]) -> Vt100Emulator { + let mut emu = Vt100Emulator::new(2, 24, 0, crate::graphics::DEFAULT_CAPTURE); + feed_all(&mut emu, bytes); + emu + } + + /// Where the cursor ended up, which is the whole of what a tab does. + fn cursor_col(bytes: &[u8]) -> u16 { + wide_emu(bytes).snapshot().cursor().1 + } + + #[test] + fn a_plain_tab_still_lands_on_the_default_eighth_column() { + // The rewrite takes `HT` over from vt100 entirely, so the behaviour + // that was already right has to keep being right. + let s = wide_emu(b"a\tb").snapshot(); + assert_eq!(s.row_text(0).trim_end(), "a b", "{s}"); + assert_eq!(s.find("b"), Some((0, 8))); + assert_eq!(cursor_col(b"\t\t"), 16); + } + + /// The scope note from the issue, and the one test that proves the two + /// tab implementations are not disagreeing: `HTS` sets a stop and a + /// *plain* `\t` — not `CHT` — is what honours it. + #[test] + fn a_plain_tab_honours_a_stop_set_by_hts() { + // Column 4 (`CSI 4 G` is one-based), set a stop, back to column 1. + let s = wide_emu(b"\x1b[4G\x1bH\x1b[1Ga\tb").snapshot(); + assert_eq!(s.row_text(0).trim_end(), "a b", "{s}"); + assert_eq!(s.find("b"), Some((0, 3))); + } + + #[test] + fn tbc_clears_one_stop_and_csi_3_g_clears_them_all() { + // Standing on the default stop at column 8 and clearing it sends the + // next tab on to 16 instead. + assert_eq!(cursor_col(b"\x1b[9G\x1b[g\x1b[1G\t"), 16); + assert_eq!(cursor_col(b"\x1b[9G\x1b[0g\x1b[1G\t"), 16); + // With every stop gone a tab runs to the last column and stays. + assert_eq!(cursor_col(b"\x1b[3g\t"), 23); + assert_eq!(cursor_col(b"\x1b[3g\t\t\t"), 23); + } + + #[test] + fn cht_and_cbt_move_by_whole_stops() { + assert_eq!(cursor_col(b"\x1b[2I"), 16); + assert_eq!(cursor_col(b"\x1b[I"), 8); + // Back-tab from a stop goes to the one before it. Written without a + // character in the way, because a character advances the cursor and + // the back-tab would then return to the stop it was standing on — + // which is what xterm and alacritty both do, and what the issue's + // second reproduction reads past. + assert_eq!(cursor_col(b"\t\t\x1b[1Z"), 8); + assert_eq!(cursor_col(b"\t\tX\x1b[1Z"), 16); + assert_eq!(cursor_col(b"\t\t\x1b[2Z"), 0); + // Nowhere left to go is column 0, not a wrap. + assert_eq!(cursor_col(b"\x1b[9Z"), 0); + } + + #[test] + fn ris_and_decstr_restore_the_default_stops() { + for reset in [&b"\x1bc"[..], b"\x1b[!p"] { + let mut stream = b"\x1b[3g\x1b[4G\x1bH".to_vec(); + stream.extend_from_slice(reset); + stream.extend_from_slice(b"\x1b[1G\t"); + assert_eq!( + cursor_col(&stream), + 8, + "the custom stop must not survive {reset:?}" + ); + } + } + + #[test] + fn a_resize_extends_the_stops_into_the_new_columns() { + let mut emu = wide_emu(b"\x1b[4G\x1bH"); + emu.set_size(2, 40); + // The stop set before the resize is still there … + feed_all(&mut emu, b"\x1b[1G\t"); + assert_eq!(emu.snapshot().cursor().1, 3); + // … and the columns the grid did not have get the default pattern. + feed_all(&mut emu, b"\x1b[25G\t"); + assert_eq!(emu.snapshot().cursor().1, 32); + } + + /// The rewrite is fed to both parsers, so the attribute shadow moves + /// with the primary grid. `snapshot` debug-asserts they agree, which is + /// what this drives — a tab inside a styled span is the shape that would + /// break if only one of them were told. + #[test] + fn a_tab_inside_a_styled_span_keeps_the_shadow_in_step() { + let s = wide_emu(b"\x1b[5m\x1b[4G\x1bH\x1b[1Ga\tb\x1b[0m").snapshot(); + assert_eq!(s.find("b"), Some((0, 3))); + assert!(s.cell(0, 3).unwrap().style().blink, "{s}"); + } } diff --git a/crates/termlens/tests/tabs.rs b/crates/termlens/tests/tabs.rs new file mode 100644 index 0000000..bb3ecfe --- /dev/null +++ b/crates/termlens/tests/tabs.rs @@ -0,0 +1,239 @@ +//! Tab stops: `HTS`, `TBC`, `CHT`, `CBT` and the plain `HT` they redefine. +//! All four capabilities sit in the terminfo entry termlens hands every +//! child (`hts`, `tbc`, `cbt`), so an application that lays a table out by +//! setting its own stops and tabbing between them is using what it was told +//! it had. Before this the stops were the backend's hardcoded eight and the +//! four escapes vanished, so every column landed in the wrong place — and +//! silently, since the characters were all still on the screen. + +use std::time::Duration; + +use termlens::{Key, Terminal}; + +fn sh(script: &str) -> termlens::Result { + Terminal::builder() + .size(24, 4) + .timeout(Duration::from_secs(10)) + .args(["-c", script]) + .spawn("/bin/sh") +} + +/// Where a needle sits, which is the only thing any of these assert. +/// `contains` is deliberately not enough: the failure this file exists for +/// leaves every character present and every column wrong, so a column is +/// what has to be checked. +fn col_of(screen: &termlens::Screen, needle: &str) -> Option { + screen.find(needle).map(|(_, col)| col) +} + +/// The first reproduction from the issue: a stop set with `HTS` and reached +/// with a plain `\t`. The tab is the point — `HT` used to be answered by the +/// backend's fixed eight, so a stop set here and a tab taken there would +/// have disagreed even with `CHT` working. +#[test] +fn a_tab_lands_on_a_stop_set_by_hts() -> termlens::Result<()> { + let mut t = sh(r"printf '\033[4G\033H\033[1Ga\011b'; printf ' DONE'; read _")?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(col_of(&s, "a"), Some(0), "{s}"); + assert_eq!(col_of(&s, "b"), Some(3), "the stop set at column 4:\n{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// The default stops are every eighth column, and setting one adds to them +/// rather than replacing them. +#[test] +fn the_default_stops_survive_a_custom_one() -> termlens::Result<()> { + let mut t = sh(r"printf '\033[4G\033H\033[1Ga\011b\011c'; printf ' DONE'; read _")?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(col_of(&s, "b"), Some(3), "{s}"); + assert_eq!(col_of(&s, "c"), Some(8), "the default eighth column:\n{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// `CSI g` clears the stop under the cursor, and the tab that used to land +/// on it runs on to the next one. +#[test] +fn tbc_clears_the_stop_under_the_cursor() -> termlens::Result<()> { + // Standing on the default stop at column 9 (one-based), clear it: the + // tab from column 1 runs past it to the next, at column 17. + let mut t = sh(r"printf '\033[9G\033[g\033[1Ga\011b'; printf ' DONE'; read _")?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(col_of(&s, "b"), Some(16), "{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// `CSI 3 g` — the form terminfo's `tbc` is written as — clears the lot. +/// With no stops at all a tab runs to the last column and stays there, which +/// is what a second tab proves: `b` is written after two of them. +#[test] +fn csi_3_g_clears_every_stop() -> termlens::Result<()> { + // `DONE` goes on the next row on purpose: the last column is where the + // tabs end up, so anything printed after them on row 0 would overwrite + // the very cell under test. + let mut t = sh(r"printf '\033[3ga\011\011b\r\nDONE'; read _")?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(col_of(&s, "a"), Some(0), "{s}"); + assert_eq!( + col_of(&s, "b"), + Some(23), + "the last column, and the second tab does not move on from it:\n{s}" + ); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// `CHT` moves forward by whole stops, with a count. +#[test] +fn cht_moves_forward_by_whole_stops() -> termlens::Result<()> { + let mut t = sh(r"printf '\033[2Ia'; printf ' DONE'; read _")?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(col_of(&s, "a"), Some(16), "two stops forward:\n{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// `CBT` moves back by whole stops — and is what `Shift-Tab` sends, so an +/// application echoing it emits `CSI Z` on its output side. +#[test] +fn cbt_moves_back_by_whole_stops() -> termlens::Result<()> { + // From a stop, back-tab reaches the one before it. + let mut t = sh(r"printf '\011\011\033[1Zy'; printf ' DONE'; read _")?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(col_of(&s, "y"), Some(8), "{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// The issue's second reproduction, pinned with the answer this crate +/// gives — which is not the one the issue predicts. +/// +/// `X` advances the cursor to column 17, and a back-tab goes to the nearest +/// stop *strictly* left of where it starts, so it returns to the stop at 16 +/// that `X` is sitting just past. xterm and alacritty both do this. The +/// issue reads the reproduction as landing at column 8, which is what it +/// would do without the `X` in the way — the case above. +#[test] +fn a_back_tab_returns_to_the_stop_the_cursor_is_just_past() -> termlens::Result<()> { + let mut t = sh(r"printf '\011\011X\033[1Zy'; printf ' DONE'; read _")?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(col_of(&s, "y"), Some(16), "{s}"); + assert!(!s.contains("X"), "y is written over X:\n{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// `RIS` puts the terminal back to power-on, and the stops with it. +#[test] +fn a_hard_reset_restores_the_default_stops() -> termlens::Result<()> { + let mut t = sh(r"printf '\033[3g\033[4G\033H\033c\011x'; printf ' DONE'; read _")?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(col_of(&s, "x"), Some(8), "every eighth column again:\n{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// `DECSTR` restores them too — the soft reset a well-behaved TUI sends on +/// startup and teardown, which leaves the screen alone. +#[test] +fn a_soft_reset_restores_the_default_stops() -> termlens::Result<()> { + let mut t = sh(r"printf '\033[3g\033[4G\033H\033[!p\033[1G\011x'; printf ' DONE'; read _")?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(col_of(&s, "x"), Some(8), "every eighth column again:\n{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// The documented resize rule, through a real `resize`: columns the grid +/// did not have before get the every-eighth pattern, and the stops inside +/// the old width are left exactly as they were. +/// +/// This is the rule most likely to be broken by a later change and the one +/// nothing else pins end to end — a set rebuilt from scratch on resize would +/// lose the custom stop and pass every other test in this file. +#[test] +fn a_resize_extends_the_stops_and_keeps_the_ones_it_had() -> termlens::Result<()> { + // The stop at column 4 is set before the resize; `READY` parks the + // child so the widen lands between the two halves of the script. + // + // One `read` and no trailing wait: a resize raises `SIGWINCH` in the + // child, which can cut a pending `read` short, so a script that paused + // twice would be racing the signal for which pause our one keypress + // lands in. With a single pause the second half is printed after the + // widen either way. + // + // Which *row* it is printed on is the other side of that same race, so + // it is deliberately not asserted: when the `read` consumes our Enter + // the terminal echoes the newline and the second half starts a row + // lower than when `SIGWINCH` has already ended it. The column is what + // the resize rule is about, and it is the same either way. + let mut t = sh(concat!( + r"printf '\033[4G\033H\033[1GREADY\r\n'; read _; ", + r"printf '\011a\033[25G\011b\r\nDONE'" + ))?; + t.wait_until(|s| s.contains("READY"))?; + t.resize(40, 4)?; + t.send(Key::Enter)?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(col_of(&s, "a"), Some(3), "the custom stop survives:\n{s}"); + assert_eq!( + col_of(&s, "b"), + Some(32), + "and column 25 tabs on to the every-eighth stop at 33:\n{s}" + ); + assert!(t.wait_exit()?.success()); + Ok(()) +} + +/// A table drawn the way the capabilities are meant to be used: clear the +/// stops, set the column ones, then tab between them for every row. This is +/// the failure the issue describes — every character present, every column +/// wrong — so it is asserted by column rather than by `contains`. +/// +/// The defaults are cleared first because a cell whose text reaches the next +/// default stop would otherwise tab past it: `name` ends exactly at column 8 +/// where `ada` ends at 7, so the two rows would part company on the third +/// column and nothing about the escape handling would be at fault. +#[test] +fn a_table_laid_out_with_its_own_stops_lines_up() -> termlens::Result<()> { + let mut t = sh(concat!( + // Stops at columns 5 and 13, one-based, and nothing else. + r"printf '\033[3g\033[5G\033H\033[13G\033H\033[1G'; ", + r"printf 'id\011name\011role\r\n'; ", + r"printf '7\011ada\011dev\r\n'; ", + "printf DONE; read _" + ))?; + t.wait_until(|s| s.contains("DONE"))?; + let s = t.screen(); + assert_eq!(s.row_text(0).trim_end(), "id name role", "{s}"); + assert_eq!(s.row_text(1).trim_end(), "7 ada dev", "{s}"); + // The columns line up, which is the whole point of the capability. + assert_eq!(col_of(&s, "name"), Some(4), "{s}"); + assert_eq!(col_of(&s, "ada"), Some(4), "{s}"); + assert_eq!(col_of(&s, "role"), Some(12), "{s}"); + assert_eq!(col_of(&s, "dev"), Some(12), "{s}"); + t.send(Key::Enter)?; + assert!(t.wait_exit()?.success()); + Ok(()) +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0b6c38d..9ba412c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -671,6 +671,28 @@ But it is an implementation detail: G0/G1, SS2/SS3 for one character, one set translated, everything else read as ASCII. `LS2`/`LS3` and `DECSC`/`DECRC` of charset state are not modelled. +- vt100's tab stops are a hardcoded eight with no way to be told otherwise, + so **the stop set is termlens's too**, and for the same reason: `HTS`, + `TBC`, `CHT` and `CBT` reached `unhandled_escape`/`unhandled_csi` and were + dropped, while `hts`, `tbc` and `cbt` sit in the terminfo entry every + child is handed. The set is one flag per column in the sequence tracker; + the *cursor column* each operation is relative to is the grid's, so the + tracker classifies and the emulator resolves — feeding the bytes it still + owes the parser first, so the position it reads is current rather than + wherever the last feed stopped. A motion then goes out as `CSI n G` + (`CHA`), which vt100 does dispatch, so this is a rewrite on the same + staged stream the glyph substitution uses rather than a new grid + operation. The original escape is fed rather than dropped: vt100 ignores + all four, and dropping a final byte would leave its parser in a + half-consumed escape that swallows the next one. Plain `HT` is rewritten + too — vt100 *does* act on it, and its eight and a custom stop would + disagree the moment an application set one; the `CHA` lands after it and + wins, which is sound because `HT` draws nothing and only moves the cursor. + Both parsers are fed the rewrite, so the attribute shadow keeps the same + shape as the primary grid and the invariant `snapshot` debug-asserts still + holds. Scope is in the README: `TBC 0` and `TBC 3` only, back-tab to the + nearest stop strictly left of the cursor, and a resize that extends into + new columns with the default pattern while leaving existing stops alone. ### The attribute shadow