From f8adba5f5091ec5adcd00eb877d87932e6637adc Mon Sep 17 00:00:00 2001 From: pcarter Date: Tue, 7 Apr 2026 16:58:07 -0700 Subject: [PATCH] support ligatures --- Cargo.lock | 47 ++++ Cargo.toml | 1 + src/bin/tdiff.rs | 105 ++++++++ src/bin/tjson.rs | 76 ++++++ src/renderer.rs | 610 +++++++++++++++++++++++++++++++++++++++++++++-- src/terminal.rs | 335 +++++++++++++++++++++++++- 6 files changed, 1148 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c41928..816b5a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1586,6 +1586,22 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1857,6 +1873,7 @@ dependencies = [ "objc2", "pollster", "portable-pty", + "rustybuzz", "serde_json", "syntect", "vte", @@ -1999,18 +2016,48 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.12.0" diff --git a/Cargo.toml b/Cargo.toml index bd8295d..8bcb207 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ objc2 = "0.5" vte = "0.13" portable-pty = "0.8" fontdue = "0.8" +rustybuzz = "0.14" syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "parsing", "regex-fancy"] } serde_json = "1" diff --git a/src/bin/tdiff.rs b/src/bin/tdiff.rs index 55a5bcc..499bfcc 100644 --- a/src/bin/tdiff.rs +++ b/src/bin/tdiff.rs @@ -398,4 +398,109 @@ mod tests { fn test_parse_file_path_whitespace_trimmed() { assert_eq!(parse_file_path(" a/foo.rs "), "foo.rs"); } + + // ── syntax_hint ─────────────────────────────────────────────────────────── + + #[test] + fn test_syntax_hint_rust_extension() { + assert_eq!(syntax_hint("src/main.rs"), "rs"); + } + + #[test] + fn test_syntax_hint_python_extension() { + assert_eq!(syntax_hint("script.py"), "py"); + } + + #[test] + fn test_syntax_hint_no_extension_returns_empty() { + assert_eq!(syntax_hint("Makefile"), ""); + } + + #[test] + fn test_syntax_hint_dotfile_no_ext_returns_empty() { + // ".gitignore" has no extension (the whole name is the stem). + assert_eq!(syntax_hint(".gitignore"), ""); + } + + #[test] + fn test_syntax_hint_empty_path_returns_empty() { + assert_eq!(syntax_hint(""), ""); + } + + #[test] + fn test_syntax_hint_uses_last_component() { + assert_eq!(syntax_hint("a/b/c/foo.ts"), "ts"); + } + + // ── write_code_line ─────────────────────────────────────────────────────── + + #[test] + fn test_write_code_line_contains_prefix_char() { + // write_code_line must emit the prefix sigil somewhere in its output. + let mut out = Vec::::new(); + let ps = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + let spans = hl_line("let x = 1;", &mut make_highlighter("rs", &ps, &ts), &ps); + write_code_line(&mut out, "+", (0, 255, 0), &spans, (30, 40, 30)).unwrap(); + let s = String::from_utf8_lossy(&out); + assert!(s.contains('+'), "output must contain the prefix sigil '+'"); + } + + #[test] + fn test_write_code_line_ends_with_newline() { + let mut out = Vec::::new(); + let ps = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + let spans = hl_line("x", &mut make_highlighter("rs", &ps, &ts), &ps); + write_code_line(&mut out, "-", (255, 0, 0), &spans, (40, 20, 20)).unwrap(); + assert!( + out.ends_with(b"\n"), + "write_code_line must end with newline" + ); + } + + #[test] + fn test_write_code_line_empty_spans_still_writes_prefix_and_newline() { + let mut out = Vec::::new(); + write_code_line(&mut out, " ", (200, 200, 200), &[], (0, 0, 0)).unwrap(); + let s = String::from_utf8_lossy(&out); + // Even with no spans, prefix and newline must be present. + assert!(out.ends_with(b"\n")); + // Must contain some ANSI sequences (bg, bold, reset). + assert!(s.contains('\x1b'), "must emit at least one ANSI escape"); + } + + // ── strip_ansi edge cases ───────────────────────────────────────────────── + + #[test] + fn test_strip_ansi_lone_esc_not_followed_by_bracket_consumed() { + // A bare ESC that is NOT followed by '[' or ']' — the current + // implementation falls through to the CSI branch which consumes until + // the next alphabetic byte. Either way the result must not contain + // the ESC byte. + let result = strip_ansi("\x1b7hello"); + assert!( + !result.contains('\x1b'), + "lone ESC must not appear in output; got: {result:?}" + ); + } + + #[test] + fn test_strip_ansi_multiple_osc_sequences_all_stripped() { + let result = strip_ansi("\x1b]0;first\x07\x1b]2;second\x07done"); + assert_eq!(result, "done"); + } + + #[test] + fn test_strip_ansi_adjacent_csi_sequences() { + // Two back-to-back CSI sequences — both must be stripped. + let result = strip_ansi("\x1b[1m\x1b[0mtext"); + assert_eq!(result, "text"); + } + + #[test] + fn test_strip_ansi_preserves_unicode_text() { + let result = strip_ansi("héllo\x1b[31m wörld\x1b[0m"); + assert_eq!(result, "héllo wörld"); + } } diff --git a/src/bin/tjson.rs b/src/bin/tjson.rs index e37b762..3e78bf8 100644 --- a/src/bin/tjson.rs +++ b/src/bin/tjson.rs @@ -372,4 +372,80 @@ mod tests { // The raw leading spaces must NOT appear in prettified output. assert!(!out.starts_with(" {"), "leading spaces must be stripped from prettified output"); } + + // ── Additional process_line / process_output tests ──────────────────────── + + #[test] + fn test_empty_line_passes_through() { + let out = process_output(""); + // An empty line is not JSON — it must be echoed (as a bare newline). + assert_eq!(out, "\n", "empty line must be passed through as a newline"); + } + + #[test] + fn test_whitespace_only_line_passes_through() { + let out = process_output(" "); + // Whitespace-only lines cannot start with '{' or '[' after trimming. + assert!(out.contains(" "), "whitespace-only line must pass through unchanged"); + } + + #[test] + fn test_unicode_json_object_prettified() { + let out = process_output(r#"{"emoji":"🦀","name":"裁"}"#); + assert!(out.contains("\x1b["), "unicode JSON must be prettified"); + assert!(out.contains("emoji"), "key must appear in output"); + } + + #[test] + fn test_deeply_nested_json_prettified() { + let out = process_output(r#"{"a":{"b":{"c":{"d":42}}}}"#); + assert!(out.contains("\x1b["), "deeply nested JSON must be prettified"); + assert!(out.contains("42"), "leaf value must appear in output"); + } + + #[test] + fn test_json_array_of_objects_prettified() { + let out = process_output(r#"[{"id":1},{"id":2}]"#); + assert!(out.contains("\x1b["), "array of objects must be prettified"); + // syntect may split tokens at quote boundaries; check the key text only. + assert!(out.contains("id"), "key name must appear in output"); + } + + #[test] + fn test_line_starting_with_brace_but_invalid_json_passes_through() { + let line = "{not valid json at all"; + let out = process_output(line); + assert!(out.contains(line), "invalid JSON starting with '{{' must pass through"); + } + + #[test] + fn test_line_starting_with_bracket_but_invalid_json_passes_through() { + let line = "[1, 2, broken"; + let out = process_output(line); + assert!(out.contains(line), "invalid JSON starting with '[' must pass through"); + } + + #[test] + fn test_json_with_null_value_prettified() { + let out = process_output(r#"{"key":null}"#); + assert!(out.contains("null"), "null value must appear in prettified output"); + } + + #[test] + fn test_json_with_boolean_values_prettified() { + let out = process_output(r#"{"ok":true,"fail":false}"#); + assert!(out.contains("true")); + assert!(out.contains("false")); + } + + #[test] + fn test_multiple_keys_each_on_own_line_in_output() { + let out = process_output(r#"{"a":1,"b":2,"c":3}"#); + // serde_json::to_string_pretty puts each key on its own line. + let newline_count = out.chars().filter(|&c| c == '\n').count(); + assert!( + newline_count >= 3, + "prettified 3-key object must have ≥3 newlines, got {newline_count}" + ); + } } diff --git a/src/renderer.rs b/src/renderer.rs index 1d39a6c..a601a61 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -4,6 +4,8 @@ use bytemuck::{Pod, Zeroable}; use std::collections::HashMap; use std::sync::Arc; +static FONT_BYTES: &[u8] = include_bytes!("../assets/JetBrainsMono-Regular.ttf"); + // ── tcat gutter detection ───────────────────────────────────────────────────── /// Find the first content column of a tcat gutter line, or 0 if this row is @@ -185,6 +187,29 @@ struct AtlasEntry { h: u32, } +// ── Ligature shaping ────────────────────────────────────────────────────────── + +/// A single glyph produced by text shaping, mapped back to the input cells. +struct ShapedGlyph { + /// OpenType glyph index (u16 fits all fonts; 0 means .notdef/missing). + glyph_id: u16, + /// Index into the input `chars` slice where this glyph starts. + /// For a ligature, the cells between this and the next glyph's `char_idx` + /// are covered — they receive no `GlyphOp::Glyph` and stay `Skip`. + char_idx: usize, +} + +/// Per-cell render decision computed before the draw loop. +#[derive(Copy, Clone)] +enum GlyphOp { + /// Nothing to draw (space, null, or a cell consumed by a multi-cell ligature). + Skip, + /// Block or Braille character — rendered as fill rects, not from the atlas. + Block, + /// Draw glyph `id` from the atlas at this cell's pixel origin. + Glyph(u16), +} + // ── Color helpers ───────────────────────────────────────────────────────────── fn c2f(c: Color) -> [f32; 4] { @@ -207,6 +232,113 @@ pub fn push_rect(v: &mut Vec, x: f32, y: f32, w: f32, h: f32, color: [ } } +// ── Shaping helpers (free functions so tests can call them without a GPU) ───── + +fn shape_run_impl(face: &rustybuzz::Face<'_>, chars: &[char]) -> Vec { + if chars.is_empty() { + return Vec::new(); + } + + let text: String = chars.iter().collect(); + + // Build a byte-offset → char-index mapping for the UTF-8 string. + let mut byte_to_char = vec![0usize; text.len() + 1]; + for (char_idx, (byte_off, c)) in text.char_indices().enumerate() { + for b in byte_off..byte_off + c.len_utf8() { + byte_to_char[b] = char_idx; + } + } + byte_to_char[text.len()] = chars.len(); + + let mut buf = rustybuzz::UnicodeBuffer::new(); + buf.push_str(&text); + let output = rustybuzz::shape(face, &[], buf); + let infos = output.glyph_infos(); + + let mut result = Vec::with_capacity(infos.len()); + for i in 0..infos.len() { + let cluster_byte = infos[i].cluster as usize; + let char_idx = byte_to_char[cluster_byte.min(text.len())]; + result.push(ShapedGlyph { + glyph_id: infos[i].glyph_id as u16, + char_idx, + }); + } + result +} + +fn compute_row_glyph_ops_impl( + face: &rustybuzz::Face<'_>, + state: &TerminalState, + row: usize, + vis_cols: usize, +) -> Vec { + let mut ops = vec![GlyphOp::Skip; vis_cols]; + + let mut col = 0; + while col < vis_cols { + let cell = state.visual_cell(row, col); + let c = cell.c; + + if c == ' ' || c == '\0' { + col += 1; + continue; + } + + if is_block_char(c) { + ops[col] = GlyphOp::Block; + col += 1; + continue; + } + + // Effective fg (after inverse) — run boundary changes on fg change. + let fg_color = if cell.attrs.inverse { cell.attrs.bg } else { cell.attrs.fg }; + + // Extend the run as far as chars share the same fg and are printable. + let run_start = col; + let mut run_end = col + 1; + while run_end < vis_cols { + let nc = state.visual_cell(row, run_end); + let nc_c = nc.c; + if nc_c == ' ' || nc_c == '\0' || is_block_char(nc_c) { + break; + } + let nc_fg = if nc.attrs.inverse { nc.attrs.bg } else { nc.attrs.fg }; + if nc_fg != fg_color { + break; + } + run_end += 1; + } + + // Collect chars and shape. + let run_chars: Vec = (run_start..run_end) + .map(|c| state.visual_cell(row, c).c) + .collect(); + let shaped = shape_run_impl(face, &run_chars); + + // Map shaped glyphs back to column positions. + // Cells covered by a ligature (not the first) stay as GlyphOp::Skip. + for sg in &shaped { + let abs_col = run_start + sg.char_idx; + if abs_col < vis_cols { + ops[abs_col] = if sg.glyph_id == 0 { + GlyphOp::Skip + } else { + GlyphOp::Glyph(sg.glyph_id) + }; + } + } + + col = run_end; + } + + ops +} + +fn is_block_char(c: char) -> bool { + matches!(c, '\u{2580}'..='\u{259F}' | '\u{2800}'..='\u{28FF}') +} + // ── PaneView ───────────────────────────────────────────────────────────────── /// A single terminal pane to be drawn within the surface. @@ -239,7 +371,9 @@ pub struct Renderer { _atlas_sampler: wgpu::Sampler, atlas_bg: wgpu::BindGroup, _atlas_bgl: wgpu::BindGroupLayout, - atlas_cache: HashMap>, + /// Atlas cache keyed by OpenType glyph ID (not char) so shaped ligature + /// glyphs share entries with their constituent characters when applicable. + atlas_cache: HashMap>, atlas_x: u32, atlas_y: u32, atlas_row_h: u32, @@ -251,6 +385,8 @@ pub struct Renderer { glyph_buf_cap: usize, font: fontdue::Font, + /// HarfBuzz-compatible shaper for OpenType ligature / calt substitution. + rb_face: rustybuzz::Face<'static>, pub cell_width: usize, pub cell_height: usize, pub baseline: i32, @@ -267,9 +403,8 @@ impl Renderer { ) -> Self { // ── Font ────────────────────────────────────────────────────────────── let font_size = (FONT_SIZE_PT * scale_factor as f32).round(); - let font_data = include_bytes!("../assets/JetBrainsMono-Regular.ttf").to_vec(); let font = fontdue::Font::from_bytes( - font_data.as_slice(), + FONT_BYTES, fontdue::FontSettings { scale: font_size, collection_index: 0, @@ -277,6 +412,8 @@ impl Renderer { }, ) .expect("font load"); + let rb_face = rustybuzz::Face::from_slice(FONT_BYTES, 0) + .expect("rustybuzz face load"); let (m, _) = font.rasterize('M', font_size); let cell_width = m.advance_width.ceil() as usize; @@ -430,6 +567,7 @@ impl Renderer { glyph_buf, glyph_buf_cap: init_glyph, font, + rb_face, cell_width, cell_height, baseline: ascent, @@ -529,20 +667,18 @@ impl Renderer { // ── Atlas management ────────────────────────────────────────────────────── - /// Ensure glyph `c` is in the atlas. Returns a clone of the entry, or None - /// if the character has no visible glyph (space, control, .notdef). - fn ensure_glyph(&mut self, c: char) -> Option { - if let Some(entry) = self.atlas_cache.get(&c) { - return entry.clone(); - } - // Skip chars not in font - if self.font.lookup_glyph_index(c) == 0 { - self.atlas_cache.insert(c, None); + /// Ensure OpenType glyph `id` is in the atlas. Returns the entry, or `None` + /// if the glyph is missing or invisible (id 0, zero-size bitmap). + fn ensure_glyph_id(&mut self, id: u16) -> Option { + if id == 0 { return None; } - let (m, bitmap) = self.font.rasterize(c, self.font_size); + if let Some(entry) = self.atlas_cache.get(&id) { + return entry.clone(); + } + let (m, bitmap) = self.font.rasterize_indexed(id, self.font_size); if m.width == 0 || m.height == 0 { - self.atlas_cache.insert(c, None); + self.atlas_cache.insert(id, None); return None; } let gw = m.width as u32; @@ -600,21 +736,21 @@ impl Renderer { w: gw, h: gh, }; - self.atlas_cache.insert(c, Some(entry.clone())); + self.atlas_cache.insert(id, Some(entry.clone())); Some(entry) } - // ── Glyph emit helper ───────────────────────────────────────────────────── + // ── Glyph emit helpers ──────────────────────────────────────────────────── - fn emit_char( + fn emit_glyph_id( &mut self, glyphs: &mut Vec, - c: char, + id: u16, px: f32, py: f32, fg: [f32; 4], ) { - if let Some(e) = self.ensure_glyph(c) { + if let Some(e) = self.ensure_glyph_id(id) { glyphs.push(GlyphInst { pos: [px + e.glyph_x as f32, py + e.glyph_y as f32], sz: [e.w as f32, e.h as f32], @@ -625,6 +761,35 @@ impl Renderer { } } + fn emit_char( + &mut self, + glyphs: &mut Vec, + c: char, + px: f32, + py: f32, + fg: [f32; 4], + ) { + let id = self.font.lookup_glyph_index(c); + self.emit_glyph_id(glyphs, id, px, py, fg); + } + + // ── Ligature shaping ────────────────────────────────────────────────────── + + /// Pre-compute per-column glyph operations for a single terminal row. + /// + /// Cells are grouped into same-fg-color runs. Each run is shaped through + /// the OpenType engine; ligature glyphs that span multiple cells produce + /// `GlyphOp::Glyph` at the first cell and `GlyphOp::Skip` at subsequent + /// covered cells. + fn compute_row_glyph_ops( + &self, + state: &TerminalState, + row: usize, + vis_cols: usize, + ) -> Vec { + compute_row_glyph_ops_impl(&self.rb_face, state, row, vis_cols) + } + // ── Block character rects ───────────────────────────────────────────────── /// Decompose a Unicode block/Braille char into colored fill rects. @@ -800,6 +965,11 @@ impl Renderer { 0 }; + // Pre-compute ligature-aware glyph ops for the row. + // This is a pure shaping step (no atlas mutation) so it can + // borrow &self while the draw loop later uses &mut self. + let glyph_ops = self.compute_row_glyph_ops(pane.state, row, vis_cols); + for col in 0..vis_cols { let cell = pane.state.visual_cell(row, col); let mut fg_color = cell.attrs.fg; @@ -829,13 +999,23 @@ impl Renderer { }; push_rect(&mut bg_rects, px, py, cw, ch, bg); + let fg = c2f(fg_color); let c = cell.c; - if c != ' ' && c != '\0' { - let fg = c2f(fg_color); - if !Self::push_block_char(&mut block_rects, px, py, cw, ch, c, fg) { - self.emit_char(&mut glyphs, c, px, py, fg); + match glyph_ops[col] { + GlyphOp::Skip => {} + GlyphOp::Block => { + // Block chars can't participate in ligatures; skip + // the GlyphOp::Skip guard above — we always render. + if c != ' ' && c != '\0' { + Self::push_block_char(&mut block_rects, px, py, cw, ch, c, fg); + } + } + GlyphOp::Glyph(glyph_id) => { + self.emit_glyph_id(&mut glyphs, glyph_id, px, py, fg); + // Combining chars overlay at the same cell origin. for &combining in cell.combining_chars() { - self.emit_char(&mut glyphs, combining, px, py, fg); + let comb_id = self.font.lookup_glyph_index(combining); + self.emit_glyph_id(&mut glyphs, comb_id, px, py, fg); } } } @@ -997,14 +1177,394 @@ impl Renderer { #[cfg(test)] mod tests { use super::*; + use crate::terminal::Terminal; + + fn test_face() -> rustybuzz::Face<'static> { + rustybuzz::Face::from_slice(FONT_BYTES, 0) + .expect("bundled JetBrains Mono must parse as a valid font") + } + + fn make_term(cols: usize, rows: usize) -> Terminal { + Terminal::new(cols, rows) + } + + // ── Color helpers ───────────────────────────────────────────────────────── + + #[test] + fn c2f_black_is_zero_rgb_full_alpha() { + let r = c2f(Color::new(0, 0, 0)); + assert_eq!(r, [0., 0., 0., 1.]); + } + + #[test] + fn c2f_white_is_one_rgb_full_alpha() { + let r = c2f(Color::new(255, 255, 255)); + assert_eq!(r, [1., 1., 1., 1.]); + } + + #[test] + fn c2f_channel_conversion_is_divide_by_255() { + let r = c2f(Color::new(255, 0, 128)); + assert_eq!(r[0], 1.0); + assert_eq!(r[1], 0.0); + assert!((r[2] - 128. / 255.).abs() < 1e-6); + assert_eq!(r[3], 1.0); + } + + #[test] + fn c2fa_carries_alpha() { + let r = c2fa(Color::new(255, 0, 0), 0.5); + assert_eq!(r[0], 1.0); + assert_eq!(r[1], 0.0); + assert_eq!(r[2], 0.0); + assert_eq!(r[3], 0.5); + } + + #[test] + fn c2fa_zero_alpha() { + let r = c2fa(Color::new(255, 255, 255), 0.0); + assert_eq!(r[3], 0.0); + } + + #[test] + fn rgb_f_converts_components() { + let r = rgb_f(0, 128, 255); + assert_eq!(r[0], 0.0); + assert!((r[1] - 128. / 255.).abs() < 1e-6); + assert_eq!(r[2], 1.0); + assert_eq!(r[3], 1.0); + } + + #[test] + fn rgb_f_black() { + assert_eq!(rgb_f(0, 0, 0), [0., 0., 0., 1.]); + } + + // ── push_rect ───────────────────────────────────────────────────────────── #[test] fn divider_rect_is_pushed_into_fg_rects() { - // Smoke test: push_rect with a divider coordinate should not panic. let mut v: Vec = Vec::new(); push_rect(&mut v, 400., 0., 2., 600., rgb_f(0x3a, 0x3a, 0x3a)); assert_eq!(v.len(), 1); assert_eq!(v[0].pos, [400., 0.]); assert_eq!(v[0].sz, [2., 600.]); } + + #[test] + fn push_rect_zero_width_is_rejected() { + let mut v: Vec = Vec::new(); + push_rect(&mut v, 0., 0., 0., 100., rgb_f(255, 0, 0)); + assert!(v.is_empty(), "zero-width rect must not be pushed"); + } + + #[test] + fn push_rect_zero_height_is_rejected() { + let mut v: Vec = Vec::new(); + push_rect(&mut v, 0., 0., 100., 0., rgb_f(255, 0, 0)); + assert!(v.is_empty(), "zero-height rect must not be pushed"); + } + + #[test] + fn push_rect_negative_width_is_rejected() { + let mut v: Vec = Vec::new(); + push_rect(&mut v, 0., 0., -1., 100., rgb_f(255, 0, 0)); + assert!(v.is_empty(), "negative-width rect must not be pushed"); + } + + #[test] + fn push_rect_negative_height_is_rejected() { + let mut v: Vec = Vec::new(); + push_rect(&mut v, 0., 0., 100., -1., rgb_f(255, 0, 0)); + assert!(v.is_empty(), "negative-height rect must not be pushed"); + } + + #[test] + fn push_rect_stores_position_and_size() { + let mut v: Vec = Vec::new(); + push_rect(&mut v, 10., 20., 30., 40., [1., 0., 0., 1.]); + assert_eq!(v[0].pos, [10., 20.]); + assert_eq!(v[0].sz, [30., 40.]); + assert_eq!(v[0].color, [1., 0., 0., 1.]); + } + + // ── is_block_char ───────────────────────────────────────────────────────── + + #[test] + fn is_block_char_lower_block_boundary() { + assert!(is_block_char('\u{2580}'), "U+2580 UPPER HALF BLOCK must be a block char"); + } + + #[test] + fn is_block_char_upper_block_boundary() { + assert!(is_block_char('\u{259F}'), "U+259F QUADRANT LOWER-RIGHT must be a block char"); + } + + #[test] + fn is_block_char_just_before_block_range_is_false() { + assert!(!is_block_char('\u{257F}'), "U+257F must not be a block char"); + } + + #[test] + fn is_block_char_just_after_block_range_is_false() { + assert!(!is_block_char('\u{25A0}'), "U+25A0 BLACK SQUARE must not be a block char"); + } + + #[test] + fn is_block_char_braille_lower_boundary() { + assert!(is_block_char('\u{2800}'), "U+2800 BRAILLE BLANK must be a block char"); + } + + #[test] + fn is_block_char_braille_upper_boundary() { + assert!(is_block_char('\u{28FF}'), "U+28FF BRAILLE 8-DOT must be a block char"); + } + + #[test] + fn is_block_char_just_after_braille_range_is_false() { + assert!(!is_block_char('\u{2900}'), "U+2900 must not be a block char"); + } + + #[test] + fn is_block_char_ascii_is_false() { + for c in ' '..='~' { + assert!(!is_block_char(c), "ASCII '{c}' must not be a block char"); + } + } + + #[test] + fn is_block_char_mid_block_range() { + // U+2588 FULL BLOCK is the canonical block character. + assert!(is_block_char('\u{2588}')); + } + + // ── shape_run_impl ──────────────────────────────────────────────────────── + + #[test] + fn shape_run_empty_input_returns_empty() { + let face = test_face(); + let result = shape_run_impl(&face, &[]); + assert!(result.is_empty()); + } + + #[test] + fn shape_run_single_ascii_char_returns_one_glyph_at_index_zero() { + let face = test_face(); + let result = shape_run_impl(&face, &['A']); + assert_eq!(result.len(), 1); + assert_eq!(result[0].char_idx, 0); + assert_ne!(result[0].glyph_id, 0, "A must have a real glyph in JetBrains Mono"); + } + + #[test] + fn shape_run_multiple_ascii_chars_each_have_valid_char_idx() { + let face = test_face(); + let chars: Vec = "hello".chars().collect(); + let result = shape_run_impl(&face, &chars); + assert!(!result.is_empty()); + // All char_idxes must be in-range. + for sg in &result { + assert!(sg.char_idx < chars.len(), "char_idx {} out of range", sg.char_idx); + } + } + + #[test] + fn shape_run_char_idxes_are_monotonically_nondecreasing() { + let face = test_face(); + let chars: Vec = "fn foo() ->".chars().collect(); + let result = shape_run_impl(&face, &chars); + let mut prev = 0usize; + for sg in &result { + assert!( + sg.char_idx >= prev, + "char_idx went backwards: {} < {}", + sg.char_idx, prev + ); + prev = sg.char_idx; + } + } + + #[test] + fn shape_run_output_count_does_not_exceed_input_count() { + let face = test_face(); + // Ligatures may reduce glyph count; they must never increase it. + let chars: Vec = "->>=!=".chars().collect(); + let result = shape_run_impl(&face, &chars); + assert!( + result.len() <= chars.len(), + "shaped output ({}) must not exceed input ({}) chars", + result.len(), chars.len() + ); + } + + #[test] + fn shape_run_all_glyph_ids_nonzero_for_ascii() { + // Every printable ASCII char must be present in JetBrains Mono. + let face = test_face(); + for c in '!'..='~' { + let result = shape_run_impl(&face, &[c]); + assert_eq!(result.len(), 1); + assert_ne!( + result[0].glyph_id, 0, + "ASCII '{c}' (U+{:04X}) must have a glyph in JetBrains Mono", + c as u32 + ); + } + } + + #[test] + fn shape_run_unicode_multibyte_char_has_correct_char_idx() { + // U+00E9 (é) is 2 bytes in UTF-8; its cluster byte offset must map to char index 0. + let face = test_face(); + let chars = vec!['\u{00E9}']; + let result = shape_run_impl(&face, &chars); + assert!(!result.is_empty()); + assert_eq!(result[0].char_idx, 0, "char_idx for single multibyte char must be 0"); + } + + #[test] + fn shape_run_mixed_ascii_and_unicode_char_idxes_are_correct() { + let face = test_face(); + // "aé" — 'a' is 1 byte, 'é' is 2 bytes; char indices must be 0 and 1. + let chars = vec!['a', '\u{00E9}']; + let result = shape_run_impl(&face, &chars); + assert!(!result.is_empty()); + // The first glyph must come from char 0, second from char 1. + let idxes: Vec = result.iter().map(|s| s.char_idx).collect(); + assert!(idxes.contains(&0), "char_idx 0 must appear in result"); + // Char idx 1 might be merged if a ligature forms (unlikely for 'aé'). + assert!(idxes.iter().all(|&i| i < 2), "all char_idxes must be < 2"); + } + + // ── compute_row_glyph_ops_impl ──────────────────────────────────────────── + + #[test] + fn glyph_ops_space_cells_are_skip() { + let face = test_face(); + let term = make_term(10, 5); + // Default cells contain ' '. + let ops = compute_row_glyph_ops_impl(&face, &term.state, 0, 10); + for (i, op) in ops.iter().enumerate() { + assert!( + matches!(op, GlyphOp::Skip), + "col {i}: space must produce Skip, not Glyph/Block" + ); + } + } + + #[test] + fn glyph_ops_null_cell_is_skip() { + let face = test_face(); + let term = make_term(4, 2); + // A fresh terminal has ' ' in every cell — all must be Skip. + let ops = compute_row_glyph_ops_impl(&face, &term.state, 0, 4); + assert!(ops.iter().all(|o| matches!(o, GlyphOp::Skip))); + drop(term); + } + + #[test] + fn glyph_ops_printable_ascii_produces_glyph() { + let face = test_face(); + let mut term = make_term(10, 5); + // Write 'A' at (0,0). + term.process(b"A"); + let ops = compute_row_glyph_ops_impl(&face, &term.state, 0, 10); + assert!( + matches!(ops[0], GlyphOp::Glyph(_)), + "col 0 with 'A' must produce Glyph, got Skip/Block" + ); + // Remaining cols were not written → Skip. + for i in 1..10 { + assert!(matches!(ops[i], GlyphOp::Skip), "col {i} must be Skip"); + } + } + + #[test] + fn glyph_ops_block_char_is_block() { + let face = test_face(); + let mut term = make_term(10, 5); + // Write U+2588 FULL BLOCK. + term.process("\u{2588}".as_bytes()); + let ops = compute_row_glyph_ops_impl(&face, &term.state, 0, 10); + assert!( + matches!(ops[0], GlyphOp::Block), + "U+2588 must produce Block op" + ); + } + + #[test] + fn glyph_ops_different_fg_colors_break_shaping_run() { + // Two chars with different fg colors must each produce an independent + // Glyph op (no cross-color ligature). + let face = test_face(); + let mut term = make_term(10, 5); + // Write '-' in red. + term.process(b"\x1b[31m-"); + // Write '>' in green. + term.process(b"\x1b[32m>"); + let ops = compute_row_glyph_ops_impl(&face, &term.state, 0, 10); + // Both cols must be Glyph (independent runs, not ligature-merged Skip). + assert!(matches!(ops[0], GlyphOp::Glyph(_)), "col 0 '-' red must be Glyph"); + assert!(matches!(ops[1], GlyphOp::Glyph(_)), "col 1 '>' green must be Glyph"); + } + + #[test] + fn glyph_ops_same_fg_color_allows_ligature_run() { + // '-' and '>' in the same fg color must be shaped as one run. + // If JetBrains Mono produces a true single-glyph ligature, col 1 will be + // Skip. If it uses calt (2 contextual glyphs), both will be Glyph. + // Either way, neither should be Block, and col 0 must be Glyph. + let face = test_face(); + let mut term = make_term(10, 5); + term.process(b"->"); + let ops = compute_row_glyph_ops_impl(&face, &term.state, 0, 10); + assert!(matches!(ops[0], GlyphOp::Glyph(_)), "first char must be Glyph"); + assert!( + matches!(ops[1], GlyphOp::Glyph(_) | GlyphOp::Skip), + "second char must be Glyph or Skip (ligature)" + ); + } + + #[test] + fn glyph_ops_space_breaks_run() { + // 'a', ' ', 'b' — the space must break the shaping run so 'a' and 'b' + // are shaped independently. + let face = test_face(); + let mut term = make_term(10, 5); + term.process(b"a b"); + let ops = compute_row_glyph_ops_impl(&face, &term.state, 0, 10); + assert!(matches!(ops[0], GlyphOp::Glyph(_)), "col 0 'a' must be Glyph"); + assert!(matches!(ops[1], GlyphOp::Skip), "col 1 ' ' must be Skip"); + assert!(matches!(ops[2], GlyphOp::Glyph(_)), "col 2 'b' must be Glyph"); + } + + #[test] + fn glyph_ops_block_char_breaks_run() { + // 'a', U+2588, 'b' — the block char must break shaping runs. + let face = test_face(); + let mut term = make_term(10, 5); + term.process("a\u{2588}b".as_bytes()); + let ops = compute_row_glyph_ops_impl(&face, &term.state, 0, 10); + assert!(matches!(ops[0], GlyphOp::Glyph(_)), "col 0 'a' must be Glyph"); + assert!(matches!(ops[1], GlyphOp::Block), "col 1 U+2588 must be Block"); + assert!(matches!(ops[2], GlyphOp::Glyph(_)), "col 2 'b' must be Glyph"); + } + + #[test] + fn glyph_ops_vis_cols_zero_returns_empty() { + let face = test_face(); + let term = make_term(10, 5); + let ops = compute_row_glyph_ops_impl(&face, &term.state, 0, 0); + assert!(ops.is_empty()); + } + + #[test] + fn glyph_ops_row_out_of_visible_range_returns_all_skip() { + // Requesting a row beyond the grid should not panic; visual_cell returns + // a default (space) cell for out-of-range rows. + let face = test_face(); + let term = make_term(5, 3); + let ops = compute_row_glyph_ops_impl(&face, &term.state, 10, 5); + assert!(ops.iter().all(|o| matches!(o, GlyphOp::Skip))); + } } diff --git a/src/terminal.rs b/src/terminal.rs index d4b6f16..df7d3e2 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -372,12 +372,22 @@ impl TerminalState { self.grid[row][c] = blank; } } - 2 | 3 => { + 2 => { + for r in 0..self.rows { + for c in 0..self.cols { + self.grid[r][c] = blank; + } + } + } + 3 => { + // ED 3: erase display and clear scrollback (xterm extension). for r in 0..self.rows { for c in 0..self.cols { self.grid[r][c] = blank; } } + self.scrollback.clear(); + self.viewport_offset = 0; } _ => {} } @@ -2085,6 +2095,329 @@ mod tests { assert_eq!(ch(&t, 6, 0), 'G', "below region unchanged"); assert_eq!(ch(&t, 7, 0), 'H'); } + + // ── SGR italic / underline ──────────────────────────────────────────────── + + #[test] + fn sgr_italic_on_off() { + let mut t = t(10, 5); + t.process(b"\x1b[3m"); + assert!(t.state.attrs.italic, "SGR 3 must set italic"); + t.process(b"\x1b[23m"); + assert!(!t.state.attrs.italic, "SGR 23 must clear italic"); + } + + #[test] + fn sgr_underline_on_off() { + let mut t = t(10, 5); + t.process(b"\x1b[4m"); + assert!(t.state.attrs.underline, "SGR 4 must set underline"); + t.process(b"\x1b[24m"); + assert!(!t.state.attrs.underline, "SGR 24 must clear underline"); + } + + #[test] + fn sgr_fg_default_reset_restores_default_fg() { + let mut t = t(10, 5); + t.process(b"\x1b[31m"); // red fg + assert_ne!(t.state.attrs.fg, DEFAULT_FG); + t.process(b"\x1b[39m"); // reset fg + assert_eq!(t.state.attrs.fg, DEFAULT_FG, "SGR 39 must restore default fg"); + } + + #[test] + fn sgr_bg_default_reset_restores_default_bg() { + let mut t = t(10, 5); + t.process(b"\x1b[41m"); // red bg + assert_ne!(t.state.attrs.bg, DEFAULT_BG); + t.process(b"\x1b[49m"); // reset bg + assert_eq!(t.state.attrs.bg, DEFAULT_BG, "SGR 49 must restore default bg"); + } + + #[test] + fn sgr_bright_bg_100_to_107_uses_high_ansi() { + let mut t = t(10, 5); + for n in 0u8..8 { + t.process(format!("\x1b[{}m", 100 + n).as_bytes()); + assert_eq!( + t.state.attrs.bg, ANSI_COLORS[(n + 8) as usize], + "SGR {} must use ANSI_COLORS[{}]", 100 + n, n + 8 + ); + } + } + + // ── CSI E / F (cursor next/previous line) ───────────────────────────────── + + #[test] + fn cnl_moves_cursor_down_and_to_col_zero() { + let mut t = t(10, 10); + t.process(b"\x1b[5;5H"); // row 5, col 5 + t.process(b"\x1b[2E"); // CNL 2 — down 2, col 0 + assert_eq!(t.state.cursor_row, 6, "CNL 2 from row 4 must land at row 6"); + assert_eq!(t.state.cursor_col, 0, "CNL must reset col to 0"); + } + + #[test] + fn cpl_moves_cursor_up_and_to_col_zero() { + let mut t = t(10, 10); + t.process(b"\x1b[5;5H"); // row 5, col 5 + t.process(b"\x1b[2F"); // CPL 2 — up 2, col 0 + assert_eq!(t.state.cursor_row, 2, "CPL 2 from row 4 must land at row 2"); + assert_eq!(t.state.cursor_col, 0, "CPL must reset col to 0"); + } + + #[test] + fn cpl_clamped_at_top() { + let mut t = t(10, 10); + t.process(b"\x1b[2;5H"); // row 2, col 5 + t.process(b"\x1b[100F"); // CPL 100 — should clamp at row 0 + assert_eq!(t.state.cursor_row, 0, "CPL past top must clamp at row 0"); + assert_eq!(t.state.cursor_col, 0); + } + + // ── CSI d (VPA — vertical position absolute) ────────────────────────────── + + #[test] + fn vpa_sets_row_1indexed() { + let mut t = t(10, 10); + t.process(b"\x1b[5d"); // VPA 5 → row 4 (0-indexed) + assert_eq!(t.state.cursor_row, 4, "VPA 5 must set cursor_row to 4"); + } + + #[test] + fn vpa_default_goes_to_row_zero() { + let mut t = t(10, 10); + t.process(b"\x1b[5;1H"); // move away first + t.process(b"\x1b[1d"); // VPA 1 → row 0 + assert_eq!(t.state.cursor_row, 0); + } + + #[test] + fn vpa_clamped_to_last_row() { + let mut t = t(10, 5); + t.process(b"\x1b[100d"); // beyond last row + assert_eq!(t.state.cursor_row, 4, "VPA past last row must clamp"); + } + + // ── CSI S / T (scroll up / down) ───────────────────────────────────────── + + #[test] + fn su_scroll_up_discards_top_lines() { + let mut t = t(5, 4); + t.process(b"A\r\nB\r\nC\r\nD"); + t.process(b"\x1b[1S"); // SU 1 + // After scrolling up 1, old row 1 ("B") is now row 0. + assert_eq!(ch(&t, 0, 0), 'B', "after SU 1, row 0 must be 'B'"); + assert_eq!(ch(&t, 1, 0), 'C'); + assert_eq!(ch(&t, 2, 0), 'D'); + assert_eq!(ch(&t, 3, 0), ' ', "new bottom row must be blank"); + } + + #[test] + fn sd_scroll_down_inserts_blank_at_top() { + let mut t = t(5, 4); + t.process(b"A\r\nB\r\nC\r\nD"); + t.process(b"\x1b[1T"); // SD 1 + // After scrolling down 1, blank row inserted at top. + assert_eq!(ch(&t, 0, 0), ' ', "after SD 1, row 0 must be blank"); + assert_eq!(ch(&t, 1, 0), 'A'); + assert_eq!(ch(&t, 2, 0), 'B'); + assert_eq!(ch(&t, 3, 0), 'C'); + } + + // ── CSI M (DL — delete line) ────────────────────────────────────────────── + + #[test] + fn dl_deletes_line_at_cursor() { + let mut t = t(5, 4); + t.process(b"A\r\nB\r\nC\r\nD"); + t.process(b"\x1b[2;1H"); // cursor to row 1 (0-indexed) + t.process(b"\x1b[1M"); // DL 1 + // Row "B" deleted; "C","D" shift up. + assert_eq!(ch(&t, 0, 0), 'A'); + assert_eq!(ch(&t, 1, 0), 'C', "DL must shift remaining rows up"); + assert_eq!(ch(&t, 2, 0), 'D'); + assert_eq!(ch(&t, 3, 0), ' ', "new bottom row must be blank"); + } + + // ── ESC D / E / M (IND / NEL / RI) ─────────────────────────────────────── + + #[test] + fn esc_d_index_advances_row() { + let mut t = t(5, 5); + t.process(b"\x1b[2;3H"); // row 1, col 2 + t.process(b"\x1bD"); // IND — newline without CR + assert_eq!(t.state.cursor_row, 2, "ESC D must advance row"); + assert_eq!(t.state.cursor_col, 2, "ESC D must not change col"); + } + + #[test] + fn esc_e_next_line_advances_row_and_resets_col() { + let mut t = t(5, 5); + t.process(b"\x1b[2;3H"); // row 1, col 2 + t.process(b"\x1bE"); // NEL + assert_eq!(t.state.cursor_row, 2, "ESC E must advance row"); + assert_eq!(t.state.cursor_col, 0, "ESC E must reset col to 0"); + } + + #[test] + fn esc_m_reverse_index_moves_up() { + let mut t = t(5, 5); + t.process(b"\x1b[3;1H"); // row 2 + t.process(b"\x1bM"); // RI — reverse index + assert_eq!(t.state.cursor_row, 1, "ESC M must move cursor up"); + } + + #[test] + fn esc_m_at_scroll_top_scrolls_down() { + // At the scroll top, RI inserts a blank line (scrolls content down). + let mut t = t(5, 4); + t.process(b"A\r\nB\r\nC\r\nD"); + t.process(b"\x1b[1;1H"); // cursor to row 0 (scroll_top) + t.process(b"\x1bM"); + assert_eq!(ch(&t, 0, 0), ' ', "RI at top must insert blank row"); + assert_eq!(ch(&t, 1, 0), 'A', "previous row 0 shifts down"); + } + + // ── ESC c (hard reset) ──────────────────────────────────────────────────── + + #[test] + fn esc_c_resets_to_fresh_state() { + let mut t = t(10, 5); + // Set some state. + t.process(b"\x1b[31m"); // red fg + t.process(b"\x1b[5;5H"); // move cursor + t.process(b"HELLO"); + t.process(b"\x1bc"); // RIS — hard reset + assert_eq!(t.state.cursor_row, 0, "RIS must reset cursor to origin"); + assert_eq!(t.state.cursor_col, 0); + assert_eq!(t.state.attrs.fg, DEFAULT_FG, "RIS must reset SGR attributes"); + assert_eq!(ch(&t, 0, 0), ' ', "RIS must clear screen"); + } + + // ── CSI c (device attributes) ───────────────────────────────────────────── + + #[test] + fn csi_c_queues_device_attributes_response() { + let mut t = t(10, 5); + t.process(b"\x1b[c"); + assert!( + !t.state.pending_responses.is_empty(), + "CSI c must queue a device attributes response" + ); + let resp = &t.state.pending_responses[0]; + assert_eq!(resp, b"\x1b[?1;2c", "device attributes response must be ESC[?1;2c"); + } + + // ── Cursor keys application mode ────────────────────────────────────────── + + #[test] + fn cursor_keys_app_mode_enabled_by_1h() { + let mut t = t(10, 5); + assert!(!t.state.cursor_keys_app_mode); + t.process(b"\x1b[?1h"); + assert!(t.state.cursor_keys_app_mode, "?1h must enable app mode"); + } + + #[test] + fn cursor_keys_app_mode_disabled_by_1l() { + let mut t = t(10, 5); + t.process(b"\x1b[?1h"); + t.process(b"\x1b[?1l"); + assert!(!t.state.cursor_keys_app_mode, "?1l must disable app mode"); + } + + // ── OSC 7 hostname stripping ────────────────────────────────────────────── + + #[test] + fn osc_7_strips_hostname_from_file_url() { + let mut t = t(10, 5); + // OSC 7 ; file://hostname/home/user ST + t.process(b"\x1b]7;file://mymac/home/user\x07"); + assert_eq!( + t.state.current_dir, "/home/user", + "OSC 7 must strip the hostname and keep the path" + ); + } + + #[test] + fn osc_7_empty_host_preserves_path() { + let mut t = t(10, 5); + t.process(b"\x1b]7;file:///tmp/work\x07"); + assert_eq!(t.state.current_dir, "/tmp/work"); + } + + #[test] + fn osc_7_non_file_url_used_verbatim() { + // No file:// prefix → content used as-is. + let mut t = t(10, 5); + t.process(b"\x1b]7;/just/a/path\x07"); + assert_eq!(t.state.current_dir, "/just/a/path"); + } + + // ── SGR: simultaneous bold + italic + underline ─────────────────────────── + + #[test] + fn sgr_combined_bold_italic_underline() { + let mut t = t(10, 5); + t.process(b"\x1b[1;3;4m"); + assert!(t.state.attrs.bold); + assert!(t.state.attrs.italic); + assert!(t.state.attrs.underline); + // Reset clears all three. + t.process(b"\x1b[0m"); + assert!(!t.state.attrs.bold); + assert!(!t.state.attrs.italic); + assert!(!t.state.attrs.underline); + } + + // ── CUF/CUB with explicit count ─────────────────────────────────────────── + + #[test] + fn cuf_with_count_moves_right_n_cols() { + let mut t = t(20, 5); + t.process(b"\x1b[1;1H"); // col 0 + t.process(b"\x1b[5C"); // CUF 5 + assert_eq!(t.state.cursor_col, 5); + } + + #[test] + fn cub_with_count_moves_left_n_cols() { + let mut t = t(20, 5); + t.process(b"\x1b[1;10H"); // col 9 + t.process(b"\x1b[3D"); // CUB 3 + assert_eq!(t.state.cursor_col, 6); + } + + #[test] + fn cuf_clamped_at_last_col() { + let mut t = t(10, 5); + t.process(b"\x1b[1;1H"); + t.process(b"\x1b[100C"); + assert_eq!(t.state.cursor_col, 9, "CUF past last col must clamp"); + } + + #[test] + fn cub_clamped_at_col_zero() { + let mut t = t(10, 5); + t.process(b"\x1b[1;5H"); // col 4 + t.process(b"\x1b[100D"); // CUB 100 + assert_eq!(t.state.cursor_col, 0, "CUB past col 0 must clamp at 0"); + } + + // ── ED mode 3 (clear scrollback) ────────────────────────────────────────── + + #[test] + fn ed3_clears_scrollback_buffer() { + let mut t = t(5, 3); + // Fill scrollback by printing more lines than the terminal height. + for i in 0..10 { + t.process(format!("line{i}\r\n").as_bytes()); + } + assert!(!t.state.scrollback.is_empty(), "scrollback must be non-empty before ED 3"); + t.process(b"\x1b[3J"); + assert!(t.state.scrollback.is_empty(), "ED 3 (CSI 3 J) must clear scrollback"); + } } pub struct Terminal { -- 2.51.2