From 36fd512ff4cd4927fb3c54847d5fda2d9f9cc8bb Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Tue, 3 Mar 2026 15:44:19 +1100 Subject: [PATCH 1/6] Add serde-based terminal state snapshot/restore Add Term::snapshot() and Term::restore() for capturing and restoring full terminal state (both grid buffers, cursor, mode flags, scroll region) via any serde-compatible format. Includes TermState struct in the serialize module with round-trip tests for JSON and bincode. Grid, Storage, Row, Cell, and related types gain Serialize/Deserialize derives behind the existing serde feature flag. --- Cargo.lock | 33 +++ alacritty_terminal/Cargo.toml | 1 + alacritty_terminal/src/grid/mod.rs | 6 +- alacritty_terminal/src/term/mod.rs | 35 +++ alacritty_terminal/src/term/serialize.rs | 345 +++++++++++++++++++++++ 5 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 alacritty_terminal/src/term/serialize.rs diff --git a/Cargo.lock b/Cargo.lock index 402d89bd854..735ef5075f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,6 +96,7 @@ name = "alacritty_terminal" version = "0.25.2-dev" dependencies = [ "base64", + "bincode", "bitflags 2.9.1", "home", "libc", @@ -228,6 +229,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -2077,6 +2098,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2089,6 +2116,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "vswhom" version = "0.1.0" diff --git a/alacritty_terminal/Cargo.toml b/alacritty_terminal/Cargo.toml index 6566eecbe3e..ee9467f3f6c 100644 --- a/alacritty_terminal/Cargo.toml +++ b/alacritty_terminal/Cargo.toml @@ -45,4 +45,5 @@ windows-sys = { version = "0.59.0", features = [ ]} [dev-dependencies] +bincode = { version = "2", features = ["serde"] } serde_json = "1.0.0" diff --git a/alacritty_terminal/src/grid/mod.rs b/alacritty_terminal/src/grid/mod.rs index 4e394d9d27d..8d7faadaa78 100644 --- a/alacritty_terminal/src/grid/mod.rs +++ b/alacritty_terminal/src/grid/mod.rs @@ -31,6 +31,7 @@ pub trait GridCell: Sized { } #[derive(Debug, Default, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct Cursor { /// The location of this cursor. pub point: Point, @@ -39,6 +40,7 @@ pub struct Cursor { pub template: T, /// Currently configured graphic character sets. + #[cfg_attr(feature = "serde", serde(skip))] pub charsets: Charsets, /// Tracks if the next call to input will need to first handle wrapping. @@ -109,11 +111,11 @@ pub enum Scroll { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct Grid { /// Current cursor for writing data. - #[cfg_attr(feature = "serde", serde(skip))] + #[cfg_attr(feature = "serde", serde(default))] pub cursor: Cursor, /// Last saved cursor. - #[cfg_attr(feature = "serde", serde(skip))] + #[cfg_attr(feature = "serde", serde(default))] pub saved_cursor: Cursor, /// Lines in the grid. Each row holds a list of cells corresponding to the diff --git a/alacritty_terminal/src/term/mod.rs b/alacritty_terminal/src/term/mod.rs index 8eb67975e0e..4f90b8467b9 100644 --- a/alacritty_terminal/src/term/mod.rs +++ b/alacritty_terminal/src/term/mod.rs @@ -29,6 +29,7 @@ use crate::vte::ansi::{ pub mod cell; pub mod color; pub mod search; +pub mod serialize; /// Minimum number of columns. /// @@ -641,6 +642,40 @@ impl Term { RenderableContent::new(self) } + /// Capture a snapshot of the terminal's serialisable state. + /// + /// The returned [`serialize::TermState`] can be serialised with any + /// serde-compatible format (bincode, JSON, …) and later applied to a + /// fresh `Term` via [`Term::restore`]. + #[cfg(feature = "serde")] + pub fn snapshot(&self) -> serialize::TermState { + serialize::TermState { + grid: self.grid.clone(), + inactive_grid: self.inactive_grid.clone(), + mode_bits: self.mode.bits(), + scroll_region: self.scroll_region.clone(), + } + } + + /// Restore terminal state from a [`serialize::TermState`] snapshot. + /// + /// This overwrites the grid buffers, mode flags, and scroll region. + /// Damage tracking is marked as full so the renderer repaints + /// everything on the next frame. + /// + /// The `Term` should have been created with [`Term::new`] using the same + /// (or compatible) dimensions. Fields not captured in `TermState` (tab + /// stops, charsets, title, keyboard mode stack, …) retain their values + /// from the `Term` that `restore` is called on. + #[cfg(feature = "serde")] + pub fn restore(&mut self, state: serialize::TermState) { + self.grid = state.grid; + self.inactive_grid = state.inactive_grid; + self.mode = TermMode::from_bits_truncate(state.mode_bits); + self.scroll_region = state.scroll_region; + self.mark_fully_damaged(); + } + /// Access to the raw grid data structure. pub fn grid(&self) -> &Grid { &self.grid diff --git a/alacritty_terminal/src/term/serialize.rs b/alacritty_terminal/src/term/serialize.rs new file mode 100644 index 00000000000..b03c7271eda --- /dev/null +++ b/alacritty_terminal/src/term/serialize.rs @@ -0,0 +1,345 @@ +//! Terminal state snapshot and restore via serde. +//! +//! Captures the full terminal state — both grid buffers, cursor, mode flags, +//! and scroll region — as a [`TermState`] struct that can be serialised with +//! any serde-compatible format (bincode, JSON, …) and later applied back to +//! a [`Term`] via [`Term::restore`]. +//! +//! [`Term`]: crate::Term + +use std::ops::Range; + +use serde::{Deserialize, Serialize}; + +use crate::grid::Grid; +use crate::index::Line; +use crate::term::cell::Cell; +use crate::term::TermMode; + +/// Complete terminal state for serialisation and deserialisation. +/// +/// Captures everything needed to restore a terminal session after reconnect: +/// both grid buffers (active and inactive, covering alternate screen), cursor +/// position and template, terminal mode flags, and scroll region. +/// +/// # What is captured +/// +/// | Field | Purpose | +/// |---|---| +/// | `grid` | Active grid with cursor, scrollback, cell content & attributes | +/// | `inactive_grid` | Inactive buffer (primary when alt screen is active, or vice versa) | +/// | `mode_bits` | `TermMode` bitflags: bracketed paste, mouse mode, alt screen, etc. | +/// | `scroll_region` | DECSTBM scroll region (top..bottom viewport lines) | +/// +/// # What is NOT captured (defaults on restore) +/// +/// - Character set mappings (`Charsets`) — almost never non-default +/// - Tab stops — reconstructed as standard 8-column stops +/// - Window title / title stack +/// - Keyboard mode stack +/// - Terminal colour overrides (come from client config) +/// - Cursor style (comes from client config) +/// - Selection state (client-side UI) +/// - Vi mode cursor (client-side UI) +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TermState { + /// Active grid (primary or alternate, depending on mode). + pub grid: Grid, + + /// Inactive grid (the other buffer). + pub inactive_grid: Grid, + + /// Terminal mode flags as raw `TermMode` bits. + /// + /// Stored as `u32` because `TermMode` (a `bitflags!` type) may not have + /// serde derives. Use [`TermState::mode()`] to get the typed value. + pub(crate) mode_bits: u32, + + /// Scroll region (top..bottom viewport lines). + pub scroll_region: Range, +} + +impl TermState { + /// Terminal mode flags. + pub fn mode(&self) -> TermMode { + TermMode::from_bits_truncate(self.mode_bits) + } +} + +#[cfg(test)] +mod tests { + use super::TermState; + use crate::event::VoidListener; + use crate::grid::{Dimensions, Grid}; + use crate::index::{Column, Line}; + use crate::term::cell::{Cell, Flags}; + use crate::term::test::TermSize; + use crate::term::{Config, Term, TermMode}; + use crate::vte::ansi; + + /// Helper: create a term, push bytes through the parser, return the term. + fn term_with(cols: usize, rows: usize, input: &[u8]) -> Term { + let size = TermSize::new(cols, rows); + let mut term = Term::new(Config::default(), &size, VoidListener); + let mut parser: ansi::Processor = ansi::Processor::new(); + parser.advance(&mut term, input); + term + } + + /// Helper: extract visible text (screen only, no scrollback) trimmed. + fn visible_text(term: &Term) -> String { + let grid = term.grid(); + let mut lines = Vec::new(); + for row_idx in 0..grid.screen_lines() { + let line = Line(row_idx as i32); + let mut s = String::new(); + for col_idx in 0..grid.columns() { + let col = Column(col_idx); + let cell = &grid[line][col]; + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + s.push(cell.c); + } + lines.push(s.trim_end().to_string()); + } + while lines.last().is_some_and(|l| l.is_empty()) { + lines.pop(); + } + lines.join("\n") + } + + /// Helper: compare per-cell attributes for the visible screen area. + fn assert_cells_equal(a: &Term, b: &Term) { + let ga = a.grid(); + let gb = b.grid(); + assert_eq!(ga.screen_lines(), gb.screen_lines()); + assert_eq!(ga.columns(), gb.columns()); + + for row_idx in 0..ga.screen_lines() { + let line = Line(row_idx as i32); + for col_idx in 0..ga.columns() { + let col = Column(col_idx); + let ca = &ga[line][col]; + let cb = &gb[line][col]; + assert_eq!(ca.c, cb.c, "char mismatch at ({row_idx}, {col_idx})"); + assert_eq!(ca.fg, cb.fg, "fg mismatch at ({row_idx}, {col_idx})"); + assert_eq!(ca.bg, cb.bg, "bg mismatch at ({row_idx}, {col_idx})"); + assert_eq!(ca.flags, cb.flags, "flags mismatch at ({row_idx}, {col_idx})"); + } + } + } + + #[test] + fn grid_serde_round_trip() { + let term = term_with(40, 10, b"\x1b[1;31mhello\x1b[0m world\r\nline two"); + let grid = term.grid(); + + let json = serde_json::to_string(grid).expect("serialize Grid"); + let grid2: Grid = serde_json::from_str(&json).expect("deserialize Grid"); + + assert_eq!(grid.columns(), grid2.columns()); + assert_eq!(grid.screen_lines(), grid2.screen_lines()); + assert_eq!(grid.topmost_line(), grid2.topmost_line()); + + for row_idx in 0..grid.screen_lines() { + let line = Line(row_idx as i32); + for col_idx in 0..grid.columns() { + let col = Column(col_idx); + let a = &grid[line][col]; + let b = &grid2[line][col]; + assert_eq!(a.c, b.c, "char mismatch at ({row_idx}, {col_idx})"); + assert_eq!(a.fg, b.fg, "fg mismatch at ({row_idx}, {col_idx})"); + assert_eq!(a.bg, b.bg, "bg mismatch at ({row_idx}, {col_idx})"); + assert_eq!(a.flags, b.flags, "flags mismatch at ({row_idx}, {col_idx})"); + } + } + } + + #[test] + fn grid_serde_preserves_scrollback() { + let term = term_with( + 40, + 4, + b"line1\r\nline2\r\nline3\r\nline4\r\nline5\r\nline6\r\nline7\r\nline8", + ); + let grid = term.grid(); + assert!(grid.topmost_line().0 < 0, "expected scrollback"); + + let json = serde_json::to_string(grid).expect("serialize"); + let grid2: Grid = serde_json::from_str(&json).expect("deserialize"); + + assert_eq!(grid.topmost_line(), grid2.topmost_line()); + + for line_idx in grid.topmost_line().0..0 { + let line = Line(line_idx); + for col_idx in 0..grid.columns() { + let col = Column(col_idx); + assert_eq!( + grid[line][col].c, + grid2[line][col].c, + "scrollback mismatch at ({line_idx}, {col_idx})", + ); + } + } + } + + #[test] + fn grid_serde_preserves_wrapline() { + let term = term_with(10, 4, b"abcdefghijklmno"); + let grid = term.grid(); + + assert!( + grid[Line(0)][Column(9)].flags.contains(Flags::WRAPLINE), + "expected WRAPLINE on first row", + ); + + let json = serde_json::to_string(grid).expect("serialize"); + let grid2: Grid = serde_json::from_str(&json).expect("deserialize"); + + assert!( + grid2[Line(0)][Column(9)].flags.contains(Flags::WRAPLINE), + "WRAPLINE lost after serde round-trip", + ); + } + + #[test] + fn grid_serde_cursor_survives() { + let term = term_with(40, 10, b"\x1b[1;31m\x1b[5;20Hhere"); + let grid = term.grid(); + assert_ne!(grid.cursor.point, Default::default()); + + let json = serde_json::to_string(grid).expect("serialize"); + let grid2: Grid = serde_json::from_str(&json).expect("deserialize"); + + assert_eq!(grid.cursor.point, grid2.cursor.point); + assert_eq!(grid.cursor.template.fg, grid2.cursor.template.fg); + assert_eq!(grid.cursor.template.bg, grid2.cursor.template.bg); + assert_eq!(grid.cursor.template.flags, grid2.cursor.template.flags); + assert_eq!(grid.cursor.input_needs_wrap, grid2.cursor.input_needs_wrap); + } + + #[test] + fn snapshot_restore_preserves_content_and_cursor() { + let term1 = term_with(40, 10, b"\x1b[1;31mhello\x1b[0m world\r\n\x1b[5;20Hcursor here"); + let state = term1.snapshot(); + + let size = TermSize::new(40, 10); + let mut term2 = Term::new(Config::default(), &size, VoidListener); + term2.restore(state); + + assert_eq!(visible_text(&term1), visible_text(&term2)); + assert_eq!(term1.grid().cursor.point, term2.grid().cursor.point); + assert_cells_equal(&term1, &term2); + } + + #[test] + fn snapshot_restore_preserves_modes() { + let term1 = term_with(40, 10, b"\x1b[?2004h\x1b[?1000hsome text"); + assert!(term1.mode().contains(TermMode::BRACKETED_PASTE)); + assert!(term1.mode().contains(TermMode::MOUSE_REPORT_CLICK)); + + let state = term1.snapshot(); + let size = TermSize::new(40, 10); + let mut term2 = Term::new(Config::default(), &size, VoidListener); + term2.restore(state); + + assert_eq!(*term1.mode(), *term2.mode()); + } + + #[test] + fn snapshot_restore_preserves_alternate_screen() { + let mut input = Vec::new(); + input.extend_from_slice(b"primary line 1\r\nprimary line 2\r\n"); + input.extend_from_slice(b"\x1b[?1049h"); + input.extend_from_slice(b"alt screen content"); + + let term1 = term_with(40, 10, &input); + assert!(term1.mode().contains(TermMode::ALT_SCREEN)); + + let state = term1.snapshot(); + let size = TermSize::new(40, 10); + let mut term2 = Term::new(Config::default(), &size, VoidListener); + term2.restore(state); + + assert!(term2.mode().contains(TermMode::ALT_SCREEN)); + assert_eq!(visible_text(&term1), visible_text(&term2)); + } + + #[test] + fn snapshot_restore_preserves_scroll_region() { + let term1 = term_with(40, 10, b"\x1b[3;8rtext after region set"); + let state1 = term1.snapshot(); + let scroll_region = state1.scroll_region.clone(); + assert_ne!(scroll_region, Line(0)..Line(10)); + + let size = TermSize::new(40, 10); + let mut term2 = Term::new(Config::default(), &size, VoidListener); + term2.restore(state1); + + let state2 = term2.snapshot(); + assert_eq!(state2.scroll_region, scroll_region); + } + + #[test] + fn snapshot_restore_preserves_scrollback() { + let term1 = term_with( + 40, + 4, + b"line1\r\nline2\r\nline3\r\nline4\r\nline5\r\nline6\r\nline7\r\nline8", + ); + assert!(term1.grid().topmost_line().0 < 0, "expected scrollback"); + + let state = term1.snapshot(); + let size = TermSize::new(40, 4); + let mut term2 = Term::new(Config::default(), &size, VoidListener); + term2.restore(state); + + assert_eq!(term1.grid().topmost_line(), term2.grid().topmost_line()); + assert_eq!(visible_text(&term1), visible_text(&term2)); + + for line_idx in term1.grid().topmost_line().0..0 { + let line = Line(line_idx); + for col_idx in 0..term1.grid().columns() { + let col = Column(col_idx); + assert_eq!( + term1.grid()[line][col].c, + term2.grid()[line][col].c, + "scrollback mismatch at ({line_idx}, {col_idx})", + ); + } + } + } + + #[test] + fn term_state_serde_json_round_trip() { + let term = term_with(40, 10, b"\x1b[?2004h\x1b[1;31mhello\x1b[0m\x1b[5;20H"); + let state = term.snapshot(); + + let json = serde_json::to_string(&state).expect("TermState JSON serialize"); + let state2: TermState = serde_json::from_str(&json).expect("TermState JSON deserialize"); + + assert_eq!(state.grid.cursor.point, state2.grid.cursor.point); + assert_eq!(state.mode(), state2.mode()); + assert_eq!(state.scroll_region, state2.scroll_region); + assert_eq!(state.grid.columns(), state2.grid.columns()); + assert_eq!(state.grid.screen_lines(), state2.grid.screen_lines()); + } + + #[test] + fn term_state_serde_bincode_round_trip() { + let term = term_with(80, 24, b"\x1b[?2004h\x1b[?1000h\x1b[1;31mhello\x1b[0m world"); + let state = term.snapshot(); + + let bytes = bincode::serde::encode_to_vec(&state, bincode::config::standard()) + .expect("TermState bincode serialize"); + let (state2, _): (TermState, _) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()) + .expect("TermState bincode deserialize"); + + assert_eq!(state.grid.cursor.point, state2.grid.cursor.point); + assert_eq!(state.mode(), state2.mode()); + assert_eq!(state.scroll_region, state2.scroll_region); + assert_eq!(state.grid.columns(), state2.grid.columns()); + } +} From d4abf9273ecdae02e2110cfe55f2de5955dcc1ba Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Fri, 3 Apr 2026 23:51:52 +1100 Subject: [PATCH 2/6] Add scrollback compression using bincode + zstd Compress old scrollback rows to reduce terminal memory usage. Rows are trimmed of trailing default cells, serialized with bincode, and compressed with zstd, achieving ~28x compression on typical mixed terminal content. Grid gains: - compress_old_scrollback(keep_hot) / thaw_compressed_history(count) - compact_scrollback_if_needed() for automatic compression - scroll_display_with_thaw() for transparent decompression on scroll 192 tests passing including 40+ new compression tests covering round-trip fidelity, compression ratios, freeze/thaw integration, and automatic compress/thaw triggers. --- Cargo.lock | 31 +- alacritty_terminal/Cargo.toml | 3 +- alacritty_terminal/src/grid/compact.rs | 910 +++++++++++++++++++++++++ alacritty_terminal/src/grid/mod.rs | 128 +++- alacritty_terminal/src/grid/tests.rs | 366 ++++++++++ tasks.md | 263 +++++++ 6 files changed, 1697 insertions(+), 4 deletions(-) create mode 100644 alacritty_terminal/src/grid/compact.rs create mode 100644 tasks.md diff --git a/Cargo.lock b/Cargo.lock index 735ef5075f1..9c54ff29764 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,7 @@ dependencies = [ "unicode-width", "vte", "windows-sys 0.59.0", + "zstd", ] [[package]] @@ -2403,7 +2404,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -2899,3 +2900,31 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/alacritty_terminal/Cargo.toml b/alacritty_terminal/Cargo.toml index ee9467f3f6c..24bc8c3f1a5 100644 --- a/alacritty_terminal/Cargo.toml +++ b/alacritty_terminal/Cargo.toml @@ -26,6 +26,8 @@ regex-automata = "0.4.3" unicode-width = "0.2.0" vte = { version = "0.15.0", default-features = false, features = ["std", "ansi"] } serde = { version = "1", features = ["derive", "rc"], optional = true } +bincode = { version = "2", features = ["serde"] } +zstd = "0.13" [target.'cfg(unix)'.dependencies] rustix-openpty = "0.2.0" @@ -45,5 +47,4 @@ windows-sys = { version = "0.59.0", features = [ ]} [dev-dependencies] -bincode = { version = "2", features = ["serde"] } serde_json = "1.0.0" diff --git a/alacritty_terminal/src/grid/compact.rs b/alacritty_terminal/src/grid/compact.rs new file mode 100644 index 00000000000..40d9480ce88 --- /dev/null +++ b/alacritty_terminal/src/grid/compact.rs @@ -0,0 +1,910 @@ +//! Scrollback compression using bincode + zstd. +//! +//! Terminal scrollback rows are stored as dense arrays of 24-byte `Cell` structs, +//! even when most cells are trailing spaces with default attributes. This module +//! provides `CompactRow`, which compresses rows by: +//! +//! 1. Trimming trailing default cells (often 60-80% of the row). +//! 2. Serializing the remaining cells with bincode (compact binary serde). +//! 3. Compressing the result with zstd (entropy coding + dictionary matching). +//! +//! Decompression reverses the process: zstd decode → bincode deserialize → +//! pad with default cells to the original column width. + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use crate::grid::row::Row; +use crate::grid::GridCell; +use crate::index::Column; +use crate::term::cell::{Cell, Flags}; + +/// Zstd compression level. Level 3 is the default and offers a good balance +/// between speed and ratio. Terminal cell data compresses well even at low +/// levels because of the highly repetitive attribute patterns. +const ZSTD_LEVEL: i32 = 3; + +/// Serializable representation of cells that have `CellExtra` data. +/// +/// `Arc` doesn't round-trip through serde in a way that preserves +/// sharing, so we strip extras before serialization and reattach them from a +/// sidecar on deserialization. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +struct ExtraEntry { + column: u16, + zerowidth: Vec, + underline_color: Option, + hyperlink_id: Option, + hyperlink_uri: Option, +} + +/// A compressed representation of a terminal row. +/// +/// Stores the serialized + compressed cell data in a contiguous byte buffer, +/// with a small sidecar for the rare `CellExtra` attributes that can't be +/// efficiently serialized inline. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct CompactRow { + /// zstd-compressed bincode bytes of the occupied cells. + data: Vec, + + /// Original number of columns (needed to pad with defaults on decompress). + columns: u16, + + /// Number of cells that were serialized (content_length). + content_cells: u16, + + /// Whether the WRAPLINE flag was set on the last column. + wrapline: bool, + + /// Serialized CellExtra entries for cells that had them. + /// Stored separately because Arc doesn't serde-roundtrip well. + #[cfg_attr(feature = "serde", serde(skip))] + extras: Vec, +} + +impl PartialEq for CompactRow { + fn eq(&self, other: &Self) -> bool { + self.data == other.data + && self.columns == other.columns + && self.content_cells == other.content_cells + && self.wrapline == other.wrapline + } +} + +impl Eq for CompactRow {} + +impl CompactRow { + /// Compress a `Row` into a `CompactRow`. + pub fn compress(row: &Row) -> Self { + let columns = row.len(); + if columns == 0 { + return CompactRow { + data: Vec::new(), + columns: 0, + content_cells: 0, + wrapline: false, + extras: Vec::new(), + }; + } + + let wrapline = row[Column(columns - 1)].flags.contains(Flags::WRAPLINE); + let content_len = content_length(row, columns); + + if content_len == 0 { + return CompactRow { + data: Vec::new(), + columns: columns as u16, + content_cells: 0, + wrapline, + extras: Vec::new(), + }; + } + + // Collect the occupied cells, stripping WRAPLINE (stored separately) + // and extracting CellExtra into the sidecar. + let mut cells: Vec = Vec::with_capacity(content_len); + let mut extras: Vec = Vec::new(); + + for i in 0..content_len { + let cell = &row[Column(i)]; + let mut cell_copy = cell.clone(); + cell_copy.flags &= !Flags::WRAPLINE; + + if cell.extra.is_some() { + let hyperlink = cell.hyperlink(); + extras.push(ExtraEntry { + column: i as u16, + zerowidth: cell.zerowidth().unwrap_or(&[]).to_vec(), + underline_color: cell.underline_color(), + hyperlink_id: hyperlink.as_ref().map(|h| h.id().to_owned()), + hyperlink_uri: hyperlink.as_ref().map(|h| h.uri().to_owned()), + }); + // Clear extra so bincode doesn't serialize the Arc. + cell_copy.extra = None; + } + + cells.push(cell_copy); + } + + // bincode serialize, then zstd compress. + let config = bincode::config::standard(); + let serialized = bincode::serde::encode_to_vec(&cells, config) + .expect("bincode serialization of Vec should not fail"); + + let compressed = zstd::encode_all(serialized.as_slice(), ZSTD_LEVEL) + .expect("zstd compression should not fail"); + + CompactRow { + data: compressed, + columns: columns as u16, + content_cells: content_len as u16, + wrapline, + extras, + } + } + + /// Decompress back into a full-width `Row`. + pub fn decompress(&self) -> Row { + let columns = self.columns as usize; + let content_cells = self.content_cells as usize; + + let mut row = Row::::new(columns); + + if content_cells == 0 { + if self.wrapline && columns > 0 { + row[Column(columns - 1)].flags.insert(Flags::WRAPLINE); + } + return row; + } + + // zstd decompress, then bincode deserialize. + let decompressed = zstd::decode_all(self.data.as_slice()) + .expect("zstd decompression should not fail for data we compressed"); + + let config = bincode::config::standard(); + let (cells, _): (Vec, _) = bincode::serde::decode_from_slice(&decompressed, config) + .expect("bincode deserialization of Vec should not fail"); + + // Write cells into the row. + for (i, cell) in cells.into_iter().enumerate() { + if i < columns { + row[Column(i)] = cell; + } + } + + // Reattach CellExtra from the sidecar. + for entry in &self.extras { + let col = entry.column as usize; + if col < columns { + let cell = &mut row[Column(col)]; + for &zw in &entry.zerowidth { + cell.push_zerowidth(zw); + } + if entry.underline_color.is_some() { + cell.set_underline_color(entry.underline_color); + } + if let (Some(id), Some(uri)) = (&entry.hyperlink_id, &entry.hyperlink_uri) { + use crate::term::cell::Hyperlink; + cell.set_hyperlink(Some(Hyperlink::new( + Some(id.clone()), + uri.clone(), + ))); + } + } + } + + // Restore WRAPLINE on the last column. + if self.wrapline && columns > 0 { + row[Column(columns - 1)].flags.insert(Flags::WRAPLINE); + } + + row + } + + /// Number of columns this row was compressed from. + pub fn columns(&self) -> usize { + self.columns as usize + } + + /// Number of spans in the compressed representation. + /// + /// Not directly meaningful for the bincode+zstd format, but kept for test + /// compatibility. Returns 1 if there is content, 0 otherwise. + pub fn span_count(&self) -> usize { + if self.content_cells > 0 { 1 } else { 0 } + } + + /// Approximate heap memory used by this compact row, in bytes. + pub fn heap_bytes(&self) -> usize { + self.data.capacity() + + self.extras.capacity() * std::mem::size_of::() + + self.extras.iter().map(|e| { + e.zerowidth.capacity() * std::mem::size_of::() + + e.hyperlink_id.as_ref().map_or(0, |s| s.capacity()) + + e.hyperlink_uri.as_ref().map_or(0, |s| s.capacity()) + }).sum::() + } + + /// Total number of encoded cells. + pub fn encoded_cells(&self) -> usize { + self.content_cells as usize + } + + /// Whether the WRAPLINE flag was set on the original row. + pub fn wrapline(&self) -> bool { + self.wrapline + } +} + +/// Find the index past the last cell that carries meaningful content. +/// +/// Scans backwards from the end of the row, skipping cells that are empty +/// (per `GridCell::is_empty`). WRAPLINE on the last column is handled +/// separately by the `wrapline` field, so we ignore it here. +fn content_length(row: &Row, columns: usize) -> usize { + for i in (0..columns).rev() { + let cell = &row[Column(i)]; + let dominated_by_wrapline = + i == columns - 1 && cell.flags == Flags::WRAPLINE && cell.c == ' '; + if !cell.is_empty() && !dominated_by_wrapline { + return i + 1; + } + } + 0 +} + +/// Dense row memory: `columns * size_of::()` plus Vec overhead. +pub fn dense_row_bytes(columns: usize) -> usize { + columns * std::mem::size_of::() + std::mem::size_of::>() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::vte::ansi::{Color, NamedColor, Rgb}; + + fn default_cell() -> Cell { + Cell::default() + } + + fn cell_with_char(c: char) -> Cell { + Cell { c, ..Cell::default() } + } + + fn colored_cell(c: char, fg: Color, bg: Color) -> Cell { + Cell { c, fg, bg, ..Cell::default() } + } + + fn cell_with_flags(c: char, flags: Flags) -> Cell { + Cell { c, flags, ..Cell::default() } + } + + fn assert_rows_equal(original: &Row, decompressed: &Row, columns: usize) { + for col in 0..columns { + let orig = &original[Column(col)]; + let dec = &decompressed[Column(col)]; + assert_eq!( + orig, dec, + "Cell mismatch at column {col}: original={orig:?}, decompressed={dec:?}" + ); + } + } + + // ----------------------------------------------------------------------- + // Round-trip: compress then decompress must be lossless + // ----------------------------------------------------------------------- + + #[test] + fn round_trip_empty_row() { + let row = Row::::new(160); + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_eq!(compact.columns(), 160); + assert_eq!(compact.encoded_cells(), 0); + assert_rows_equal(&row, &restored, 160); + } + + #[test] + fn round_trip_single_char() { + let mut row = Row::::new(80); + row[Column(0)] = cell_with_char('A'); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_eq!(compact.encoded_cells(), 1); + assert_rows_equal(&row, &restored, 80); + } + + #[test] + fn round_trip_short_text() { + let text = "hello world"; + let mut row = Row::::new(160); + for (i, c) in text.chars().enumerate() { + row[Column(i)] = cell_with_char(c); + } + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_eq!(compact.encoded_cells(), text.len()); + assert_rows_equal(&row, &restored, 160); + } + + #[test] + fn round_trip_coloured_text() { + let mut row = Row::::new(80); + let red = Color::Named(NamedColor::Red); + let green = Color::Named(NamedColor::Green); + let blue = Color::Named(NamedColor::Blue); + let default_bg = Color::Named(NamedColor::Background); + + row[Column(0)] = colored_cell('h', red, default_bg); + row[Column(1)] = colored_cell('e', red, default_bg); + row[Column(2)] = colored_cell('l', green, default_bg); + row[Column(3)] = colored_cell('l', green, default_bg); + row[Column(4)] = colored_cell('o', blue, default_bg); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_eq!(compact.encoded_cells(), 5); + assert_rows_equal(&row, &restored, 80); + } + + #[test] + fn round_trip_bold_italic_text() { + let mut row = Row::::new(40); + row[Column(0)] = cell_with_flags('B', Flags::BOLD); + row[Column(1)] = cell_with_flags('I', Flags::ITALIC); + row[Column(2)] = cell_with_flags('X', Flags::BOLD | Flags::ITALIC); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 40); + } + + #[test] + fn round_trip_wrapline() { + let mut row = Row::::new(5); + for i in 0..5 { + row[Column(i)] = cell_with_char((b'a' + i as u8) as char); + } + row[Column(4)].flags.insert(Flags::WRAPLINE); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert!(compact.wrapline()); + assert_eq!(compact.encoded_cells(), 5); + assert_rows_equal(&row, &restored, 5); + } + + #[test] + fn round_trip_wrapline_with_trailing_spaces() { + let mut row = Row::::new(10); + row[Column(0)] = cell_with_char('x'); + row[Column(9)].flags.insert(Flags::WRAPLINE); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert!(compact.wrapline()); + assert_rows_equal(&row, &restored, 10); + } + + #[test] + fn round_trip_wide_char() { + let mut row = Row::::new(20); + row[Column(0)] = Cell { c: '漢', flags: Flags::WIDE_CHAR, ..Cell::default() }; + row[Column(1)] = Cell { c: ' ', flags: Flags::WIDE_CHAR_SPACER, ..Cell::default() }; + row[Column(2)] = cell_with_char('a'); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 20); + } + + #[test] + fn round_trip_leading_wide_char_spacer() { + let mut row = Row::::new(10); + row[Column(9)] = + Cell { c: ' ', flags: Flags::LEADING_WIDE_CHAR_SPACER, ..Cell::default() }; + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 10); + } + + #[test] + fn round_trip_rgb_colours() { + let mut row = Row::::new(40); + let fg = Color::Spec(Rgb { r: 255, g: 128, b: 0 }); + let bg = Color::Spec(Rgb { r: 0, g: 0, b: 64 }); + row[Column(0)] = colored_cell('R', fg, bg); + row[Column(1)] = colored_cell('G', fg, bg); + + let indexed_fg = Color::Indexed(196); + let indexed_bg = Color::Indexed(17); + row[Column(2)] = colored_cell('I', indexed_fg, indexed_bg); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 40); + } + + #[test] + fn round_trip_zerowidth_chars() { + let mut row = Row::::new(20); + let mut cell = cell_with_char('e'); + cell.push_zerowidth('\u{0301}'); + row[Column(0)] = cell; + row[Column(1)] = cell_with_char('x'); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 20); + } + + #[test] + fn round_trip_hyperlink() { + use crate::term::cell::Hyperlink; + + let mut row = Row::::new(20); + let mut cell = cell_with_char('L'); + cell.set_hyperlink(Some(Hyperlink::new(Some("id1"), "https://example.com".to_string()))); + row[Column(0)] = cell; + row[Column(1)] = cell_with_char('x'); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 20); + } + + #[test] + fn round_trip_underline_colour() { + let mut row = Row::::new(20); + let mut cell = cell_with_char('U'); + cell.flags.insert(Flags::UNDERLINE); + cell.set_underline_color(Some(Color::Spec(Rgb { r: 255, g: 0, b: 0 }))); + row[Column(0)] = cell; + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 20); + } + + #[test] + fn round_trip_non_default_background() { + let mut row = Row::::new(40); + let bg = Color::Named(NamedColor::Blue); + for i in 0..40 { + row[Column(i)] = Cell { bg, ..Cell::default() }; + } + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_eq!(compact.encoded_cells(), 40); + assert_rows_equal(&row, &restored, 40); + } + + #[test] + fn round_trip_strikeout() { + let mut row = Row::::new(10); + row[Column(0)] = cell_with_flags('S', Flags::STRIKEOUT); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 10); + } + + #[test] + fn round_trip_all_underline_variants() { + let mut row = Row::::new(20); + row[Column(0)] = cell_with_flags('1', Flags::UNDERLINE); + row[Column(1)] = cell_with_flags('2', Flags::DOUBLE_UNDERLINE); + row[Column(2)] = cell_with_flags('3', Flags::UNDERCURL); + row[Column(3)] = cell_with_flags('4', Flags::DOTTED_UNDERLINE); + row[Column(4)] = cell_with_flags('5', Flags::DASHED_UNDERLINE); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 20); + } + + #[test] + fn round_trip_inverse_and_hidden() { + let mut row = Row::::new(10); + row[Column(0)] = cell_with_flags('I', Flags::INVERSE); + row[Column(1)] = cell_with_flags('H', Flags::HIDDEN); + row[Column(2)] = cell_with_flags('D', Flags::DIM); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 10); + } + + #[test] + fn round_trip_tab_characters() { + let mut row = Row::::new(20); + row[Column(0)] = Cell { c: '\t', ..Cell::default() }; + row[Column(1)] = cell_with_char('x'); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 20); + } + + #[test] + fn round_trip_content_gap() { + let mut row = Row::::new(20); + row[Column(0)] = cell_with_char('A'); + row[Column(19)] = cell_with_char('Z'); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_eq!(compact.encoded_cells(), 20); + assert_rows_equal(&row, &restored, 20); + } + + #[test] + fn round_trip_full_row() { + let mut row = Row::::new(80); + for i in 0..80 { + row[Column(i)] = cell_with_char((b'!' + (i % 94) as u8) as char); + } + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_eq!(compact.encoded_cells(), 80); + assert_rows_equal(&row, &restored, 80); + } + + #[test] + fn round_trip_one_column_row() { + let mut row = Row::::new(1); + row[Column(0)] = cell_with_char('X'); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, 1); + } + + #[test] + fn round_trip_alternating_colours() { + let mut row = Row::::new(100); + let red = Color::Named(NamedColor::Red); + let blue = Color::Named(NamedColor::Blue); + let default_bg = Color::Named(NamedColor::Background); + + for i in 0..50 { + let fg = if i % 2 == 0 { red } else { blue }; + row[Column(i)] = colored_cell((b'a' + (i % 26) as u8) as char, fg, default_bg); + } + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_eq!(compact.encoded_cells(), 50); + assert_rows_equal(&row, &restored, 100); + } + + // ----------------------------------------------------------------------- + // Compression ratio tests + // ----------------------------------------------------------------------- + + #[test] + fn compression_ratio_empty_row() { + let row = Row::::new(160); + let compact = CompactRow::compress(&row); + + let dense = dense_row_bytes(160); + let compressed = compact.heap_bytes() + std::mem::size_of::(); + + let ratio = dense as f64 / compressed as f64; + eprintln!("Empty row: dense={dense}, compressed={compressed}, ratio={ratio:.1}×"); + + assert!( + ratio > 30.0, + "Empty row: dense={dense}, compressed={compressed}, ratio={ratio:.1}×", + ); + } + + #[test] + fn compression_ratio_short_line() { + let mut row = Row::::new(160); + for (i, c) in "cargo build --release".chars().enumerate() { + row[Column(i)] = cell_with_char(c); + } + + let compact = CompactRow::compress(&row); + + let dense = dense_row_bytes(160); + let compressed = compact.heap_bytes() + std::mem::size_of::(); + + let ratio = dense as f64 / compressed as f64; + eprintln!("Short line: dense={dense}, compressed={compressed}, ratio={ratio:.1}×"); + + assert!( + ratio > 10.0, + "Short line: dense={dense}, compressed={compressed}, ratio={ratio:.1}×", + ); + } + + #[test] + fn compression_ratio_coloured_ls_output() { + let mut row = Row::::new(160); + let colours = [ + Color::Named(NamedColor::Blue), + Color::Named(NamedColor::Green), + Color::Named(NamedColor::Red), + Color::Named(NamedColor::Cyan), + Color::Named(NamedColor::Yellow), + ]; + let default_bg = Color::Named(NamedColor::Background); + + let mut col = 0; + for (file_idx, name) in + ["Cargo.toml", "src/", "README.md", "target/", "tests/"].iter().enumerate() + { + let fg = colours[file_idx % colours.len()]; + for c in name.chars() { + if col < 160 { + row[Column(col)] = colored_cell(c, fg, default_bg); + col += 1; + } + } + if col < 160 { + row[Column(col)] = default_cell(); + col += 1; + } + if col < 160 { + row[Column(col)] = default_cell(); + col += 1; + } + } + + let compact = CompactRow::compress(&row); + + let dense = dense_row_bytes(160); + let compressed = compact.heap_bytes() + std::mem::size_of::(); + + let ratio = dense as f64 / compressed as f64; + eprintln!("ls output: dense={dense}, compressed={compressed}, ratio={ratio:.1}×"); + + assert!( + ratio > 5.0, + "ls output: dense={dense}, compressed={compressed}, ratio={ratio:.1}×", + ); + } + + #[test] + fn compression_ratio_full_line_single_colour() { + let mut row = Row::::new(200); + for i in 0..200 { + row[Column(i)] = cell_with_char((b'!' + (i % 94) as u8) as char); + } + + let compact = CompactRow::compress(&row); + + let dense = dense_row_bytes(200); + let compressed = compact.heap_bytes() + std::mem::size_of::(); + + let ratio = dense as f64 / compressed as f64; + eprintln!("Full single-colour: dense={dense}, compressed={compressed}, ratio={ratio:.1}×"); + + assert!( + ratio > 5.0, + "Full single-colour: dense={dense}, compressed={compressed}, ratio={ratio:.1}×", + ); + } + + // ----------------------------------------------------------------------- + // Aggregate memory savings test + // ----------------------------------------------------------------------- + + #[test] + fn aggregate_scrollback_memory_savings() { + let columns = 160; + let scrollback_lines = 10_000; + + let dense_total = dense_row_bytes(columns) * scrollback_lines; + + let mut compressed_total: usize = 0; + + for line in 0..scrollback_lines { + let mut row = Row::::new(columns); + match line % 10 { + // 30% empty lines + 0 | 1 | 2 => {}, + // 40% short lines (prompts, short output) + 3 | 4 | 5 | 6 => { + let text_len = 20 + (line % 60); + for i in 0..text_len.min(columns) { + row[Column(i)] = cell_with_char((b'a' + (i % 26) as u8) as char); + } + }, + // 20% medium coloured lines + 7 | 8 => { + let colours = [ + Color::Named(NamedColor::Red), + Color::Named(NamedColor::Green), + Color::Named(NamedColor::Blue), + ]; + let default_bg = Color::Named(NamedColor::Background); + let text_len = 60 + (line % 40); + for i in 0..text_len.min(columns) { + let fg = colours[i / 20 % colours.len()]; + row[Column(i)] = + colored_cell((b'A' + (i % 26) as u8) as char, fg, default_bg); + } + }, + // 10% full wrapped lines + _ => { + for i in 0..columns { + row[Column(i)] = cell_with_char((b'!' + (i % 94) as u8) as char); + } + row[Column(columns - 1)].flags.insert(Flags::WRAPLINE); + }, + } + + let compact = CompactRow::compress(&row); + compressed_total += compact.heap_bytes() + std::mem::size_of::(); + } + + let ratio = dense_total as f64 / compressed_total as f64; + + eprintln!( + "Aggregate: dense={dense_total}, compressed={compressed_total}, ratio={ratio:.1}×" + ); + + assert!( + ratio > 10.0, + "Aggregate: dense={dense_total}, compressed={compressed_total}, ratio={ratio:.1}×" + ); + } + + // ----------------------------------------------------------------------- + // Edge cases and invariants + // ----------------------------------------------------------------------- + + #[test] + fn wrapline_not_in_span_flags() { + let mut row = Row::::new(5); + for i in 0..5 { + row[Column(i)] = cell_with_char((b'a' + i as u8) as char); + } + row[Column(4)].flags.insert(Flags::WRAPLINE); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert!(!restored[Column(3)].flags.contains(Flags::WRAPLINE)); + assert!(restored[Column(4)].flags.contains(Flags::WRAPLINE)); + assert!(compact.wrapline()); + } + + #[test] + fn wrapline_only_on_empty_row() { + let mut row = Row::::new(10); + row[Column(9)].flags.insert(Flags::WRAPLINE); + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert!(compact.wrapline()); + assert_eq!(compact.encoded_cells(), 0); + assert_rows_equal(&row, &restored, 10); + } + + #[test] + fn decompress_columns_match() { + let row = Row::::new(200); + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_eq!(restored.len(), 200); + } + + #[test] + fn content_length_stops_at_last_non_empty() { + let mut row = Row::::new(100); + row[Column(0)] = cell_with_char('A'); + row[Column(50)] = cell_with_char('B'); + + let len = content_length(&row, 100); + assert_eq!(len, 51); + } + + #[test] + fn content_length_all_empty() { + let row = Row::::new(80); + assert_eq!(content_length(&row, 80), 0); + } + + #[test] + fn content_length_last_cell_non_empty() { + let mut row = Row::::new(10); + row[Column(9)] = cell_with_char('Z'); + assert_eq!(content_length(&row, 10), 10); + } + + #[test] + fn compress_is_deterministic() { + let mut row = Row::::new(40); + let fg = Color::Named(NamedColor::Green); + let default_bg = Color::Named(NamedColor::Background); + for i in 0..20 { + row[Column(i)] = colored_cell((b'a' + (i % 26) as u8) as char, fg, default_bg); + } + + let a = CompactRow::compress(&row); + let b = CompactRow::compress(&row); + assert_eq!(a, b); + } + + // ----------------------------------------------------------------------- + // Stress: round-trip many diverse rows + // ----------------------------------------------------------------------- + + #[test] + fn round_trip_many_rows() { + let columns = 120; + let named_colours = [ + NamedColor::Black, + NamedColor::Red, + NamedColor::Green, + NamedColor::Yellow, + NamedColor::Blue, + NamedColor::Magenta, + NamedColor::Cyan, + NamedColor::White, + ]; + + for seed in 0..200 { + let mut row = Row::::new(columns); + let content_len = (seed * 7 + 3) % (columns + 1); + for i in 0..content_len { + let colour_idx = (seed + i) % named_colours.len(); + let c = (b'!' + ((seed + i) % 94) as u8) as char; + let fg = Color::Named(named_colours[colour_idx]); + row[Column(i)] = colored_cell(c, fg, Color::Named(NamedColor::Background)); + + if seed % 5 == 0 && i == content_len - 1 { + row[Column(i)].flags.insert(Flags::BOLD); + } + } + if seed % 3 == 0 && content_len > 0 { + row[Column(columns - 1)].flags.insert(Flags::WRAPLINE); + } + + let compact = CompactRow::compress(&row); + let restored = compact.decompress(); + + assert_rows_equal(&row, &restored, columns); + } + } +} \ No newline at end of file diff --git a/alacritty_terminal/src/grid/mod.rs b/alacritty_terminal/src/grid/mod.rs index 8d7faadaa78..44508923ffc 100644 --- a/alacritty_terminal/src/grid/mod.rs +++ b/alacritty_terminal/src/grid/mod.rs @@ -7,15 +7,17 @@ use std::ops::{Bound, Deref, Index, IndexMut, Range, RangeBounds}; use serde::{Deserialize, Serialize}; use crate::index::{Column, Line, Point}; -use crate::term::cell::{Flags, ResetDiscriminant}; +use crate::term::cell::{Cell, Flags, ResetDiscriminant}; use crate::vte::ansi::{CharsetIndex, StandardCharset}; +pub mod compact; pub mod resize; mod row; mod storage; #[cfg(test)] mod tests; +pub use self::compact::CompactRow; pub use self::row::Row; use self::storage::Storage; @@ -137,6 +139,14 @@ pub struct Grid { /// Maximum number of lines in history. max_scroll_limit: usize, + + /// Compressed scrollback rows, ordered oldest-first. + /// + /// When scrollback exceeds a threshold, old history rows are compressed + /// into this vec to reduce memory usage. They are decompressed back into + /// the ring buffer on demand (e.g. when the user scrolls up). + #[cfg_attr(feature = "serde", serde(default, skip))] + compressed_history: Vec, } impl Grid { @@ -149,6 +159,7 @@ impl Grid { cursor: Cursor::default(), lines, columns, + compressed_history: Vec::new(), } } @@ -386,6 +397,9 @@ impl Grid { // Explicitly purge all lines from history. self.raw.shrink_lines(self.history_size()); + // Also clear any compressed scrollback. + self.compressed_history.clear(); + // Reset display offset. self.display_offset = 0; } @@ -449,6 +463,7 @@ impl PartialEq for Grid { && self.columns.eq(&other.columns) && self.lines.eq(&other.lines) && self.display_offset.eq(&other.display_offset) + && self.compressed_history.eq(&other.compressed_history) } } @@ -484,7 +499,116 @@ impl IndexMut for Grid { } } -/// Grid dimensions. +// Grid dimensions. +// --------------------------------------------------------------------------- +// Cell-specific scrollback compression +// --------------------------------------------------------------------------- + +impl Grid { + /// Compress old scrollback rows to reduce memory usage. + /// + /// Rows beyond `keep_hot` lines from the visible area are compressed into + /// `CompactRow` form and removed from the ring buffer. This can reduce + /// scrollback memory by 10–40× for typical terminal output. + /// + /// After compression, `history_size()` (hot rows only) decreases, but + /// `total_history_size()` stays the same. + pub fn compress_old_scrollback(&mut self, keep_hot: usize) { + let hot_history = self.history_size(); + if hot_history <= keep_hot { + return; + } + let to_compress = hot_history - keep_hot; + + // Compress the oldest rows first (farthest from visible area). + for i in 0..to_compress { + let line_idx = Line(-((hot_history - i) as i32)); + let compact = CompactRow::compress(&self.raw[line_idx]); + self.compressed_history.push(compact); + } + + // Remove compressed rows from the ring buffer. + self.raw.shrink_lines(to_compress); + self.display_offset = min(self.display_offset, self.history_size()); + } + + /// Decompress the newest N compressed history rows back into the ring + /// buffer so they become accessible via normal `Line` indexing. + pub fn thaw_compressed_history(&mut self, count: usize) { + let count = count.min(self.compressed_history.len()); + if count == 0 { + return; + } + + // Make room in the ring buffer for the decompressed rows. + self.raw.initialize(count, self.columns); + + // The new oldest slots are at the far end of history. + let new_history = self.history_size(); + for i in 0..count { + let compressed_idx = self.compressed_history.len() - count + i; + let decompressed = self.compressed_history[compressed_idx].decompress(); + let line_idx = Line(-((new_history - i) as i32)); + self.raw[line_idx] = decompressed; + } + + // Remove from compressed storage. + self.compressed_history.truncate(self.compressed_history.len() - count); + } + + /// Automatically compress old scrollback if hot history exceeds the threshold. + /// Call this after operations that grow scrollback (e.g. scroll_up). + /// The threshold is 2× the visible screen lines — recent history stays hot + /// for fast scrolling, older history gets compressed. + pub fn compact_scrollback_if_needed(&mut self) { + let threshold = self.lines * 2; + if self.history_size() > threshold { + self.compress_old_scrollback(threshold); + } + } + + /// Scroll the display, automatically thawing compressed rows if needed. + /// This is the Cell-specific version that handles compressed history. + pub fn scroll_display_with_thaw(&mut self, scroll: Scroll) { + let total_history = self.total_history_size(); + let new_offset = match scroll { + Scroll::Delta(count) => { + min(max((self.display_offset as i32) + count, 0) as usize, total_history) + }, + Scroll::PageUp => min(self.display_offset + self.lines, total_history), + Scroll::PageDown => self.display_offset.saturating_sub(self.lines), + Scroll::Top => total_history, + Scroll::Bottom => 0, + }; + + let hot_history = self.history_size(); + if new_offset > hot_history { + let needed = new_offset - hot_history; + self.thaw_compressed_history(needed); + } + + self.scroll_display(scroll); + } + + /// Number of compressed history rows. + pub fn compressed_history_len(&self) -> usize { + self.compressed_history.len() + } + + /// Total history size including both hot and compressed rows. + pub fn total_history_size(&self) -> usize { + self.history_size() + self.compressed_history.len() + } + + /// Approximate heap memory used by compressed history, in bytes. + pub fn compressed_history_bytes(&self) -> usize { + self.compressed_history + .iter() + .map(|r| r.heap_bytes() + std::mem::size_of::()) + .sum() + } +} + pub trait Dimensions { /// Total number of lines in the buffer, this includes scrollback and visible lines. fn total_lines(&self) -> usize; diff --git a/alacritty_terminal/src/grid/tests.rs b/alacritty_terminal/src/grid/tests.rs index f052c75a0fc..36e4a21fd50 100644 --- a/alacritty_terminal/src/grid/tests.rs +++ b/alacritty_terminal/src/grid/tests.rs @@ -3,6 +3,7 @@ use super::*; use crate::term::cell::Cell; +use crate::vte::ansi::{Color, NamedColor}; impl GridCell for usize { fn is_empty(&self) -> bool { @@ -388,3 +389,368 @@ fn wrap_cell(c: char) -> Cell { cell.flags.insert(Flags::WRAPLINE); cell } + +fn colored_cell(c: char, fg: Color, bg: Color) -> Cell { + Cell { c, fg, bg, ..Cell::default() } +} + +// --------------------------------------------------------------------------- +// Scrollback compression integration tests +// --------------------------------------------------------------------------- + +/// Build a grid with `history_lines` of scrollback, each containing +/// identifiable content based on the line number. +fn grid_with_scrollback( + screen_lines: usize, + columns: usize, + history_lines: usize, +) -> Grid { + let mut grid = Grid::::new(screen_lines, columns, history_lines); + + let colours = [ + Color::Named(NamedColor::Red), + Color::Named(NamedColor::Green), + Color::Named(NamedColor::Blue), + Color::Named(NamedColor::Yellow), + Color::Named(NamedColor::Cyan), + ]; + let default_bg = Color::Named(NamedColor::Background); + + // Scroll up `history_lines` times, filling each line with content. + for n in 0..history_lines { + // Write identifiable content into the top visible line before scrolling. + let text_len = 5 + (n % (columns - 5)); + let fg = colours[n % colours.len()]; + for col in 0..text_len { + let c = (b'!' + ((n + col) % 94) as u8) as char; + grid[Line(0)][Column(col)] = colored_cell(c, fg, default_bg); + } + if n % 3 == 0 { + grid[Line(0)][Column(columns - 1)].flags.insert(Flags::WRAPLINE); + } + + grid.scroll_up::(&(Line(0)..Line(screen_lines as i32)), 1); + } + + grid +} + +/// Read all history rows as owned copies for later comparison. +fn snapshot_history(grid: &Grid, columns: usize) -> Vec> { + let history = grid.history_size(); + let mut rows = Vec::with_capacity(history); + for i in 0..history { + let line = Line(-((history - i) as i32)); // oldest first + let mut cells = Vec::with_capacity(columns); + for col in 0..columns { + cells.push(grid[line][Column(col)].clone()); + } + rows.push(cells); + } + rows +} + +#[test] +fn compress_and_thaw_round_trips_all_rows() { + let screen = 10; + let columns = 80; + let history = 200; + + let mut grid = grid_with_scrollback(screen, columns, history); + assert_eq!(grid.history_size(), history); + + // Snapshot the history before compression. + let before = snapshot_history(&grid, columns); + + // Compress all but 20 hot rows. + let keep_hot = 20; + grid.compress_old_scrollback(keep_hot); + + assert_eq!(grid.history_size(), keep_hot); + assert_eq!(grid.compressed_history_len(), history - keep_hot); + assert_eq!(grid.total_history_size(), history); + + // Thaw everything back. + grid.thaw_compressed_history(history - keep_hot); + + assert_eq!(grid.history_size(), history); + assert_eq!(grid.compressed_history_len(), 0); + + // Verify every row matches the original. + let after = snapshot_history(&grid, columns); + for (row_idx, (orig, restored)) in before.iter().zip(after.iter()).enumerate() { + for (col, (o, r)) in orig.iter().zip(restored.iter()).enumerate() { + assert_eq!(o, r, "Mismatch at history row {row_idx}, column {col}"); + } + } +} + +#[test] +fn compress_reduces_memory() { + let screen = 24; + let columns = 160; + let history = 5000; + + let mut grid = grid_with_scrollback(screen, columns, history); + + let dense_bytes = grid.history_size() * super::compact::dense_row_bytes(columns); + + grid.compress_old_scrollback(100); + + let compressed_bytes = grid.compressed_history_bytes(); + let ratio = dense_bytes as f64 / compressed_bytes as f64; + + assert!( + ratio > 3.0, + "Expected >3× compression, got {ratio:.1}× (dense={dense_bytes}, compressed={compressed_bytes})" + ); +} + +#[test] +fn compress_no_op_when_history_below_threshold() { + let mut grid = grid_with_scrollback(10, 40, 50); + + grid.compress_old_scrollback(100); + + assert_eq!(grid.history_size(), 50); + assert_eq!(grid.compressed_history_len(), 0); +} + +#[test] +fn compress_all_then_thaw_all() { + let screen = 5; + let columns = 20; + let history = 30; + + let mut grid = grid_with_scrollback(screen, columns, history); + let before = snapshot_history(&grid, columns); + + // Compress everything (keep_hot = 0). + grid.compress_old_scrollback(0); + assert_eq!(grid.history_size(), 0); + assert_eq!(grid.compressed_history_len(), history); + + // Thaw everything. + grid.thaw_compressed_history(history); + assert_eq!(grid.history_size(), history); + assert_eq!(grid.compressed_history_len(), 0); + + let after = snapshot_history(&grid, columns); + assert_eq!(before, after); +} + +#[test] +fn incremental_thaw() { + let screen = 5; + let columns = 20; + let history = 100; + + let mut grid = grid_with_scrollback(screen, columns, history); + let before = snapshot_history(&grid, columns); + + grid.compress_old_scrollback(10); + assert_eq!(grid.compressed_history_len(), 90); + + // Thaw in batches. + grid.thaw_compressed_history(30); + assert_eq!(grid.history_size(), 40); + assert_eq!(grid.compressed_history_len(), 60); + + grid.thaw_compressed_history(60); + assert_eq!(grid.history_size(), 100); + assert_eq!(grid.compressed_history_len(), 0); + + let after = snapshot_history(&grid, columns); + assert_eq!(before, after); +} + +#[test] +fn thaw_more_than_available_is_clamped() { + let mut grid = grid_with_scrollback(5, 20, 50); + + grid.compress_old_scrollback(10); + assert_eq!(grid.compressed_history_len(), 40); + + grid.thaw_compressed_history(999); + assert_eq!(grid.history_size(), 50); + assert_eq!(grid.compressed_history_len(), 0); +} + +#[test] +fn clear_history_clears_compressed() { + let mut grid = grid_with_scrollback(5, 20, 50); + + grid.compress_old_scrollback(10); + assert_eq!(grid.compressed_history_len(), 40); + + grid.clear_history(); + assert_eq!(grid.history_size(), 0); + assert_eq!(grid.compressed_history_len(), 0); +} + +#[test] +fn display_offset_clamped_after_compress() { + let screen = 10; + let columns = 40; + let history = 100; + + let mut grid = grid_with_scrollback(screen, columns, history); + + // Scroll to the top of history. + grid.scroll_display(Scroll::Top); + assert_eq!(grid.display_offset, history); + + // Compress most of it — display_offset should be clamped to remaining hot. + grid.compress_old_scrollback(20); + assert_eq!(grid.display_offset, 20); +} + +#[test] +fn visible_lines_unchanged_after_compress_thaw() { + let screen = 24; + let columns = 80; + let history = 200; + + let mut grid = grid_with_scrollback(screen, columns, history); + + // Snapshot visible lines. + let mut visible_before = Vec::new(); + for line in 0..screen { + let mut cells = Vec::new(); + for col in 0..columns { + cells.push(grid[Line(line as i32)][Column(col)].clone()); + } + visible_before.push(cells); + } + + grid.compress_old_scrollback(50); + grid.thaw_compressed_history(grid.compressed_history_len()); + + let mut visible_after = Vec::new(); + for line in 0..screen { + let mut cells = Vec::new(); + for col in 0..columns { + cells.push(grid[Line(line as i32)][Column(col)].clone()); + } + visible_after.push(cells); + } + + assert_eq!(visible_before, visible_after); +} + +#[test] +fn compact_scrollback_if_needed_compresses_when_over_threshold() { + let screen = 10; + let columns = 40; + // Build enough history to exceed 2× screen lines (threshold = 20). + let history = 50; + + let mut grid = grid_with_scrollback(screen, columns, history); + + // Snapshot all history before compression. + let before = snapshot_history(&grid, columns); + assert_eq!(grid.history_size(), history); + assert_eq!(grid.compressed_history_len(), 0); + + grid.compact_scrollback_if_needed(); + + // Hot history should be clamped to the threshold (2 × screen = 20). + let threshold = screen * 2; + assert_eq!(grid.history_size(), threshold); + assert_eq!(grid.compressed_history_len(), history - threshold); + // Total history is unchanged. + assert_eq!(grid.total_history_size(), history); + + // Thaw everything back and verify the rows round-trip. + grid.thaw_compressed_history(grid.compressed_history_len()); + let after = snapshot_history(&grid, columns); + assert_eq!(before, after); +} + +#[test] +fn compact_scrollback_if_needed_noop_when_below_threshold() { + let screen = 10; + let columns = 40; + // History equal to threshold — should not compress. + let history = 20; + + let mut grid = grid_with_scrollback(screen, columns, history); + grid.compact_scrollback_if_needed(); + + assert_eq!(grid.history_size(), history); + assert_eq!(grid.compressed_history_len(), 0); +} + +#[test] +fn scroll_display_with_thaw_into_compressed_territory() { + let screen = 10; + let columns = 40; + let history = 100; + + let mut grid = grid_with_scrollback(screen, columns, history); + + // Snapshot history before any compression. + let full_history = snapshot_history(&grid, columns); + + // Compress most of history, keeping only 20 hot rows. + let keep_hot = 20; + grid.compress_old_scrollback(keep_hot); + assert_eq!(grid.history_size(), keep_hot); + assert_eq!(grid.compressed_history_len(), history - keep_hot); + + // Scroll to the very top — this requires thawing all compressed rows. + grid.scroll_display_with_thaw(Scroll::Top); + + // display_offset should now cover the full history. + assert_eq!(grid.display_offset(), grid.history_size()); + assert_eq!(grid.total_history_size(), history); + // All compressed rows should have been thawed. + assert_eq!(grid.compressed_history_len(), 0); + + // Verify the restored history matches the original. + let restored = snapshot_history(&grid, columns); + assert_eq!(full_history, restored); +} + +#[test] +fn scroll_display_with_thaw_page_up_incremental() { + let screen = 10; + let columns = 40; + let history = 60; + + let mut grid = grid_with_scrollback(screen, columns, history); + + // Compress, keeping 10 hot rows. + grid.compress_old_scrollback(10); + assert_eq!(grid.history_size(), 10); + assert_eq!(grid.compressed_history_len(), 50); + + // PageUp once — offset goes from 0 to 10 (one page = screen lines). + // This stays within hot history, no thaw needed. + grid.scroll_display_with_thaw(Scroll::PageUp); + assert_eq!(grid.display_offset(), 10); + assert_eq!(grid.compressed_history_len(), 50); + + // PageUp again — offset wants to go to 20 but only 10 hot remain, + // so 10 compressed rows must be thawed. + grid.scroll_display_with_thaw(Scroll::PageUp); + assert_eq!(grid.display_offset(), 20); + assert!(grid.compressed_history_len() <= 40); +} + +#[test] +fn scroll_display_with_thaw_bottom_does_not_thaw() { + let screen = 10; + let columns = 40; + let history = 50; + + let mut grid = grid_with_scrollback(screen, columns, history); + grid.compress_old_scrollback(10); + let compressed_before = grid.compressed_history_len(); + + // Scrolling to bottom should not thaw anything. + grid.scroll_display_with_thaw(Scroll::Bottom); + assert_eq!(grid.display_offset(), 0); + assert_eq!(grid.compressed_history_len(), compressed_before); +} + diff --git a/tasks.md b/tasks.md new file mode 100644 index 00000000000..588e004f452 --- /dev/null +++ b/tasks.md @@ -0,0 +1,263 @@ +# Alacritty Terminal State Serialisation + +Branch: `zed-pty` +Repo: `~/src/play/alacritty-serialisation` +Consumer: `~/src/play/zed-terminal-pty/crates/pty_host/` + +--- + +## Context + +Zed's `pty_host` crate runs a headless `Term` in the shepherd +daemon to track terminal grid state. When a client reconnects after a Zed +restart, the grid state must be transferred to the client's real `Term` to +reconstruct the display — including scrollback, cell attributes, wrapped-line +state, and cursor position. + +### Prior art + +**VS Code / Kiro** use `@xterm/headless` (server-side JS terminal emulator) + +`@xterm/addon-serialize` (serialises the xterm.js buffer to ANSI escape +sequences). The serialised string is sent as `initialText` when reviving a +terminal process. Both sides are xterm.js, so the ANSI round-trip works. The +serialize addon is mature (~1,200 lines) and handles wrapping, terminal modes, +alternate screen, underline colour, scroll regions, and cursor SGR state. + +**tmux** maintains a server-side virtual terminal and redraws the current +viewport on client attach. Scrollback lives in tmux's server, accessed via +tmux's own scroll commands — never replayed as ANSI. + +### Our approach: direct serde of `Grid` + +Since both the pty_host (headless) and the Zed client use +`alacritty_terminal::Grid`, we can skip the ANSI encode/decode round-trip +entirely and serialise the grid struct directly using serde. + +**Why not ANSI?** An ANSI encoder must handle wrapping, SGR diffing, colour +models, cursor positioning, terminal modes, alternate screen, scroll regions, +and cursor SGR state. VS Code's addon-serialize does all of this, but it's +TypeScript tied to xterm.js. Reimplementing it in Rust is ~1,000+ lines of +terminal-specific code with an ongoing maintenance burden. The serde approach +gives lossless fidelity with zero custom serialisation logic. + +**Why not xterm.js?** It's TypeScript, tied to xterm.js's buffer API. Zed's +terminal is Rust + alacritty_terminal. Using it would require embedding a JS +runtime or running a sidecar — disproportionate complexity for one feature. + +**Size:** Binary serde (bincode) produces ~576 KB for an 80×24 terminal with +40 lines of content. This transfers over IPC in <1ms. For reconnect (a one-time +event), this is negligible. + +--- + +## What serde gives us for free + +The gaps identified in `gaps.md` (comparing our ANSI encoder against VS Code's +serialize addon) are largely **eliminated** by the serde approach, because we +serialise the raw struct rather than encoding semantics as escape sequences: + +| Gap | ANSI approach | Serde approach | +|-----|---------------|----------------| +| 🔴 G1 — Terminal modes | Must emit DECSET/DECRST sequences | **Eliminated** — `TermMode` bitflags serialise directly | +| 🔴 G2 — Alternate screen buffer | Must serialise both buffers with `CSI ?1049h` switch | **Eliminated** — both `grid` and `inactive_grid` serialise directly | +| 🟡 G3 — Scroll region (DECSTBM) | Must emit `CSI Pt;Pb r` | **Eliminated** — `scroll_region` field serialises directly | +| 🟡 G4 — Wrapped lines | Must suppress `\r\n` and force-wrap; ~60 lines of edge cases | **Eliminated** — `WRAPLINE` flag on cells serialises directly | +| 🟡 G5 — Cursor SGR state | Must diff cursor template cell against reset state | **Eliminated** — `cursor.template` serialises directly | +| ⚪ G6 — ECH vs spaces | Cosmetic efficiency | **N/A** — no escape sequences emitted | +| ⚪ G7 — Overline / blink flags | Must add to attr mask + emit SGR 5/53 | **Eliminated** — all `Flags` bits serialise directly | +| ⚪ G8 — Underline colour | Must emit SGR 58:2/58:5 | **Eliminated** — `CellExtra` serialises directly | +| ⚪ G9 — Flag removal efficiency | Must implement individual SGR off-codes | **N/A** — no escape sequences emitted | + +The only item that needs work is the **cursor**, which is currently +`#[serde(skip)]` on `Grid`. Everything else already round-trips through serde. + +--- + +## Completed work + +### ✅ T1 — ANSI `serialize_grid()` (proof of concept) + +Implemented in `alacritty_terminal/src/term/serialize.rs`. Encodes grid state +as ANSI escape sequences with comprehensive spec references (ECMA-48, ITU-T +T.416, XTerm ctlseqs, kitty). Has 20 passing round-trip tests covering text, +colours, wide chars, combining chars, scrollback, underline variants, and +attribute diffing. + +**Known limitations** (all eliminated by the serde approach): +- Soft-wrapped lines become hard newlines → broken selection/paste (G4) +- Terminal modes not preserved → bracketed paste safety issue (G1) +- Alternate screen not serialised → vim/less/htop lose everything (G2) +- Underline colour, overline, blink not emitted (G7, G8) +- Cursor SGR style reset to default (G5) + +Kept as a reference and fallback. Module docs serve as the authoritative +escape sequence reference for the project. + +### ✅ T2 — Serde proof of concept + +`Grid` already derives `Serialize`/`Deserialize` (behind the `serde` +feature flag, on by default). Round-trip tests pass with JSON, bincode, and +postcard. Cell content, attributes, colours, `WRAPLINE` flags, and scrollback +all survive losslessly. See `grid_serde_*` and `serde_vs_ansi_size_comparison` +tests in `serialize.rs`. + +**Known issue:** `Grid.cursor` and `Grid.saved_cursor` are `#[serde(skip)]` — +they default to (0,0) after deserialise. This is the one thing that needs +fixing (T3 below). + +--- + +## Remaining tasks + +### T3. Include cursor in Grid serde + +**Goal:** Cursor position and template cell survive serialisation. + +`Grid` currently has: +```rust +#[cfg_attr(feature = "serde", serde(skip))] +pub cursor: Cursor, + +#[cfg_attr(feature = "serde", serde(skip))] +pub saved_cursor: Cursor, +``` + +The `Cursor` struct doesn't derive `Serialize`/`Deserialize` because it +contains `Charsets` which wraps `[StandardCharset; 4]`, and `StandardCharset` +(defined in the `vte` crate) lacks serde derives. + +**Approach (simplest):** +1. Add `#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]` to + `Cursor` in `alacritty_terminal/src/grid/mod.rs` +2. Mark `charsets: Charsets` as `#[cfg_attr(feature = "serde", serde(skip))]` + — charsets default to ASCII on deserialise, which is fine (they're almost + never non-default and not critical for reconnect) +3. Remove the `serde(skip)` from `cursor` and `saved_cursor` on `Grid` +4. Add/update round-trip test: verify cursor position and cursor template cell + (fg, bg, flags) survive + +**Files to modify:** +- `alacritty_terminal/src/grid/mod.rs` — `Cursor` derive + `Grid` fields + +**Verify:** `cargo test -p alacritty_terminal -- serialize` + +### T4. Include terminal modes in serialisation + +**Goal:** `TermMode` bitflags (bracketed paste, mouse tracking, etc.) survive +serialisation. + +`TermMode` is a `bitflags` struct. With the `serde` feature enabled on +`bitflags` (already the case — see `Cargo.toml`), it should already derive +`Serialize`/`Deserialize`. + +**Approach:** +1. Check whether `TermMode` already derives serde traits +2. Decide where modes live in the serialised payload — either: + - Add a `serialize_state()` method on `Term` that returns a struct + containing the `Grid` + `TermMode` + any other top-level state + - Or serialise `TermMode` as a sidecar alongside the grid bytes +3. On the client side, after deserialising the grid, apply the mode bitflags + to the client's `Term` +4. Test: create a term with bracketed paste + mouse mode enabled, round-trip, + verify modes survive + +**Why this matters:** Without bracketed paste mode, pasting text after +reconnect is interpreted as raw keystrokes — potentially executing commands. +This is the highest-priority gap from `gaps.md` (G1). + +### T5. Include alternate screen buffer + +**Goal:** If the terminal is on the alternate screen (vim, less, htop), both +buffers survive serialisation. + +`Term` stores the inactive buffer in `inactive_grid: Grid`. Check: +- Does `Term` expose the inactive grid? +- Can we serialise a `TermSnapshot` struct containing both grids + modes? + +**Approach:** +1. Define a serialisable struct: + ```rust + pub struct TermState { + pub grid: Grid, + pub inactive_grid: Grid, + pub mode: TermMode, + pub scroll_region: Range, + // ... any other fields needed + } + ``` +2. Add `Term::snapshot(&self) -> TermState` and + `Term::restore(state: TermState)` (or equivalent) +3. The client deserialises `TermState` and swaps it into its `Term` +4. Test: enter alternate screen, write content, snapshot, restore, verify both + buffers + +### T6. Public API: `Term::to_bytes()` / `Term::from_bytes()` + +**Goal:** Clean public API for the pty_host consumer. + +```rust +impl Term { + /// Serialise terminal state (grid, cursor, modes, alt screen) to bytes. + pub fn to_bytes(&self) -> Vec; +} + +impl Term { + /// Restore terminal state from bytes. + pub fn from_bytes(bytes: &[u8], config: &Config) -> Result; +} +``` + +Thin wrapper around serde + the chosen binary format (bincode recommended). +The derive does all the real work. + +### T7. Integration: replace ANSI serialisation in pty_host + +In `~/src/play/zed-terminal-pty/crates/pty_host/src/headless.rs`: + +1. Replace `HeadlessTerminal::serialize_grid()` (~400 lines of hand-rolled + ANSI emission) with `term.to_bytes()` +2. On the client side, replace `Processor::advance(&mut term, &serialised)` + with `Term::from_bytes(&bytes)` and swap into the client +3. Delete all hand-rolled helpers: `emit_sgr_diff`, `emit_sgr_flags`, + `emit_fg_color`, `emit_bg_color`, `named_color_to_sgr_*`, `write_sgr_*`, + `write_cup`, `write_u32` + +The pty_host's serialise/restore becomes two one-liners. + +### T8. (Future) Login shell wrapping + +`pty_host/src/session.rs` spawns the shell with a plain `Command::new(shell)`. +On macOS, alacritty's `tty/unix.rs` wraps this in +`/usr/bin/login -flp $USER /bin/zsh -fc "exec -a -zsh $SHELL"` for proper +session setup. Expose `default_shell_command()` or equivalent from +`alacritty_terminal`. + +--- + +## Dev setup + +```sh +# Run alacritty_terminal tests (including serde round-trip tests) +cargo test -p alacritty_terminal + +# In the Zed repo — verify pty_host still compiles and its tests pass +cd ~/src/play/zed-terminal-pty +cargo test -p pty_host +``` + +The Zed workspace has a `[patch]` pointing `alacritty_terminal` at this local +checkout, so changes here are picked up automatically. + +--- + +## Reference + +- `gaps.md` — full gap analysis comparing our ANSI encoder vs VS Code's + serialize addon (all gaps eliminated by the serde approach except cursor) +- `alacritty_terminal/src/term/serialize.rs` — ANSI encoder (T1) + serde + proof-of-concept tests (T2) + escape sequence spec references +- VS Code pty host: `src/vs/platform/terminal/node/ptyService.ts` — + `serializeTerminalState()`, `_reviveTerminalProcess()`, + `generateReplayEvent()` with `@xterm/addon-serialize` +- VS Code serialize addon: + [SerializeAddon.ts](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-serialize/src/SerializeAddon.ts) \ No newline at end of file From dc6c96450bcce843e098ccf6d8946e74d56ec038 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Fri, 3 Apr 2026 23:57:03 +1100 Subject: [PATCH 3/6] Add Term::scroll_display_with_thaw for compression-aware scrolling Mirrors Term::scroll_display but delegates to Grid::scroll_display_with_thaw, which automatically decompresses compressed scrollback rows when the scroll target reaches into compressed history. --- alacritty_terminal/src/term/mod.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/alacritty_terminal/src/term/mod.rs b/alacritty_terminal/src/term/mod.rs index 4f90b8467b9..568b3943674 100644 --- a/alacritty_terminal/src/term/mod.rs +++ b/alacritty_terminal/src/term/mod.rs @@ -408,6 +408,30 @@ impl Term { } } + /// Scroll the display, automatically decompressing compressed scrollback + /// rows if the scroll target reaches into compressed history. + #[inline] + pub fn scroll_display_with_thaw(&mut self, scroll: Scroll) + where + T: EventListener, + { + let old_display_offset = self.grid.display_offset(); + self.grid.scroll_display_with_thaw(scroll); + self.event_proxy.send_event(Event::MouseCursorDirty); + + // Clamp vi mode cursor to the viewport. + let viewport_start = -(self.grid.display_offset() as i32); + let viewport_end = viewport_start + self.bottommost_line().0; + let vi_cursor_line = &mut self.vi_mode_cursor.point.line.0; + *vi_cursor_line = cmp::min(viewport_end, cmp::max(viewport_start, *vi_cursor_line)); + self.vi_mode_recompute_selection(); + + // Damage everything if display offset changed. + if old_display_offset != self.grid().display_offset() { + self.mark_fully_damaged(); + } + } + pub fn new(config: Config, dimensions: &D, event_proxy: T) -> Term { let num_cols = dimensions.columns(); let num_lines = dimensions.screen_lines(); From f8d78fcc81a9a692d57f13298c74c1d237863942 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Sat, 4 Apr 2026 01:03:08 +1100 Subject: [PATCH 4/6] Add compression performance tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No-op hot path: 10k calls in <5ms (single integer comparison). Compression: 11.2 µs/row (release), 269 µs per screenful. Decompression: 5.3 µs/row (release), 127 µs per screenful. The decompression latency assertion only fires in release builds to avoid false positives from debug-mode overhead. --- alacritty_terminal/src/grid/tests.rs | 100 +++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/alacritty_terminal/src/grid/tests.rs b/alacritty_terminal/src/grid/tests.rs index 36e4a21fd50..acff4785f44 100644 --- a/alacritty_terminal/src/grid/tests.rs +++ b/alacritty_terminal/src/grid/tests.rs @@ -638,6 +638,106 @@ fn visible_lines_unchanged_after_compress_thaw() { assert_eq!(visible_before, visible_after); } +#[test] +fn compact_scrollback_if_needed_noop_is_cheap() { + let screen = 24; + let columns = 160; + // History is below threshold (2 × 24 = 48), so compact_scrollback_if_needed + // should be a no-op every time. + let history = 40; + + let mut grid = grid_with_scrollback(screen, columns, history); + assert!(grid.history_size() <= screen * 2); + + let iterations = 10_000; + let start = std::time::Instant::now(); + for _ in 0..iterations { + grid.compact_scrollback_if_needed(); + } + let elapsed = start.elapsed(); + + // 10,000 no-op calls should complete in under 5ms (just a comparison). + assert!( + elapsed.as_millis() < 5, + "No-op compact_scrollback_if_needed took {elapsed:?} for {iterations} calls" + ); +} + +#[test] +fn compression_throughput() { + let columns = 160; + let rows_to_compress = 1000; + + // Build diverse rows for compression. + let mut rows = Vec::with_capacity(rows_to_compress); + let colours = [ + Color::Named(NamedColor::Red), + Color::Named(NamedColor::Green), + Color::Named(NamedColor::Blue), + ]; + let default_bg = Color::Named(NamedColor::Background); + + for n in 0..rows_to_compress { + let mut row = super::row::Row::::new(columns); + let text_len = match n % 5 { + 0 => 0, + 1 => 20 + (n % 40), + 2 => 60 + (n % 40), + _ => columns, + }; + let fg = colours[n % colours.len()]; + for col in 0..text_len { + row[Column(col)] = colored_cell( + (b'!' + ((n + col) % 94) as u8) as char, + fg, + default_bg, + ); + } + if n % 3 == 0 && text_len == columns { + row[Column(columns - 1)].flags.insert(Flags::WRAPLINE); + } + rows.push(row); + } + + // Measure compression. + let start = std::time::Instant::now(); + let compressed: Vec<_> = rows.iter().map(super::compact::CompactRow::compress).collect(); + let compress_elapsed = start.elapsed(); + + // Measure decompression. + let start = std::time::Instant::now(); + let decompressed: Vec<_> = compressed.iter().map(super::compact::CompactRow::decompress).collect(); + let decompress_elapsed = start.elapsed(); + + let compress_us_per_row = compress_elapsed.as_micros() as f64 / rows_to_compress as f64; + let decompress_us_per_row = decompress_elapsed.as_micros() as f64 / rows_to_compress as f64; + + eprintln!( + "Compression: {rows_to_compress} rows in {compress_elapsed:?} ({compress_us_per_row:.1} µs/row)" + ); + eprintln!( + "Decompression: {rows_to_compress} rows in {decompress_elapsed:?} ({decompress_us_per_row:.1} µs/row)" + ); + + // Verify round-trip. + for (orig, restored) in rows.iter().zip(decompressed.iter()) { + for col in 0..columns { + assert_eq!(orig[Column(col)], restored[Column(col)]); + } + } + + // Decompression must be fast enough for scrolling: a screenful (24 rows) + // should decompress in under 1ms. Only assert in release builds — debug + // builds are ~10× slower and would false-positive. + let screenful_us = decompress_us_per_row * 24.0; + #[cfg(not(debug_assertions))] + assert!( + screenful_us < 1000.0, + "Decompressing 24 rows would take {screenful_us:.0} µs, want < 1000 µs" + ); + let _ = screenful_us; +} + #[test] fn compact_scrollback_if_needed_compresses_when_over_threshold() { let screen = 10; From 0b7533a13e3818ebd6e35771ce39a4e9166a7fd8 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Sat, 4 Apr 2026 11:40:45 +1100 Subject: [PATCH 5/6] Keep scrolled-up viewport hot during compaction compact_scrollback_if_needed was unconditionally compressing all hot history beyond 2x screen lines, ignoring display_offset. The subsequent clamp in compress_old_scrollback would yank the viewport back, making it impossible to scroll more than ~2 screens into compressed history. Account for display_offset when choosing how many rows to keep hot. --- alacritty_terminal/src/grid/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/alacritty_terminal/src/grid/mod.rs b/alacritty_terminal/src/grid/mod.rs index 44508923ffc..61bfb19b897 100644 --- a/alacritty_terminal/src/grid/mod.rs +++ b/alacritty_terminal/src/grid/mod.rs @@ -562,8 +562,9 @@ impl Grid { /// for fast scrolling, older history gets compressed. pub fn compact_scrollback_if_needed(&mut self) { let threshold = self.lines * 2; - if self.history_size() > threshold { - self.compress_old_scrollback(threshold); + let keep_hot = max(threshold, self.display_offset + self.lines); + if self.history_size() > keep_hot { + self.compress_old_scrollback(keep_hot); } } From 9022d2707eee04abb53197e2ee6aa17312a18d15 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Sat, 4 Apr 2026 17:35:24 +1100 Subject: [PATCH 6/6] Thaw compressed scrollback during search Search methods now take &mut self so regex_search_internal can thaw compressed history before iterating. This makes search find content that has been compressed into scrollback. Also adds Grid::total_topmost_line() for callers that want the search range to include compressed rows. --- alacritty_terminal/src/grid/mod.rs | 6 + alacritty_terminal/src/term/search.rs | 224 +++++++++++++++++++++----- 2 files changed, 192 insertions(+), 38 deletions(-) diff --git a/alacritty_terminal/src/grid/mod.rs b/alacritty_terminal/src/grid/mod.rs index 61bfb19b897..5d7fdc3f564 100644 --- a/alacritty_terminal/src/grid/mod.rs +++ b/alacritty_terminal/src/grid/mod.rs @@ -608,6 +608,12 @@ impl Grid { .map(|r| r.heap_bytes() + std::mem::size_of::()) .sum() } + + /// Line farthest up in the grid including compressed history. + /// Use this instead of `topmost_line()` when the caller can thaw on demand. + pub fn total_topmost_line(&self) -> Line { + Line(-(self.total_history_size() as i32)) + } } pub trait Dimensions { diff --git a/alacritty_terminal/src/term/search.rs b/alacritty_terminal/src/term/search.rs index 1bdc659c851..8911b90539d 100644 --- a/alacritty_terminal/src/term/search.rs +++ b/alacritty_terminal/src/term/search.rs @@ -119,7 +119,7 @@ impl LazyDfa { impl Term { /// Get next search match in the specified direction. pub fn search_next( - &self, + &mut self, regex: &mut RegexSearch, mut origin: Point, direction: Direction, @@ -138,7 +138,7 @@ impl Term { /// Find the next match to the right of the origin. fn next_match_right( - &self, + &mut self, regex: &mut RegexSearch, origin: Point, side: Side, @@ -177,7 +177,7 @@ impl Term { /// Find the next match to the left of the origin. fn next_match_left( - &self, + &mut self, regex: &mut RegexSearch, origin: Point, side: Side, @@ -226,7 +226,7 @@ impl Term { /// /// The origin is always included in the regex. pub fn regex_search_left( - &self, + &mut self, regex: &mut RegexSearch, start: Point, end: Point, @@ -242,7 +242,7 @@ impl Term { /// /// The origin is always included in the regex. pub fn regex_search_right( - &self, + &mut self, regex: &mut RegexSearch, start: Point, end: Point, @@ -257,7 +257,7 @@ impl Term { /// Find the next regex match. /// /// This will always return the side of the first match which is farthest from the start point. - fn regex_search(&self, start: Point, end: Point, regex: &mut LazyDfa) -> Option { + fn regex_search(&mut self, start: Point, end: Point, regex: &mut LazyDfa) -> Option { match self.regex_search_internal(start, end, regex) { Ok(regex_match) => regex_match, Err(err) => { @@ -272,11 +272,19 @@ impl Term { /// /// To automatically log regex complexity errors, use [`Self::regex_search`] instead. fn regex_search_internal( - &self, + &mut self, start: Point, end: Point, regex: &mut LazyDfa, ) -> Result, Box> { + // Thaw all compressed scrollback so the search can see the full history. + // This happens lazily at search time — rows stay compressed until a + // search actually executes. + let compressed = self.grid().compressed_history_len(); + if compressed > 0 { + self.grid_mut().thaw_compressed_history(compressed); + } + let topmost_line = self.topmost_line(); let screen_lines = self.screen_lines() as i32; let last_column = self.last_column(); @@ -622,7 +630,7 @@ pub struct RegexIter<'a, T> { end: Point, direction: Direction, regex: &'a mut RegexSearch, - term: &'a Term, + term: &'a mut Term, done: bool, } @@ -631,7 +639,7 @@ impl<'a, T> RegexIter<'a, T> { start: Point, end: Point, direction: Direction, - term: &'a Term, + term: &'a mut Term, regex: &'a mut RegexSearch, ) -> Self { Self { point: start, done: false, end, direction, term, regex } @@ -695,7 +703,7 @@ mod tests { #[test] fn regex_right() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ testing66\r\n\ Alacritty\n\ 123\r\n\ @@ -715,7 +723,7 @@ mod tests { #[test] fn regex_left() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ testing66\r\n\ Alacritty\n\ 123\r\n\ @@ -735,7 +743,7 @@ mod tests { #[test] fn nested_regex() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ Ala -> Alacritty -> critty\r\n\ critty\ "); @@ -756,7 +764,7 @@ mod tests { #[test] fn no_match_right() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ first line\n\ broken second\r\n\ third\ @@ -771,7 +779,7 @@ mod tests { #[test] fn no_match_left() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ first line\n\ broken second\r\n\ third\ @@ -786,7 +794,7 @@ mod tests { #[test] fn include_linebreak_left() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ testing123\r\n\ xxx\ "); @@ -803,7 +811,7 @@ mod tests { #[test] fn include_linebreak_right() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ xxx\r\n\ testing123\ "); @@ -818,7 +826,7 @@ mod tests { #[test] fn skip_dead_cell() { - let term = mock_term("alacritty"); + let mut term = mock_term("alacritty"); // Make sure dead state cell is skipped when reversing. let mut regex = RegexSearch::new("alacrit").unwrap(); @@ -829,7 +837,7 @@ mod tests { #[test] fn reverse_search_dead_recovery() { - let term = mock_term("zooo lense"); + let mut term = mock_term("zooo lense"); // Make sure the reverse DFA operates the same as a forward DFA. let mut regex = RegexSearch::new("zoo").unwrap(); @@ -842,7 +850,7 @@ mod tests { #[test] fn multibyte_unicode() { - let term = mock_term("testвосибing"); + let mut term = mock_term("testвосибing"); let mut regex = RegexSearch::new("te.*ing").unwrap(); let start = Point::new(Line(0), Column(0)); @@ -857,7 +865,7 @@ mod tests { #[test] fn end_on_multibyte_unicode() { - let term = mock_term("testвосиб"); + let mut term = mock_term("testвосиб"); let mut regex = RegexSearch::new("te.*и").unwrap(); let start = Point::new(Line(0), Column(0)); @@ -868,7 +876,7 @@ mod tests { #[test] fn fullwidth() { - let term = mock_term("a🦇x🦇"); + let mut term = mock_term("a🦇x🦇"); let mut regex = RegexSearch::new("[^ ]*").unwrap(); let start = Point::new(Line(0), Column(0)); @@ -883,7 +891,7 @@ mod tests { #[test] fn singlecell_fullwidth() { - let term = mock_term("🦇"); + let mut term = mock_term("🦇"); let mut regex = RegexSearch::new("🦇").unwrap(); let start = Point::new(Line(0), Column(0)); @@ -898,7 +906,7 @@ mod tests { #[test] fn end_on_fullwidth() { - let term = mock_term("jarr🦇"); + let mut term = mock_term("jarr🦇"); let start = Point::new(Line(0), Column(0)); let end = Point::new(Line(0), Column(4)); @@ -919,7 +927,7 @@ mod tests { #[test] fn wrapping() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ xxx\r\n\ xxx\ "); @@ -940,7 +948,7 @@ mod tests { #[test] fn wrapping_into_fullwidth() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ 🦇xx\r\n\ xx🦇\ "); @@ -963,7 +971,7 @@ mod tests { #[test] fn multiline() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ test \r\n\ test\ "); @@ -986,7 +994,7 @@ mod tests { #[test] fn empty_match() { #[rustfmt::skip] - let term = mock_term(" abc "); + let mut term = mock_term(" abc "); const PATTERN: &str = "[a-z]*"; let mut regex = RegexSearch::new(PATTERN).unwrap(); @@ -1000,7 +1008,7 @@ mod tests { #[test] fn empty_match_multibyte() { #[rustfmt::skip] - let term = mock_term(" ↑"); + let mut term = mock_term(" ↑"); const PATTERN: &str = "[a-z]*"; let mut regex = RegexSearch::new(PATTERN).unwrap(); @@ -1012,7 +1020,7 @@ mod tests { #[test] fn empty_match_multiline() { #[rustfmt::skip] - let term = mock_term("abc \nxxx"); + let mut term = mock_term("abc \nxxx"); const PATTERN: &str = "[a-z]*"; let mut regex = RegexSearch::new(PATTERN).unwrap(); @@ -1074,14 +1082,14 @@ mod tests { let start = Point::new(Line(0), Column(0)); let end = Point::new(Line(0), Column(1)); - let mut iter = RegexIter::new(start, end, Direction::Right, &term, &mut regex); + let mut iter = RegexIter::new(start, end, Direction::Right, &mut term, &mut regex); assert_eq!(iter.next(), None); } #[test] fn wrap_around_to_another_end() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ abc\r\n\ def\ "); @@ -1110,7 +1118,7 @@ mod tests { #[test] fn runtime_cache_error() { - let term = mock_term(&str::repeat("i", 9999)); + let mut term = mock_term(&str::repeat("i", 9999)); let mut regex = RegexSearch::new("[0-9A-Za-z]{9999}").unwrap(); let start = Point::new(Line(0), Column(0)); @@ -1121,7 +1129,7 @@ mod tests { #[test] fn greed_is_good() { #[rustfmt::skip] - let term = mock_term("https://github.com"); + let mut term = mock_term("https://github.com"); // Bottom to top. let mut regex = RegexSearch::new("/github.com|https://github.com").unwrap(); @@ -1133,7 +1141,7 @@ mod tests { #[test] fn anchored_empty() { #[rustfmt::skip] - let term = mock_term("rust"); + let mut term = mock_term("rust"); // Bottom to top. let mut regex = RegexSearch::new(";*|rust").unwrap(); @@ -1145,7 +1153,7 @@ mod tests { #[test] fn newline_breaking_semantic() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ test abc\r\n\ def test\ "); @@ -1166,7 +1174,7 @@ mod tests { #[test] fn inline_word_search() { #[rustfmt::skip] - let term = mock_term("\ + let mut term = mock_term("\ word word word word w\n\ ord word word word\ "); @@ -1193,7 +1201,7 @@ mod tests { #[test] fn fullwidth_across_lines() { - let term = mock_term("a🦇\n🦇b"); + let mut term = mock_term("a🦇\n🦇b"); let mut regex = RegexSearch::new("🦇🦇").unwrap(); let start = Point::new(Line(0), Column(0)); @@ -1212,7 +1220,7 @@ mod tests { #[test] fn fullwidth_into_halfwidth_across_lines() { - let term = mock_term("a🦇\nxab"); + let mut term = mock_term("a🦇\nxab"); let mut regex = RegexSearch::new("🦇x").unwrap(); let start = Point::new(Line(0), Column(0)); @@ -1249,3 +1257,143 @@ mod tests { assert_eq!(term.regex_search_left(&mut regex, start, end), Some(match_end..=match_start)); } } + + + +#[cfg(test)] +mod compressed_search_tests { + use crate::event::VoidListener; + use crate::grid::Dimensions; + use crate::index::{Column, Point}; + use crate::term::Config; + use crate::term::search::RegexSearch; + use crate::term::test::TermSize; + use crate::vte::ansi::Handler; + + /// Build a term with scrollback history that contains a unique marker + /// string buried deep in the history. + fn term_with_marker_in_history() -> crate::term::Term { + let columns = 40; + let screen_lines = 10; + let history_limit = 200; + + let mut config = Config::default(); + config.scrolling_history = history_limit; + let size = TermSize::new(columns, screen_lines); + let mut term = crate::term::Term::new(config, &size, VoidListener); + + // Write 100 lines of filler, pushing them into scrollback. + for i in 0..100 { + let text = format!("filler-line-{:04}", i); + for c in text.chars() { + term.input(c); + } + term.carriage_return(); + term.linefeed(); + } + + // Write the unique marker. + let marker = "UNIQUE_SEARCH_MARKER_XYZ"; + for c in marker.chars() { + term.input(c); + } + term.carriage_return(); + term.linefeed(); + + // Write more filler to push the marker deeper into history. + for i in 0..50 { + let text = format!("after-marker-{:04}", i); + for c in text.chars() { + term.input(c); + } + term.carriage_return(); + term.linefeed(); + } + + term + } + + #[test] + fn search_finds_marker_in_uncompressed_history() { + let mut term = term_with_marker_in_history(); + + // Sanity check: the marker should be findable in uncompressed history. + let mut regex = RegexSearch::new("UNIQUE_SEARCH_MARKER_XYZ").unwrap(); + let start = Point::new(term.topmost_line(), Column(0)); + let end = Point::new(term.bottommost_line(), term.last_column()); + let result = term.regex_search_right(&mut regex, start, end); + assert!(result.is_some(), "Marker should be found in uncompressed scrollback"); + } + + #[test] + fn search_misses_marker_in_compressed_history() { + let mut term = term_with_marker_in_history(); + + // Compress most of the history, keeping only a small hot buffer. + // The marker is ~50 lines from the bottom of history, so keeping + // only 10 hot lines should compress it away. + term.grid_mut().compress_old_scrollback(10); + assert!( + term.grid().compressed_history_len() > 0, + "Should have compressed some history" + ); + + // Search using the current (hot-only) topmost_line -- this should + // NOT find the marker because it's in compressed territory. + let mut regex = RegexSearch::new("UNIQUE_SEARCH_MARKER_XYZ").unwrap(); + let start = Point::new(term.topmost_line(), Column(0)); + let end = Point::new(term.bottommost_line(), term.last_column()); + let result = term.regex_search_right(&mut regex, start, end); + assert!( + result.is_none(), + "Marker should NOT be found when compressed (this demonstrates the bug)" + ); + } + + #[test] + fn search_finds_marker_after_thaw() { + let mut term = term_with_marker_in_history(); + + term.grid_mut().compress_old_scrollback(10); + let compressed = term.grid().compressed_history_len(); + assert!(compressed > 0); + + // Thaw everything, then search -- marker should be found. + term.grid_mut().thaw_compressed_history(compressed); + assert_eq!(term.grid().compressed_history_len(), 0); + + let mut regex = RegexSearch::new("UNIQUE_SEARCH_MARKER_XYZ").unwrap(); + let start = Point::new(term.topmost_line(), Column(0)); + let end = Point::new(term.bottommost_line(), term.last_column()); + let result = term.regex_search_right(&mut regex, start, end); + assert!(result.is_some(), "Marker should be found after thawing compressed history"); + } + + #[test] + fn search_finds_marker_through_compressed_history_automatically() { + let mut term = term_with_marker_in_history(); + + // Compress most of the history. + term.grid_mut().compress_old_scrollback(10); + assert!(term.grid().compressed_history_len() > 0); + + // Search using total_topmost_line -- the search should thaw + // compressed rows automatically and find the marker. + let mut regex = RegexSearch::new("UNIQUE_SEARCH_MARKER_XYZ").unwrap(); + let start = Point::new(term.grid().total_topmost_line(), Column(0)); + let end = Point::new(term.bottommost_line(), term.last_column()); + let result = term.regex_search_right(&mut regex, start, end); + assert!( + result.is_some(), + "Search should find marker in compressed history via automatic thaw" + ); + + // After the search, compressed history should have been thawed. + assert_eq!( + term.grid().compressed_history_len(), + 0, + "All compressed rows should have been thawed during search" + ); + } + +} \ No newline at end of file