From 883268038348639e24b605cdb2f02ebdfca3954a Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 5 Sep 2026 02:30:10 -0300 Subject: [PATCH 1/6] Add files via upload --- crates/termlens/src/emu/seq.rs | 441 ++++++++++++++++++++++++++++++--- 1 file changed, 410 insertions(+), 31 deletions(-) diff --git a/crates/termlens/src/emu/seq.rs b/crates/termlens/src/emu/seq.rs index 0a5a28c..425b234 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,36 @@ 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 +1477,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 +1502,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 +1602,7 @@ impl SeqTracker { self.reset_charsets(); self.saved_charsets = None; self.mouse_tracking = 0; + self.tabs.reset(); self.close_link(); State::Ground } @@ -1394,6 +1637,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 +1758,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 +1796,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 +1823,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 +1902,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 +1934,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 +1957,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 +2259,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 +2337,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 +2358,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 +2458,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 +2491,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 +2537,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 +2558,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 +2614,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 +2625,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 +2722,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 +2736,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 +2761,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 +2769,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 +2922,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 +2932,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 +2940,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 +2948,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 +2970,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 +2978,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)); + } } From e685022b59baa988f1e7c7e34af1753055456984 Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 5 Sep 2026 02:30:55 -0300 Subject: [PATCH 2/6] Add files via upload --- crates/termlens/tests/tabs.rs | 197 ++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 crates/termlens/tests/tabs.rs diff --git a/crates/termlens/tests/tabs.rs b/crates/termlens/tests/tabs.rs new file mode 100644 index 0000000..5e28cb0 --- /dev/null +++ b/crates/termlens/tests/tabs.rs @@ -0,0 +1,197 @@ +//! 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(()) +} + +/// 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(()) +} From 2a558c42ecb673aa92e6dbd3ab1af4d1672caebd Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 5 Sep 2026 02:31:49 -0300 Subject: [PATCH 3/6] Add files via upload --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 8 ++++++++ 2 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b70d25..00fca30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,25 @@ listed under a **Changed** or **Removed** heading. cell and style assertions. Every Rust block in it is compiled against the crate in CI, and the README shows the one-line install for Claude Code. +### 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) + ## [0.9.0] - 2026-09-05 ### Added 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 From e8a096c797db3e3de7c8494ffda69ed95033a88d Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 5 Sep 2026 02:32:22 -0300 Subject: [PATCH 4/6] Add files via upload --- docs/DESIGN.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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 From 59e03c31662bb81a79662e100a3983bfe594a9ca Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 5 Sep 2026 14:40:01 -0300 Subject: [PATCH 5/6] Add files via upload --- crates/termlens/src/emu/seq.rs | 6 +- crates/termlens/src/emu/vt100.rs | 142 ++++++++++++++++++++++++++++++- 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/crates/termlens/src/emu/seq.rs b/crates/termlens/src/emu/seq.rs index 425b234..8d3874c 100644 --- a/crates/termlens/src/emu/seq.rs +++ b/crates/termlens/src/emu/seq.rs @@ -1151,7 +1151,11 @@ impl SeqTracker { // 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 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 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}"); + } } From 113f598718f68570ce64975102de7ee0aecc4238 Mon Sep 17 00:00:00 2001 From: Alexis Date: Sat, 5 Sep 2026 14:40:35 -0300 Subject: [PATCH 6/6] Add files via upload --- crates/termlens/tests/tabs.rs | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/termlens/tests/tabs.rs b/crates/termlens/tests/tabs.rs index 5e28cb0..21eafb9 100644 --- a/crates/termlens/tests/tabs.rs +++ b/crates/termlens/tests/tabs.rs @@ -164,6 +164,42 @@ fn a_soft_reset_restores_the_default_stops() -> termlens::Result<()> { 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. + 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!(s.find("a"), Some((1, 3)), "the custom stop survives:\n{s}"); + assert_eq!( + s.find("b"), + Some((1, 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