diff --git a/bench/README.md b/bench/README.md index 845ae891..47b563ae 100644 --- a/bench/README.md +++ b/bench/README.md @@ -28,6 +28,7 @@ which the binary writes when `TOASTTY_STATS=` is set | `scrollback_memory` | 30k full-width coloured lines (fills the 10k scrollback) | RSS after | | `key_latency` | types into `cat` at 25 keys/s | key → frame ms | | `scroll_wheel` / `_big` | wheel-scrolls 20k lines of history | frame ms p50/p99 | +| `paced_text` / `_cjk` / `_tui` (+ `_big`) | `bench/paced.py` inside toastty: one full-screen synchronized-output frame every 20 ms. `text` is new random coloured words each frame, `cjk` the same with CJK words and emoji mixed in (every row needs cosmic-text), `tui` an htop-like screen whose text mostly stays put while colours and a few numbers change | frame ms p50/p99/mean, CPU s | "Frame ms" is the CPU time of `Renderer::render_term`, including the present. Output → frame is measured from the first PTY batch after a frame diff --git a/bench/paced.py b/bench/paced.py new file mode 100644 index 00000000..bce93f42 --- /dev/null +++ b/bench/paced.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Paced full-screen repaints, run *inside* toastty by `run.py`. + +Emits one full-screen synchronized-output frame every `--interval-ms` +(20 ms by default), sized to the terminal, then exits. Unlike the +`dense_cells` flood, the frames are paced, so toastty renders (nearly) +every one of them and the per-frame render cost shows up in +`TOASTTY_STATS` `frame_us`. + +Modes: + +- `text`: every frame is new text (seeded random words, a 256-colour fg + per word, a few coloured bg runs). Worst case for row shaping: no row + repeats between frames. +- `cjk`: like `text`, with CJK words and emoji mixed in, so every row + needs cosmic-text (the shaper's slow path). +- `tui`: an htop/btop-like screen. The layout and most of the text stay + put; colours cycle and a few numbers change every frame, so every row + is rewritten (and dirty) but most rows' text is unchanged. + + paced.py --mode text --frames 300 [--interval-ms 20] [--seed 1] +""" + +from __future__ import annotations + +import argparse +import os +import random +import sys +import time + +WORDS = ( + "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod " + "tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam " + "quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo " + "consequat duis aute irure in reprehenderit voluptate velit esse cillum " + "fugiat nulla pariatur excepteur sint occaecat cupidatat non proident" +).split() + + +CJK_WORDS = "你好 世界 日本語 テキスト 한글 漢字 中文 进度 🚀 👍 café naïve".split() + + +def width(word: str) -> int: + # Every non-Latin char in CJK_WORDS is a wide (2-cell) char. + return sum(2 if ord(ch) > 0x2FFF else 1 for ch in word) + + +def text_frame(rng: random.Random, cols: int, rows: int, cjk: bool = False) -> str: + out = [] + for r in range(rows): + out.append(f"\x1b[{r + 1};1H") + w = 0 + while w < cols: + word = rng.choice(CJK_WORDS if cjk and rng.random() < 0.3 else WORDS) + if width(word) > cols - w: + # Truncate (ASCII) or pad out (a wide word that won't fit). + word = word[: cols - w] if word.isascii() else "x" * (cols - w) + fg = rng.randrange(256) + if rng.random() < 0.1: + out.append(f"\x1b[38;5;{fg};48;5;{rng.randrange(256)}m{word}\x1b[49m") + else: + out.append(f"\x1b[38;5;{fg}m{word}") + w += width(word) + if w < cols: + out.append(" ") + w += 1 + out.append("\x1b[0m") + return "".join(out) + + +def tui_frame(rng: random.Random, cols: int, rows: int, f: int) -> str: + out = [] + for r in range(rows): + out.append(f"\x1b[{r + 1};1H") + if r == 0: + line = f" uptime 12:{f % 60:02d} load {f % 7}.{f % 10}{(f * 3) % 10}" + out.append(f"\x1b[1;37;44m{line[:cols].ljust(cols)}\x1b[0m") + continue + # A process-table row: fixed name / command text, a meter that + # moves on a few rows each frame. + pid = 1000 + r * 37 + name = WORDS[r % len(WORDS)] + cmd = " ".join(WORDS[(r + k) % len(WORDS)] for k in range(8)) + busy = (r + f // 10) % 9 == 0 + cpu = (r * 13 + (f if busy else 0)) % 100 + bar_w = 20 + fill = cpu * bar_w // 100 + fg = 16 + (r * 5 + f) % 216 + cells = f"{pid:>7} {name:<12} {cpu:>3}% " + rest = f" {cmd}" + tail_w = max(0, cols - len(cells) - bar_w - 2) + out.append( + f"\x1b[38;5;{fg}m{cells}\x1b[0m" + f"[\x1b[38;5;{46 + fill}m{'|' * fill}{' ' * (bar_w - fill)}\x1b[0m]" + f"\x1b[38;5;{250 - r % 6}m{rest[:tail_w].ljust(tail_w)}\x1b[0m" + ) + return "".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--mode", choices=("text", "cjk", "tui"), default="text") + ap.add_argument("--frames", type=int, default=300) + ap.add_argument("--interval-ms", type=float, default=20.0) + ap.add_argument("--seed", type=int, default=1) + args = ap.parse_args() + + cols, rows = os.get_terminal_size(sys.stdout.fileno()) + rng = random.Random(args.seed) + w = sys.stdout.buffer + w.write(b"\x1b[?25l\x1b[2J") + w.flush() + # Let the window settle (first frame, any resize) before timing. + time.sleep(0.5) + t0 = time.monotonic() + for f in range(args.frames): + if args.mode == "tui": + body = tui_frame(rng, cols, rows, f) + else: + body = text_frame(rng, cols, rows, cjk=args.mode == "cjk") + w.write(("\x1b[?2026h\x1b[H" + body + "\x1b[?2026l").encode()) + w.flush() + next_t = t0 + (f + 1) * args.interval_ms / 1000 + dt = next_t - time.monotonic() + if dt > 0: + time.sleep(dt) + w.write(b"\x1b[0m\x1b[?25h") + w.flush() + time.sleep(0.2) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/run.py b/bench/run.py index 1083f077..46947e1c 100755 --- a/bench/run.py +++ b/bench/run.py @@ -521,6 +521,45 @@ def scroll_wheel(self): def scroll_wheel_big(self): return self._scroll(width=2560, height=1400) + def _paced(self, mode: str, frames: int = 300, **cfg): + runs = [] + for _ in range(self.reps): + t = self.launch( + ["python3", str(HERE / "paced.py"), "--mode", mode, "--frames", str(frames)], + blink=False, + **cfg, + ) + st = t.wait(120) + # Drop the first frames (startup, the initial clear) so only + # the paced repaints count. + frame_ms = [x / 1000 for x in st["frame_us"]][5:] + runs.append( + { + "frames": st["frames"], + "frame_ms_p50": pct(frame_ms, 50), + "frame_ms_p99": pct(frame_ms, 99), + "frame_ms_mean": statistics.fmean(frame_ms) if frame_ms else float("nan"), + "cpu_s": (st["utime_ticks"] + st["stime_ticks"]) / CLK_TCK, + "render_s": sum(frame_ms) / 1000, + } + ) + return median_runs(runs) + + def paced_text(self): + return self._paced("text") + + def paced_text_big(self): + return self._paced("text", width=2560, height=1400) + + def paced_cjk(self): + return self._paced("cjk") + + def paced_tui(self): + return self._paced("tui") + + def paced_tui_big(self): + return self._paced("tui", width=2560, height=1400) + def close(self): self.display.close() @@ -539,6 +578,11 @@ def close(self): "key_latency", "scroll_wheel", "scroll_wheel_big", + "paced_text", + "paced_text_big", + "paced_cjk", + "paced_tui", + "paced_tui_big", ] diff --git a/crates/toastty-render/benches/render_term.rs b/crates/toastty-render/benches/render_term.rs index dc1f88a4..1235cf6f 100644 --- a/crates/toastty-render/benches/render_term.rs +++ b/crates/toastty-render/benches/render_term.rs @@ -9,8 +9,14 @@ //! iterations (the realistic keystroke case). //! - `fullframe_300x120` / `single_cell_change_300x120`: the same two //! cases at twice the row count, to show how shaping cost scales. +//! - `fullframe_unicode_200x60`: full repaint of unchanged mixed-script +//! text (CJK, emoji, box drawing), the rows that need cosmic-text. +//! - `scroll_ascii_200x60` / `scroll_unicode_200x60`: one new line of +//! output per frame at the live bottom, so every row shifts. //! //! Run with `cargo bench -p toastty-render --bench render_term -- --quick`. +//! `TOASTTY_BENCH_CPU_ONLY=1` stops each frame after shaping + instance +//! building (no GPU work), for low-noise CPU numbers. use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -20,9 +26,12 @@ use criterion::{Criterion, criterion_group, criterion_main}; use pollster::block_on; use toastty_parser::Parser; use toastty_render::DEFAULT_LINE_HEIGHT; -use toastty_render::text::glyph_rasterizer::{GlyphRasterizer, LineGlyphs}; -use toastty_render::text::instance::{CellInstance, EdgeBleed, Theme, build_instances_into}; +use toastty_render::text::glyph_rasterizer::GlyphRasterizer; +use toastty_render::text::instance::{ + CellInstance, CellRenderOpts, EdgeBleed, Theme, build_instances_into_opts, +}; use toastty_render::text::pipeline::{self, GlobalsUbo, TextPipeline}; +use toastty_render::text::row_shaper::RowShaper; use toastty_render::{instance_descriptor, instance_flags_for_release}; use toastty_term::Term; use wgpu::{ @@ -50,10 +59,14 @@ struct Harness { width: u32, height: u32, theme: Theme, - /// Mirrors `TextState::line_cache` in `Renderer`. - line_cache: Vec>, + /// The renderer's own row-shape cache + shaping loop. + rows: RowShaper, /// Mirrors `TextState::instances_scratch` in `Renderer`. instances: Vec, + /// `TOASTTY_BENCH_CPU_ONLY=1`: stop after shaping + instance + /// building, so the numbers exclude GPU work (and the noise of other + /// processes sharing the GPU). + cpu_only: bool, } impl Harness { @@ -134,8 +147,9 @@ impl Harness { width, height, theme: Theme::default_dark(), - line_cache: Vec::new(), + rows: RowShaper::new(), instances: Vec::new(), + cpu_only: std::env::var_os("TOASTTY_BENCH_CPU_ONLY").is_some(), } } @@ -151,47 +165,32 @@ impl Harness { let cell_size = self.rasterizer.cell_size(); let atlas_dims = self.rasterizer.atlas_dims(); - if self.line_cache.len() != rows as usize { - self.line_cache.resize(rows as usize, None); - } - - let damage = term.damage(); - for r in 0..rows { - let is_dirty = damage.all - || damage.rows.get(r as usize).is_some_and(|rd| !rd.is_empty()) - || self.line_cache[r as usize].is_none(); - if !is_dirty { - continue; - } - let row = term.row(r); - let line_text: String = row - .cells() - .iter() - .map(|c| if c.ch == '\0' { ' ' } else { c.ch }) - .collect(); - self.line_cache[r as usize] = Some(self.rasterizer.shape_line( - &self.queue, - &line_text, - term.grapheme_cluster_mode(), - )); - } + self.rows + .shape_rows(&mut self.rasterizer, &self.queue, term, rows, &[]); let theme = self.theme; - let line_cache_ref = &self.line_cache; - build_instances_into( + let rows_ref = &self.rows; + // Same options as the renderer's full-redraw frames. + let opts = CellRenderOpts { + blink_on: true, + decor: Some(self.rasterizer.decoration_metrics()), + skip_default_bg: true, + }; + build_instances_into_opts( &mut self.instances, term, cell_size, &theme, None, - |row, col, ch, _style| { - let lg = line_cache_ref.get(row as usize)?.as_ref()?; - lg.get(col, ch) - }, + |row, col, ch, _style| rows_ref.glyph(row, col, ch), |_, _| false, EdgeBleed::default(), f32::from(term.size().0) * cell_size.1, + &opts, ); + if self.cpu_only { + return; + } let instances = &self.instances; let mut encoder = self @@ -273,35 +272,16 @@ impl Harness { let cell_size = self.rasterizer.cell_size(); let atlas_dims = self.rasterizer.atlas_dims(); - if self.line_cache.len() != rows as usize { - self.line_cache.resize(rows as usize, None); - } - let damage = term.damage(); - let mut shaped = 0usize; - - // Phase A: shape (dirty rows only, unless `force_full`). + // Phase A: shape (dirty rows only; `force_full` also drops the + // row-shape cache, though rows still hit the rasterizer's + // row-text memo). let t_shape = Instant::now(); - for r in 0..rows { - let is_dirty = force_full - || damage.all - || damage.rows.get(r as usize).is_some_and(|rd| !rd.is_empty()) - || self.line_cache[r as usize].is_none(); - if !is_dirty { - continue; - } - let row = term.row(r); - let line_text: String = row - .cells() - .iter() - .map(|c| if c.ch == '\0' { ' ' } else { c.ch }) - .collect(); - self.line_cache[r as usize] = Some(self.rasterizer.shape_line( - &self.queue, - &line_text, - term.grapheme_cluster_mode(), - )); - shaped += 1; + if force_full { + self.rows.invalidate(); } + let shaped = self + .rows + .shape_rows(&mut self.rasterizer, &self.queue, term, rows, &[]); let shape_ms = t_shape.elapsed().as_secs_f64() * 1000.0; // Phase B: bind group (post-fix this is a no-op; left here for @@ -319,20 +299,24 @@ impl Harness { // Phase C: build_instances. let t_build = Instant::now(); let theme = self.theme; - let line_cache_ref = &self.line_cache; - build_instances_into( + let rows_ref = &self.rows; + // Same options as the renderer's full-redraw frames. + let opts = CellRenderOpts { + blink_on: true, + decor: Some(self.rasterizer.decoration_metrics()), + skip_default_bg: true, + }; + build_instances_into_opts( &mut self.instances, term, cell_size, &theme, None, - |row, col, ch, _style| { - let lg = line_cache_ref.get(row as usize)?.as_ref()?; - lg.get(col, ch) - }, + |row, col, ch, _style| rows_ref.glyph(row, col, ch), |_, _| false, EdgeBleed::default(), f32::from(term.size().0) * cell_size.1, + &opts, ); let build_ms = t_build.elapsed().as_secs_f64() * 1000.0; let n_instances = self.instances.len(); @@ -546,6 +530,269 @@ fn bench_single_cell_change_fullscreen(c: &mut Criterion) { }); } +/// Mixed-script lines: CJK, emoji, box drawing, accents. Every line +/// with a wide char goes through cosmic-text (the slow path) whenever +/// it is shaped. +const UNICODE_LINES: [&str; 4] = [ + "│ src/main.rs │ 你好世界 ideographs こんにちは 한글 — CJK wide cells ├──┤ ", + "Emoji 🚀 rocket ✨ sparkles 👍 thumbs ═══ café naïve résumé Ω λ Ж ░▒▓█ ", + "日本語のテキスト mixed with ASCII words and → arrows ← and … ellipses ", + "status: ok ✔ 进度 50% ⣿⣿⣿⣿⡇ elapsed 00:12 — 中文 漢字 ", +]; + +fn unicode_line(i: usize, cols: u16) -> String { + let base = UNICODE_LINES[i % UNICODE_LINES.len()]; + let mut line = format!("{i:>6} "); + // Fill to (roughly) the grid width without wrapping; wide chars + // count as two columns. + let mut w = line.len(); + for ch in base.chars().cycle() { + let cw = if ch.is_ascii() || ('\u{2190}'..='\u{2bff}').contains(&ch) { + 1 + } else { + 2 + }; + if w + cw > usize::from(cols) { + break; + } + line.push(ch); + w += cw; + } + line +} + +fn make_term_unicode(rows: u16, cols: u16) -> Term { + let mut term = Term::new(rows, cols, 0); + let mut parser = Parser::new(); + for i in 0..usize::from(rows) { + let mut l = unicode_line(i, cols); + if i + 1 < usize::from(rows) { + l.push_str("\r\n"); + } + parser.advance(&mut term, l.as_bytes()); + } + term +} + +fn bench_fullframe_unicode(c: &mut Criterion) { + let (width, height) = viewport_pixels(ROWS, COLS); + let mut harness = Harness::new(width, height); + let mut term = make_term_unicode(ROWS, COLS); + harness.render_term(&term); + term.clear_damage(); + c.bench_function("fullframe_unicode_200x60", |b| { + b.iter(|| { + // Full repaint of unchanged text (a TUI repainting in new + // colours, or a viewport change). + term.mark_all_dirty(); + harness.render_term(&term); + term.clear_damage(); + }); + }); +} + +/// New output at the live bottom: one new line per frame, every other +/// row shifts up one (and is dirty). +fn bench_scroll(c: &mut Criterion, name: &str, unicode: bool) { + let (width, height) = viewport_pixels(ROWS, COLS); + let mut harness = Harness::new(width, height); + let mut term = if unicode { + make_term_unicode(ROWS, COLS) + } else { + make_term_filled(ROWS, COLS) + }; + let mut parser = Parser::new(); + parser.advance(&mut term, b"\x1b[60;1H"); + harness.render_term(&term); + term.clear_damage(); + let mut i = 0usize; + c.bench_function(name, |b| { + b.iter(|| { + i += 1; + let line = if unicode { + unicode_line(i, COLS) + } else { + format!("{i:>6} lorem ipsum dolor sit amet, consectetur adipiscing elit") + }; + parser.advance(&mut term, b"\r\n"); + parser.advance(&mut term, line.as_bytes()); + harness.render_term(&term); + term.clear_damage(); + }); + }); +} + +fn bench_scroll_ascii(c: &mut Criterion) { + bench_scroll(c, "scroll_ascii_200x60", false); +} + +fn bench_scroll_unicode(c: &mut Criterion) { + bench_scroll(c, "scroll_unicode_200x60", true); +} + +/// Wide (2-cell) words for the `newtext_cjk` min-time scenario. +const CJK_WORDS: [&str; 10] = [ + "你好", + "世界", + "日本語", + "テキスト", + "한글", + "漢字", + "中文", + "进度", + "🚀", + "👍", +]; + +/// Frames per `TOASTTY_BENCH_MIN` scenario. +const MIN_FRAMES: usize = 400; + +/// Print the minimum and median of per-frame times (µs). +fn report_min(name: &str, mut times: Vec) { + times.sort_by(f64::total_cmp); + println!( + "min-time {name:<26} min {:>8.1} us p50 {:>8.1} us", + times[0], + times[times.len() / 2] + ); +} + +/// Time `frames` CPU-only frames; `step` mutates the term before each. +fn time_frames( + h: &mut Harness, + term: &mut Term, + frames: usize, + mut step: impl FnMut(&mut Term, usize), +) -> Vec { + let mut times = Vec::with_capacity(frames); + for i in 0..frames { + step(term, i); + let t0 = Instant::now(); + h.render_term(term); + times.push(t0.elapsed().as_secs_f64() * 1e6); + term.clear_damage(); + } + times +} + +/// One screen of new coloured words, `kind` = `256` / `rgb` / `cjk` +/// (256-colour with CJK / emoji words mixed in). `seed` carries the +/// LCG state between screens. +fn new_text_screen(kind: &str, rows: u16, cols: u16, frame: usize, seed: &mut u32) -> String { + use std::fmt::Write as _; + const LOREM: [&str; 19] = [ + "lorem", + "ipsum", + "dolor", + "sit", + "amet", + "consectetur", + "adipiscing", + "elit", + "sed", + "do", + "eiusmod", + "tempor", + "incididunt", + "ut", + "labore", + "et", + "dolore", + "magna", + "aliqua", + ]; + let mut screen = String::from("\x1b[H"); + for row in 0..rows { + let _ = write!(screen, "\x1b[{};1H", row + 1); + let mut width = 0usize; + while width + 12 < usize::from(cols) { + *seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); + let s = *seed; + let (word, word_w) = if kind == "cjk" && (s >> 20) % 10 < 3 { + let w = CJK_WORDS[(s >> 16) as usize % CJK_WORDS.len()]; + (w, 2 * w.chars().count()) + } else { + let w = LOREM[(s >> 16) as usize % LOREM.len()]; + (w, w.len()) + }; + if kind == "rgb" { + let (red, green, blue) = (s >> 8 & 255, s >> 12 & 255, s >> 4 & 255); + let _ = write!(screen, "\x1b[38;2;{red};{green};{blue}m{word} "); + } else { + let _ = write!(screen, "\x1b[38;5;{}m{word} ", (s >> 8) % 256); + } + width += word_w + 1; + } + let _ = write!(screen, "{frame}\x1b[K"); + } + screen +} + +/// Low-noise CPU numbers — runs when `TOASTTY_BENCH_MIN=1` is set, +/// otherwise a no-op. Each scenario renders 400 frames CPU-only +/// (shaping + instance building, no GPU) and prints the minimum and +/// median frame time: the minimum is what the code costs, the median +/// what it costs on a busy machine. +fn bench_min_times(c: &mut Criterion) { + if std::env::var_os("TOASTTY_BENCH_MIN").is_none() { + return; + } + let repaint = |term: &mut Term, _| term.mark_all_dirty(); + for (rows, cols) in [(ROWS, COLS), (BIG_ROWS, BIG_COLS)] { + let (width, height) = viewport_pixels(rows, cols); + let mut h = Harness::new(width, height); + h.cpu_only = true; + // Full repaint of unchanged text. + let mut term = make_term_filled(rows, cols); + h.render_term(&term); + let times = time_frames(&mut h, &mut term, MIN_FRAMES, repaint); + report_min(&format!("repaint_{cols}x{rows}"), times); + // Every row new text every frame (no memo hits), coloured words: + // 256-colour, truecolor, and 256-colour with CJK / emoji words + // mixed in (every row needs cosmic-text; fewer frames, it's slow). + for kind in ["256", "rgb", "cjk"] { + let frames = if kind == "cjk" { + MIN_FRAMES / 4 + } else { + MIN_FRAMES + }; + let mut parser = Parser::new(); + let mut seed = 1u32; + let times = time_frames(&mut h, &mut term, frames, |term, f| { + let screen = new_text_screen(kind, rows, cols, f, &mut seed); + parser.advance(term, screen.as_bytes()); + }); + report_min(&format!("newtext_{kind}_{cols}x{rows}"), times); + } + } + let (width, height) = viewport_pixels(ROWS, COLS); + let mut h = Harness::new(width, height); + h.cpu_only = true; + // Full repaint of faint (SGR 2) text. + let mut term = Term::new(ROWS, COLS, 0); + Parser::new().advance(&mut term, b"\x1b[2;33m"); + let filled = make_term_filled(ROWS, COLS); + let text: String = (0..ROWS) + .flat_map(|r| filled.row(r).cells().iter().map(|cell| cell.ch)) + .collect(); + Parser::new().advance(&mut term, text.as_bytes()); + h.render_term(&term); + let times = time_frames(&mut h, &mut term, MIN_FRAMES, repaint); + report_min("repaint_dim_200x60", times); + let mut term = make_term_unicode(ROWS, COLS); + h.render_term(&term); + let times = time_frames(&mut h, &mut term, MIN_FRAMES, repaint); + report_min("repaint_unicode_200x60", times); + let mut parser = Parser::new(); + parser.advance(&mut term, b"\x1b[60;1H"); + let times = time_frames(&mut h, &mut term, MIN_FRAMES, |term, i| { + parser.advance(term, b"\r\n"); + parser.advance(term, unicode_line(i + 1000, COLS).as_bytes()); + }); + report_min("scroll_unicode_200x60", times); + c.bench_function("min_times_dummy", |b| b.iter(|| {})); +} + /// Phase breakdown — runs once when `TOASTTY_BENCH_BREAKDOWN=1` is set, /// otherwise registered as a no-op criterion bench. Prints per-section /// wall time for both the cached single-cell case and the worst-case @@ -594,6 +841,10 @@ criterion_group!( bench_single_cell_change, bench_fullframe_fullscreen, bench_single_cell_change_fullscreen, + bench_fullframe_unicode, + bench_scroll_ascii, + bench_scroll_unicode, bench_phase_breakdown, + bench_min_times, ); criterion_main!(benches); diff --git a/crates/toastty-render/src/lib.rs b/crates/toastty-render/src/lib.rs index 8604ed90..37936606 100644 --- a/crates/toastty-render/src/lib.rs +++ b/crates/toastty-render/src/lib.rs @@ -55,9 +55,10 @@ use crate::image::instance::{ }; use crate::image::pipeline::ImagePipeline; use crate::rgp::{GpuAssetCache, Rgp3dPipeline}; -use crate::text::glyph_rasterizer::{DEFAULT_LINE_HEIGHT_RATIO, GlyphRasterizer, LineGlyphs}; +use crate::text::glyph_rasterizer::{DEFAULT_LINE_HEIGHT_RATIO, GlyphRasterizer}; use crate::text::instance::{CellInstance, CellRenderOpts, Theme}; use crate::text::pipeline::{GlobalsUbo, TextPipeline}; +use crate::text::row_shaper::RowShaper; /// True when a placement that actually draws sits in the kitty /// `z < INT32_MIN/2` band, which draws under non-default cell @@ -87,35 +88,16 @@ fn below_bg_forces_full_redraw(active: bool, drawn_last: &mut bool) -> bool { } /// Post-process the full-build cell instances: drop the trailing cursor -/// instance when the cursor is hidden, and, while a below-cell-bg image -/// is on screen, the default-bg quads it must show through. -fn finish_full_build( - instances: &mut Vec, - cursor_visible: bool, - below_bg_active: bool, - default_bg: [f32; 4], -) { +/// instance when the cursor is hidden. +/// +/// While a `z < INT32_MIN/2` image is on screen the builder runs with +/// [`CellRenderOpts::skip_default_bg`], so that image (drawn before the +/// bg pass) shows through default-bg cells while non-default backgrounds +/// (SGR colors, reverse video, selection) still cover it (SIZE-9). +fn finish_full_build(instances: &mut Vec, cursor_visible: bool) { if !cursor_visible { instances.pop(); } - if below_bg_active { - drop_default_bg_quads(instances, default_bg); - } -} - -/// Drop the cell-background quads painted in the default background -/// color, so `z < INT32_MIN/2` images drawn before the bg pass show -/// through default-bg cells while non-default backgrounds (SGR colors, -/// reverse video, selection) still cover them. -/// -/// Only valid on a full-clear frame: the clear already paints the -/// default background (premultiplied, so a transparent theme bg stays -/// transparent), which makes these quads redundant everywhere else. -/// Cursor, decoration and cover quads carry extra flags and are kept. -// Exact compare is intended: default-bg quads carry `theme.bg` verbatim. -#[allow(clippy::float_cmp)] -fn drop_default_bg_quads(instances: &mut Vec, default_bg: [f32; 4]) { - instances.retain(|i| i.flags != crate::text::instance::FLAG_NO_GLYPH || i.bg != default_bg); } /// Append instances for `term` into `out`. The closure pulls glyph @@ -129,7 +111,7 @@ fn build_term_instances_into( cell_size: (f32, f32), theme: &Theme, ext_palette: &[[f32; 4]; 256], - row_glyphs: &[Option], + row_glyphs: &RowShaper, bleed: EdgeBleed<'_>, content_h: f32, opts: &CellRenderOpts, @@ -140,10 +122,7 @@ fn build_term_instances_into( cell_size, theme, Some(ext_palette), - |row, col, ch, _style| { - let lg = row_glyphs.get(row as usize)?.as_ref()?; - lg.get(col, ch) - }, + |row, col, ch, _style| row_glyphs.glyph(row, col, ch), |line_id, col| term.is_cell_selected(line_id, col), bleed, content_h, @@ -162,7 +141,7 @@ fn build_term_dirty_instances_into( theme: &Theme, ext_palette: &[[f32; 4]; 256], cursor_visible: bool, - row_glyphs: &[Option], + row_glyphs: &RowShaper, bleed: EdgeBleed<'_>, opts: &CellRenderOpts, ) { @@ -174,10 +153,7 @@ fn build_term_dirty_instances_into( theme, Some(ext_palette), cursor_visible, - |row, col, ch, _style| { - let lg = row_glyphs.get(row as usize)?.as_ref()?; - lg.get(col, ch) - }, + |row, col, ch, _style| row_glyphs.glyph(row, col, ch), |line_id, col| term.is_cell_selected(line_id, col), bleed, opts, @@ -908,28 +884,11 @@ struct TextState { rasterizer: GlyphRasterizer, pipeline: TextPipeline, bind_group: wgpu::BindGroup, - /// Row-shape cache. `line_cache[r]` holds the shaped glyphs for row - /// `r` of the active grid; `None` slots are re-shaped on the next - /// frame. Sized to match the term's current visible-row count; - /// resized in `render_term` on geometry change. - /// - /// This is the minimum-viable subset of decision #7 / M9 needed to - /// kill the per-keystroke render cost — shape only dirty rows, reuse - /// cached glyphs for the rest. - line_cache: Vec>, - /// `row_blink[r]` is true when rendered row `r` holds a visible - /// SGR-blink cell. Parallel to `line_cache` and refreshed for the - /// same (dirty) rows, so finding blinking text costs nothing on - /// clean frames. Drives [`TextBlink`]. - row_blink: Vec, + /// Row-shape cache + the per-frame shaping loop. + rows: RowShaper, /// Reusable instance buffer. Cleared (not freed) at the start of /// every `render_term` so the allocation survives across frames. instances_scratch: Vec, - /// Reusable per-row text buffer. `render_term` clears + repopulates - /// this for each dirty row instead of allocating a fresh `String`, - /// which previously showed up in profile traces as per-row alloc - /// churn (one allocation per dirty row, every frame). - line_text_scratch: String, } impl std::fmt::Debug for TextState { @@ -1262,10 +1221,8 @@ impl Renderer { rasterizer, pipeline, bind_group, - line_cache: Vec::new(), - row_blink: Vec::new(), + rows: RowShaper::new(), instances_scratch: Vec::new(), - line_text_scratch: String::new(), }); // Lazy-init the image pipeline alongside the text pipeline so // both are ready before the first `render_term`. The image @@ -1308,7 +1265,7 @@ impl Renderer { }; text.rasterizer.set_font_size(font_size_px, line_height); // Cached rows hold atlas slots from the old packer: re-shape all. - text.line_cache.clear(); + text.rows.invalidate(); self.needs_full_clear = true; } @@ -2180,7 +2137,12 @@ impl Renderer { if rich_active != self.rich_drawn_last { self.needs_full_clear = true; } - let mut rich_suppress = vec![false; usize::from(term.size().0) + 2]; + // Rows whose text is suppressed for a rich slice. Left empty + // (no allocation) on the common frame without rich blocks. + let mut rich_suppress = Vec::new(); + if rich_active { + rich_suppress.resize(usize::from(term.size().0) + 2, false); + } for d in &rich_draws { if let Some(slot) = rich_suppress.get_mut(usize::from(d.0)) { *slot = true; @@ -2319,7 +2281,7 @@ impl Renderer { if text_blink_due { self.text_blink.tick(now); if let Some(text) = self.text.as_ref() { - mark_blinking_cells_dirty(term, &text.row_blink); + mark_blinking_cells_dirty(term, text.rows.row_blink()); } } // If the term has blink disabled, the cursor must always be @@ -2352,19 +2314,6 @@ impl Renderer { cell_size = text.rasterizer.cell_size(); atlas_dims = text.rasterizer.atlas_dims(); - // Resize the row-shape cache. We allocate two extra slots - // beyond `rows`: one for the partial *top* row during sub-pixel - // scrolling, one for the partial *bottom* row when the content - // area isn't a whole number of cells tall. Sizing for the worst - // case avoids reallocation churn as the scroll offset crosses - // cell boundaries. Growth is dirty (new entries are `None`); - // shrinking just drops old slots. - let cache_len = rows as usize + 2; - if text.line_cache.len() != cache_len { - text.line_cache.resize(cache_len, None); - } - text.row_blink.resize(cache_len, false); - // Number of rows to render this frame: content rows, plus the // partial top row (sub-row pixel offset) and the trailing // partial bottom row (floor-divided grid leftover). See @@ -2376,81 +2325,25 @@ impl Renderer { content_dims.1, ); - // Re-shape only dirty rows; reuse cached `LineGlyphs` for - // the rest. The atlas itself never shrinks, so a clean row's - // glyph slots stay valid across frames. - let damage = term.damage(); - let mut shaped = 0u32; let t_shape = if trace { Some(std::time::Instant::now()) } else { None }; - for r in 0..rows_rendered { - let is_dirty = damage.all - || damage.rows.get(r as usize).is_some_and(|rd| !rd.is_empty()) - || text.line_cache[r as usize].is_none(); - if !is_dirty { - continue; - } - let row = term.view_row(r); - text.row_blink[r as usize] = crate::text::instance::row_has_blink(row); - // Reuse the cross-row scratch String instead of - // allocating a fresh one per dirty row. - // - // Continuation cells are excluded: they're the second - // half of a width-2 cluster whose primary cell already - // contributes its full multi-cell glyph to the shaper. - // Feeding the continuation in as a space would insert - // an extra glyph cosmic-text would shape, shifting every - // downstream cluster's snapped column by one. - text.line_text_scratch.clear(); - // A row showing a rich-block slice draws the image - // instead of its text glyphs (the cells keep the text - // for selection / copy / the evicted fallback). - let suppress = rich_suppress.get(r as usize).copied().unwrap_or(false); - for c in row.cells() { - if c.is_continuation { - continue; - } - if suppress { - text.line_text_scratch.push(' '); - continue; - } - // Replace Kitty Unicode placeholder cells - // (U+10EEEE) with a space before shaping. The - // codepoint shapes to `.notdef` and its - // cluster width can throw off the snap of - // neighboring chars — we don't want it in the - // glyph cache and the image pipeline draws - // the real pixels anyway. - let ch = if c.ch == '\0' || c.ch == toastty_term::PLACEHOLDER { - ' ' - } else { - c.ch - }; - text.line_text_scratch.push(ch); - // The rest of a multi-codepoint grapheme (combining - // marks, ZWJ tail, VS16) so it shapes as one cluster; - // its extra glyphs come back as secondary glyphs of - // this cell (see `instance::secondary_glyph_key`). - if c.extra.is_some() && ch != ' ' { - text.line_text_scratch.push_str(term.grapheme_tail(c)); - } - } - let lg = text.rasterizer.shape_line( - &self.queue, - &text.line_text_scratch, - term.grapheme_cluster_mode(), - ); - text.line_cache[r as usize] = Some(lg); - shaped += 1; - } + let shaped = text.rows.shape_rows( + &mut text.rasterizer, + &self.queue, + term, + rows_rendered, + &rich_suppress, + ); if let Some(t) = t_shape { let ms = t.elapsed().as_secs_f64() * 1000.0; tracing::info!(target: "render_trace", "shape rows={shaped} took={ms:.3}ms"); } - any_blink = text.row_blink[..rows_rendered as usize].iter().any(|b| *b); + any_blink = text.rows.row_blink()[..rows_rendered as usize] + .iter() + .any(|b| *b); // make_bind_group used to be rebuilt every frame "in case // the atlas textures grew". They don't grow inside the // render path — `queue.write_texture` makes uploads visible @@ -2595,6 +2488,9 @@ impl Renderer { let cell_opts = crate::text::instance::CellRenderOpts { blink_on: self.text_blink.blink_on(), decor: Some(text.rasterizer.decoration_metrics()), + // Only consulted by the full builder, which only runs on + // `LoadOp::Clear(theme.bg)` frames. + skip_default_bg: crate::text::instance::may_skip_default_bg(&bleed, below_bg_active), }; let t_bi = if trace { Some(std::time::Instant::now()) @@ -2611,12 +2507,12 @@ impl Renderer { cell_size, &theme, ext_palette, - &text.line_cache, + &text.rows, bleed, content_dims.1, &cell_opts, ); - finish_full_build(&mut instances, cursor_visible, below_bg_active, theme.bg); + finish_full_build(&mut instances, cursor_visible); } else { build_term_dirty_instances_into( &mut instances, @@ -2625,7 +2521,7 @@ impl Renderer { &theme, ext_palette, cursor_visible, - &text.line_cache, + &text.rows, bleed, &cell_opts, ); @@ -4044,93 +3940,25 @@ mod below_cell_bg_tests { assert!(!below_bg_forces_full_redraw(false, &mut drawn_last)); } - /// `finish_full_build` (what `render_term` calls on the full path): - /// default-bg quads go only with the flag on; the hidden cursor is - /// popped either way. + /// The hidden cursor (the full build's last instance) is popped. #[test] - fn finish_full_build_drops_default_bg_only_when_below_bg_active() { - use crate::text::instance::{FLAG_CURSOR, FLAG_NO_GLYPH, Theme, build_instances}; + fn finish_full_build_pops_hidden_cursor() { + use crate::text::instance::{FLAG_CURSOR, Theme, build_instances}; let theme = Theme::default_dark(); let mut term = Term::new(1, 6, 0); let mut parser = toastty_parser::Parser::new(); parser.advance(&mut term, b"ab\x1b[41mcd\x1b[0m"); let built = build_instances(&term, (10.0, 20.0), &theme, None, |_, _, _, _| None); - let bg_quads = |v: &[CellInstance]| v.iter().filter(|i| i.flags == FLAG_NO_GLYPH).count(); let has_cursor = |v: &[CellInstance]| v.iter().any(|i| i.flags & FLAG_CURSOR != 0); - assert_eq!(bg_quads(&built), 4); let mut v = built.clone(); - finish_full_build(&mut v, true, false, theme.bg); - assert_eq!((bg_quads(&v), has_cursor(&v)), (4, true)); + finish_full_build(&mut v, true); + assert_eq!(v, built); + assert!(has_cursor(&v)); let mut v = built.clone(); - finish_full_build(&mut v, true, true, theme.bg); - assert_eq!((bg_quads(&v), has_cursor(&v)), (2, true), "only red stays"); - - let mut v = built.clone(); - finish_full_build(&mut v, false, false, theme.bg); - assert_eq!((bg_quads(&v), has_cursor(&v)), (4, false)); - - let mut v = built; - finish_full_build(&mut v, false, true, theme.bg); - assert_eq!((bg_quads(&v), has_cursor(&v)), (2, false)); - } - - /// SIZE-9: default-bg cell quads are dropped (the image shows - /// through), while SGR backgrounds, reverse video, selection and the - /// cursor keep their quads (they cover the image). - #[test] - fn drop_default_bg_quads_keeps_non_default_backgrounds() { - use crate::text::instance::{ - EdgeBleed, FLAG_CURSOR, FLAG_NO_GLYPH, Theme, build_instances_into, - }; - let theme = Theme::default_dark(); - let mut term = Term::new(1, 10, 0); - let mut parser = toastty_parser::Parser::new(); - // cols 0-1 default text, 2-3 red bg, 4-5 reverse, 6 blank, 7-8 - // selected blanks, cursor parked at col 9. - parser.advance(&mut term, b"ab\x1b[41mcd\x1b[0m\x1b[7mef\x1b[0m\x1b[1;10H"); - let mut v = Vec::new(); - build_instances_into( - &mut v, - &term, - (10.0, 20.0), - &theme, - None, - |_, _, _, _| None, - |_, c| c == 7 || c == 8, - EdgeBleed::default(), - 20.0, - ); - let bg_cols = |v: &[CellInstance]| -> Vec { - v.iter() - .filter(|i| i.flags == FLAG_NO_GLYPH) - .map(|i| (i.pos[0] / 10.0) as u32) - .collect() - }; - assert_eq!(bg_cols(&v), vec![0, 1, 2, 3, 4, 5, 7, 8]); - - drop_default_bg_quads(&mut v, theme.bg); - assert_eq!(bg_cols(&v), vec![2, 3, 4, 5, 7, 8]); - assert!( - v.iter().any(|i| i.flags & FLAG_CURSOR != 0), - "cursor quad is kept" - ); - } - - /// A transparent theme bg is still matched exactly, so its default - /// cells are dropped too (the clear paints the same premultiplied - /// color), while an opaque explicit color is kept. - #[test] - fn drop_default_bg_quads_matches_transparent_default() { - use crate::text::instance::CellInstance; - let default_bg = [0.1, 0.1, 0.1, 0.5]; - let mut v = vec![ - CellInstance::background([0.0, 0.0], [10.0, 20.0], default_bg), - CellInstance::background([10.0, 0.0], [10.0, 20.0], [0.1, 0.1, 0.1, 1.0]), - ]; - drop_default_bg_quads(&mut v, default_bg); - assert_eq!(v.len(), 1); - assert!((v[0].pos[0] - 10.0).abs() < 1e-6); + finish_full_build(&mut v, false); + assert_eq!(v.len(), built.len() - 1); + assert!(!has_cursor(&v)); } } diff --git a/crates/toastty-render/src/text/glyph_rasterizer.rs b/crates/toastty-render/src/text/glyph_rasterizer.rs index 849a39ea..42d40b59 100644 --- a/crates/toastty-render/src/text/glyph_rasterizer.rs +++ b/crates/toastty-render/src/text/glyph_rasterizer.rs @@ -12,6 +12,8 @@ use cosmic_text::{ SwashContent, Weight, fontdb, }; use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::sync::Arc; use toastty_protocols::unicode_core::cluster_cell_width; use unicode_width::UnicodeWidthChar; use wgpu::{Device, Extent3d, Queue, Texture, TextureFormat}; @@ -28,6 +30,37 @@ use crate::text::presentation; /// char so a stale cache entry (e.g. wrong-char lookup) misses cleanly. type ColumnSlot = Option<(char, GlyphSlot)>; +/// Chars below this code point get a dense entry in +/// [`GlyphRasterizer::fast_chars`]: Latin, Greek, Cyrillic, general +/// punctuation, arrows, box drawing, block elements and Braille (which +/// btop-style graphs are made of). Everything above falls back to the +/// hash lookups. +const FAST_CHARS_LEN: usize = 0x3000; + +/// What the shaping fast path does with one char. A dense mirror of +/// `char_cache` / `char_cache_misses` / `uncacheable_chars` (plus the +/// width-1 test) so the per-char cost is one indexed load instead of up +/// to three `SipHash` lookups. Recomputed by +/// [`GlyphRasterizer::sync_fast_char`] whenever one of those sets +/// changes, so the sets stay the source of truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FastChar { + /// Leave the fast path (not width 1, uncacheable, or not cached yet). + Bail, + /// No glyph: blank, NUL, or a recorded cache miss. + Skip, + /// Cached; index into [`GlyphRasterizer::fast_slots`]. + Cached(u32), +} + +/// Memoised shape of one row text; see [`GlyphRasterizer::shape_line_memo`]. +#[derive(Debug)] +struct MemoEntry { + glyphs: Arc, + /// [`GlyphRasterizer::memo_frame`] of the last lookup that hit it. + last_used: u64, +} + /// Mask atlas dims (R8) and color atlas dims (BGRA8) — generously sized /// per the M4b "panic when full" policy. pub const ATLAS_W: u32 = 1024; @@ -321,6 +354,25 @@ pub struct GlyphRasterizer { /// `font.family` config didn't take effect. Surfaced via /// [`GlyphRasterizer::requested_family_available`]. requested_family_available: bool, + /// Dense fast-path state for chars below [`FAST_CHARS_LEN`]; see + /// [`FastChar`]. + fast_chars: Box<[FastChar]>, + /// Slots the [`FastChar::Cached`] entries point at. Append-only + /// between [`Self::rebuild_fast_chars`] calls. + fast_slots: Vec, + /// Shaped rows keyed by their exact text, one map per mode-2027 + /// state. Lets a row whose text was shaped recently (it scrolled, + /// or was repainted in new colours) skip shaping and the + /// `LineGlyphs` allocation. Cleared whenever an earlier shape could + /// go stale: a char turning uncacheable, an atlas eviction, a size + /// change. Trimmed by [`Self::trim_line_memo`]. + line_memo: [HashMap, MemoEntry>; 2], + /// Frame counter for [`MemoEntry::last_used`], advanced by + /// [`Self::trim_line_memo`]. + memo_frame: u64, + /// Set when [`Self::ensure_atlas_slot`] returned `None` (no atlas + /// room, no image); [`Self::shape_line_memo`] then skips memoising. + slot_failed: bool, } impl GlyphRasterizer { @@ -440,6 +492,11 @@ impl GlyphRasterizer { presentation_override: HashMap::new(), vs16_color_override: HashMap::new(), requested_family_available, + fast_chars: initial_fast_chars(), + fast_slots: Vec::new(), + line_memo: [HashMap::new(), HashMap::new()], + memo_frame: 0, + slot_failed: false, } } @@ -482,6 +539,90 @@ impl GlyphRasterizer { self.atlas = Atlas::new(ATLAS_W, ATLAS_H); self.placements.clear(); self.char_cache.clear(); + self.rebuild_fast_chars(); + self.clear_line_memo(); + } + + /// Recompute `fast_chars[ch]` from the char sets, with the same + /// precedence the fast path used to apply to them one by one: + /// blank → width ≠ 1 → uncacheable → miss → cached → unknown. + fn sync_fast_char(&mut self, ch: char) { + let Some(i) = fast_char_index(ch) else { + return; + }; + self.fast_chars[i] = if ch == ' ' || ch == '\0' { + FastChar::Skip + } else if ch.width() != Some(1) || self.uncacheable_chars.contains(&ch) { + FastChar::Bail + } else if self.char_cache_misses.contains(&ch) { + FastChar::Skip + } else if let Some(slot) = self.char_cache.get(&ch) { + #[allow(clippy::cast_possible_truncation)] // < one entry per cache insert + let idx = self.fast_slots.len() as u32; + self.fast_slots.push(*slot); + FastChar::Cached(idx) + } else { + FastChar::Bail + }; + } + + /// Rebuild the whole [`Self::fast_chars`] table (after `char_cache` + /// was cleared). + fn rebuild_fast_chars(&mut self) { + self.fast_chars = initial_fast_chars(); + self.fast_slots.clear(); + let chars: Vec = self + .char_cache + .keys() + .chain(self.char_cache_misses.iter()) + .chain(self.uncacheable_chars.iter()) + .copied() + .collect(); + for ch in chars { + self.sync_fast_char(ch); + } + } + + /// Cache `slot` as the standalone glyph of `ch`, unless one is + /// cached already. + fn cache_standalone(&mut self, ch: char, slot: GlyphSlot) { + if let Entry::Vacant(e) = self.char_cache.entry(ch) { + e.insert(slot); + self.sync_fast_char(ch); + } + } + + /// Record `ch` as having no glyph. Returns true if it was new. + fn mark_cache_miss(&mut self, ch: char) -> bool { + let new = self.char_cache_misses.insert(ch); + if new { + self.sync_fast_char(ch); + // Same rule as `mark_uncacheable`: a char-cache change drops + // the memoised rows rather than reasoning about which could + // have shaped differently. + self.clear_line_memo(); + } + new + } + + /// Record `ch` as contextually shaped (see `uncacheable_chars`) and + /// evict its `char_cache` entry. Returns true if it was new. Rows + /// shaped earlier may have served the old standalone glyph for it, + /// so the line memo is dropped too. + fn mark_uncacheable(&mut self, ch: char) -> bool { + let new = self.uncacheable_chars.insert(ch); + if new { + self.char_cache.remove(&ch); + self.sync_fast_char(ch); + self.clear_line_memo(); + } + new + } + + fn clear_line_memo(&mut self) { + for m in &mut self.line_memo { + m.clear(); + } } /// Glyph id this char would produce shaped alone, with no @@ -794,54 +935,139 @@ impl GlyphRasterizer { /// uncacheable, or any non-single-column character). When it /// returns `Some`, no cosmic-text work was done. fn try_shape_line_fast(&self, text: &str) -> Option { - let mut out = LineGlyphs::default(); let cell_w = self.cell_size.0; if cell_w <= 0.0 { return None; } + // Trailing blanks never produce a slot and never bail, so only + // the trimmed prefix decides the result; this also sizes + // `by_column` to the populated range up front instead of growing + // it slot by slot. + let text = text.trim_end_matches([' ', '\0']); + let mut by_column: Vec = Vec::with_capacity(text.len()); // Each character in a monospace terminal grid occupies one // cell. We assign columns by index in the iteration order; the // term's row text is fed cell-by-cell, so the i'th char is // column i. This breaks for wide / zero-width characters // (CJK ideographs, combining marks, controls): a wide char // would span two columns, and a combining mark would consume a - // column index without advancing the cell. Bail to the slow - // path on any char whose `unicode_width` is not exactly 1. + // column index without advancing the cell. Such chars bail to + // the slow path (`FastChar::Bail`, or the `width` test in + // `fast_lookup_hashed`). for (col, ch) in text.chars().enumerate() { if col > u16::MAX as usize { return None; } - if ch == ' ' || ch == '\0' { - continue; - } - // Width-1 invariant — see `char_cache` doc comment. - if ch.width() != Some(1) { - return None; - } - // Char has been observed shaping to a contextual glyph - // (ligature / GSUB substitution). We can't serve it from - // `char_cache` — the cached single-char form might be the - // wrong half of a ligature, or the line we're rendering - // *now* might want a contextual form. Bail so the slow - // path does the right shaping each frame. - if self.uncacheable_chars.contains(&ch) { - return None; - } - // Already-known miss: skip the cell, but keep going on - // the fast path. This stops e.g. control characters from - // forcing every line through cosmic-text forever. - if self.char_cache_misses.contains(&ch) { + let slot = match fast_char_index(ch) { + Some(i) => match self.fast_chars[i] { + FastChar::Bail => return None, + FastChar::Skip => None, + FastChar::Cached(idx) => Some(self.fast_slots[idx as usize]), + }, + None => self.fast_lookup_hashed(ch)?, + }; + by_column.push(slot.map(|s| (ch, s))); + } + // `text` ends in a non-blank char, so the last column is either a + // slot or a skipped miss; drop trailing misses to match + // `LineGlyphs::insert`'s "grown only as far as the highest column + // shaped". + while by_column.last().is_some_and(Option::is_none) { + by_column.pop(); + } + Some(LineGlyphs { + by_column, + secondary: Vec::new(), + }) + } + + /// Fast-path verdict for a char outside the dense `fast_chars` + /// table: `None` = bail to the slow path, `Some(None)` = no glyph, + /// `Some(Some(slot))` = cached. + #[allow(clippy::option_option)] // three outcomes, read in one `?` + fn fast_lookup_hashed(&self, ch: char) -> Option> { + // Width-1 invariant — see `char_cache` doc comment. + if ch.width() != Some(1) { + return None; + } + // Char has been observed shaping to a contextual glyph + // (ligature / GSUB substitution). We can't serve it from + // `char_cache` — the cached single-char form might be the + // wrong half of a ligature, or the line we're rendering + // *now* might want a contextual form. Bail so the slow + // path does the right shaping each frame. + if self.uncacheable_chars.contains(&ch) { + return None; + } + // Already-known miss: skip the cell, but keep going on + // the fast path. This stops e.g. control characters from + // forcing every line through cosmic-text forever. + if self.char_cache_misses.contains(&ch) { + return Some(None); + } + self.char_cache.get(&ch).map(|slot| Some(*slot)) + } + + /// [`Self::shape_line`] through the row-text memo: a row whose exact + /// text (and mode-2027 state) was shaped recently gets the same + /// shared `LineGlyphs` back without shaping. Scrolling and + /// full-screen repaints in new colours re-dirty rows whose text + /// hasn't changed; for rows that need cosmic-text (CJK, emoji, + /// ligatures) this skips ~100 µs of layout per row. + /// + /// Call [`Self::trim_line_memo`] once per frame to bound it. + pub fn shape_line_memo( + &mut self, + queue: &Queue, + text: &str, + mode_2027_active: bool, + ) -> Arc { + let frame = self.memo_frame; + let m = usize::from(mode_2027_active); + if let Some(e) = self.line_memo[m].get_mut(text) { + e.last_used = frame; + return Arc::clone(&e.glyphs); + } + self.slot_failed = false; + let glyphs = Arc::new(self.shape_line(queue, text, mode_2027_active)); + // A glyph that got no atlas slot (atlas full even after evicting) + // left its cell blank: don't memoise that, so the row is shaped + // again next time it is dirty, as without the memo. + if self.slot_failed { + return glyphs; + } + // Shaping may have cleared the memo (a char turned uncacheable, + // an atlas shelf was evicted); this fresh shape is still valid. + self.line_memo[m].insert( + text.into(), + MemoEntry { + glyphs: Arc::clone(&glyphs), + last_used: frame, + }, + ); + glyphs + } + + /// End-of-frame bookkeeping for [`Self::shape_line_memo`]: advance + /// the frame counter and, once a memo map holds more than + /// `2 * keep` rows, drop all but the `keep` most recently used. + pub fn trim_line_memo(&mut self, keep: usize) { + self.memo_frame += 1; + for m in &mut self.line_memo { + if m.len() <= keep.saturating_mul(2) { continue; } - match self.char_cache.get(&ch) { - Some(slot) => { - #[allow(clippy::cast_possible_truncation)] - out.insert(col as u16, ch, *slot); - } - None => return None, - } + let mut stamps: Vec = m.values().map(|e| e.last_used).collect(); + let cut = stamps.len() - keep; + let cutoff = *stamps.select_nth_unstable(cut).1; + m.retain(|_, e| e.last_used >= cutoff); } - Some(out) + } + + /// Rows currently memoised (both mode-2027 states). + #[must_use] + pub fn line_memo_len(&self) -> usize { + self.line_memo.iter().map(HashMap::len).sum() } /// Slow path: cosmic-text shaping. Populates `char_cache` / @@ -998,12 +1224,11 @@ impl GlyphRasterizer { if p.cluster_chars.len() == 1 && p.ch.width() == Some(1) { let standalone = self.standalone_glyph_id_of(p.ch); if standalone == Some(p.glyph.glyph_id) { - self.char_cache.entry(p.ch).or_insert(slot); - } else if self.uncacheable_chars.insert(p.ch) { - // Already-cached entries from earlier - // standalone-only renders would now be wrong - // (the font wants to substitute), so evict. - self.char_cache.remove(&p.ch); + self.cache_standalone(p.ch, slot); + } else if self.mark_uncacheable(p.ch) { + // Earlier standalone-only cache entries would now + // be wrong (the font wants to substitute), so + // `mark_uncacheable` evicted them. tracing::debug!( target: "toastty_render::glyph", "marking {:?} (U+{:04X}) as contextually-shaped; \ @@ -1021,8 +1246,7 @@ impl GlyphRasterizer { // the ligature visual. Force the slow path on any // line containing these chars from now on. for ch in &p.cluster_chars { - if self.uncacheable_chars.insert(*ch) { - self.char_cache.remove(ch); + if self.mark_uncacheable(*ch) { tracing::debug!( target: "toastty_render::glyph", "marking {:?} (U+{:04X}) as multi-char-cluster member; \ @@ -1046,7 +1270,7 @@ impl GlyphRasterizer { if ch == ' ' || ch == '\0' { continue; } - if !produced_chars.contains(&ch) && self.char_cache_misses.insert(ch) { + if !produced_chars.contains(&ch) && self.mark_cache_miss(ch) { tracing::debug!( target: "toastty_render::glyph", "marking {ch:?} (U+{:04X}) as cache miss; future renders will skip it", @@ -1065,6 +1289,13 @@ impl GlyphRasterizer { /// [`Self::baseline_y`] to convert swash's bearing into a cell-relative /// offset — never a per-line value (see that field's docs for why). fn ensure_atlas_slot(&mut self, queue: &Queue, glyph: &LayoutGlyph) -> Option { + let slot = self.ensure_atlas_slot_inner(queue, glyph); + self.slot_failed |= slot.is_none(); + slot + } + + /// [`Self::ensure_atlas_slot`] without the failure bookkeeping. + fn ensure_atlas_slot_inner(&mut self, queue: &Queue, glyph: &LayoutGlyph) -> Option { let baseline_y = self.baseline_y; let physical = glyph.physical((0.0, 0.0), 1.0); let cache_key: CacheKey = physical.cache_key; @@ -1144,6 +1375,8 @@ impl GlyphRasterizer { "atlas {layer:?} full — evicting LRU shelf to make room for {w}x{h} glyph", ); self.atlas.evict_oldest_shelf(layer); + // Memoised rows may point at the evicted shelf. + self.clear_line_memo(); match self.atlas.reserve(key, layer, w, h) { Ok(s) => s, Err(e) => { @@ -1180,6 +1413,22 @@ impl GlyphRasterizer { } } +/// `fast_chars` index of `ch`, if it has one. +#[inline] +fn fast_char_index(ch: char) -> Option { + let i = ch as usize; + (i < FAST_CHARS_LEN).then_some(i) +} + +/// `fast_chars` with nothing cached: blanks skip, everything else bails +/// to the slow path until it is cached. +fn initial_fast_chars() -> Box<[FastChar]> { + let mut t = vec![FastChar::Bail; FAST_CHARS_LEN].into_boxed_slice(); + t[' ' as usize] = FastChar::Skip; + t[0] = FastChar::Skip; + t +} + fn stable_key(k: CacheKey) -> u64 { // font_id is wrapper around u32; glyph_id u16; font_size as bits; // pack a few fields into u64. @@ -1858,8 +2107,7 @@ mod tests { // Simulate the slow path having observed `=` shaping to a // contextual variant (the half-ligature case). Future fast- // path attempts on any line containing `=` must bail. - rasterizer.uncacheable_chars.insert('='); - rasterizer.char_cache.remove(&'='); + rasterizer.mark_uncacheable('='); assert!( rasterizer.try_shape_line_fast("ab=").is_none(), @@ -2323,4 +2571,189 @@ mod tests { assert!(rasterizer.vs16_color_override.contains_key(&heart)); let _ = rasterizer.shape_line(&queue, "\u{2764}\u{FE0F}", true); } + + /// The fast path as it was before the dense `fast_chars` table: one + /// hash lookup per set, slots inserted column by column. Kept as the + /// oracle for `fast_table_matches_hashed_reference`. + fn reference_fast(r: &GlyphRasterizer, text: &str) -> Option { + let mut out = LineGlyphs::default(); + for (col, ch) in text.chars().enumerate() { + if ch == ' ' || ch == '\0' { + continue; + } + if ch.width() != Some(1) || r.uncacheable_chars.contains(&ch) { + return None; + } + if r.char_cache_misses.contains(&ch) { + continue; + } + #[allow(clippy::cast_possible_truncation)] + out.insert(col as u16, ch, *r.char_cache.get(&ch)?); + } + Some(out) + } + + /// The dense table gives exactly the old hashed fast path's answer + /// (bail vs. not, and the same slots in the same columns), and stays + /// in sync with the char sets as shaping adds entries, misses and + /// uncacheable chars. + #[test] + fn fast_table_matches_hashed_reference() { + let (_device, queue, mut rasterizer) = make_rasterizer(); + let warm = [ + "The quick brown fox jumps over the lazy dog 0123456789", + "│ ├── src/main.rs ┼ ═ ░▒▓ ⣿⡇ → ← … «» ¿¡ ñ é ü Ω λ Ж", + "tab\there \u{200B}zero width \u{7}bell", + "a != b => c -> d", + "你好 wide 😀 emoji", + ]; + for line in warm { + let _ = rasterizer.shape_line(&queue, line, false); + } + // Force one ASCII char uncacheable to cover that state. + rasterizer.mark_uncacheable('q'); + let probes = [ + "", + " ", + "hello world ", + "hello\0world\0\0", + "The lazy dog", + "The quick dog", + "│ ├── ┼ ═ ░▒▓ ⣿⡇ → …", + "zero\u{200B}width", + "bell\u{7}x", + "你好", + "Ω λ Ж ñ é ü trailing ", + "never seen: ʬ ʭ", + "trailing miss\u{200B}", + ]; + for probe in probes { + let got = rasterizer.try_shape_line_fast(probe); + let want = reference_fast(&rasterizer, probe); + assert_eq!( + got.as_ref().map(|g| (&g.by_column, &g.secondary)), + want.as_ref().map(|g| (&g.by_column, &g.secondary)), + "fast path disagrees with the hashed reference on {probe:?}", + ); + } + // Table invariant: every dense entry equals what the sets say. + for i in 0..FAST_CHARS_LEN { + let Some(ch) = char::from_u32(u32::try_from(i).unwrap()) else { + continue; + }; + let want = reference_fast(&rasterizer, &ch.to_string()).map(|g| g.by_column); + let got = rasterizer + .try_shape_line_fast(&ch.to_string()) + .map(|g| g.by_column); + assert_eq!(got, want, "fast_chars out of sync for {ch:?}"); + } + } + + /// The row memo hands back the same shared shape for the same text + /// and mode, keeps the two modes apart, is dropped when a char turns + /// uncacheable, and is bounded by `trim_line_memo`. + #[test] + fn line_memo_reuses_and_invalidates() { + let (_device, queue, mut rasterizer) = make_rasterizer(); + let a = rasterizer.shape_line_memo(&queue, "memo 你好 row", false); + let b = rasterizer.shape_line_memo(&queue, "memo 你好 row", false); + assert!(Arc::ptr_eq(&a, &b), "same text must hit the memo"); + let fresh = rasterizer.shape_line(&queue, "memo 你好 row", false); + assert_eq!(a.by_column, fresh.by_column); + assert_eq!(a.secondary, fresh.secondary); + + let c = rasterizer.shape_line_memo(&queue, "memo 你好 row", true); + assert!(!Arc::ptr_eq(&a, &c), "mode 2027 has its own memo"); + + rasterizer.mark_uncacheable('m'); + assert_eq!( + rasterizer.line_memo_len(), + 0, + "uncacheable char drops the memo" + ); + let d = rasterizer.shape_line_memo(&queue, "memo 你好 row", false); + assert!(!Arc::ptr_eq(&a, &d)); + + for i in 0..50 { + let _ = rasterizer.shape_line_memo(&queue, &format!("row {i}"), false); + rasterizer.trim_line_memo(8); + } + assert!(rasterizer.line_memo_len() <= 2 * 8 + 1); + // The most recent rows survive a trim. + let last = rasterizer.shape_line_memo(&queue, "row 49", false); + let again = rasterizer.shape_line_memo(&queue, "row 49", false); + assert!(Arc::ptr_eq(&last, &again)); + } + + /// A new cache miss drops the memo, like a new uncacheable char. + #[test] + fn cache_miss_drops_line_memo() { + let (_device, queue, mut rasterizer) = make_rasterizer(); + let _ = rasterizer.shape_line_memo(&queue, "memo row", false); + assert_eq!(rasterizer.line_memo_len(), 1); + assert!(rasterizer.mark_cache_miss('\u{E0001}')); + assert_eq!(rasterizer.line_memo_len(), 0); + // Already known: nothing to drop. + let _ = rasterizer.shape_line_memo(&queue, "memo row", false); + assert!(!rasterizer.mark_cache_miss('\u{E0001}')); + assert_eq!(rasterizer.line_memo_len(), 1); + } + + /// A row whose glyph got no atlas slot (here: a glyph bigger than the + /// whole atlas) is not memoised, so it is shaped again next time. + #[test] + fn row_with_failed_slot_is_not_memoised() { + let (_device, queue, mut rasterizer) = make_rasterizer(); + rasterizer.set_font_size(1500.0, DEFAULT_LINE_HEIGHT_RATIO); + let lg = rasterizer.shape_line_memo(&queue, "W", false); + assert!(lg.get(0, 'W').is_none(), "glyph should not fit the atlas"); + assert_eq!(rasterizer.line_memo_len(), 0); + // A row that does fit is memoised as usual. + let _ = rasterizer.shape_line_memo(&queue, " ", false); + assert_eq!(rasterizer.line_memo_len(), 1); + } + + /// Regression (review of the dropped shape-run cache): a neutral-only + /// word shaped at RTL level must not leak its mirrored glyphs into an + /// LTR line with the same word, through any cache. The RTL line opens + /// with a Latin char so the paragraph stays LTR: an RTL paragraph is + /// right-aligned in the infinitely wide shaping buffer, and its glyph + /// x overflows cosmic-text's subpixel binning in debug builds. + #[test] + fn rtl_neutral_word_does_not_leak_into_ltr_row() { + type Cell = Option<(char, [f32; 2], [f32; 2], bool)>; + let (_device, queue, mut warm) = make_rasterizer(); + let (_device, queue2, mut fresh) = make_rasterizer(); + let rtl = + "a \u{05E9}\u{05DC}\u{05D5}\u{05DD} (( \u{05E2}\u{05D5}\u{05DC}\u{05DD} [< \u{05E9}"; + let ltr = "x (( y [<"; + let _ = warm.shape_line_memo(&queue, rtl, false); + let got = warm.shape_line_memo(&queue, ltr, false); + let want = fresh.shape_line(&queue2, ltr, false); + // Slots sit at different atlas positions in the two rasterizers; + // compare what each visible cell draws (blanks draw nothing). + let cells = |lg: &LineGlyphs| -> Vec { + lg.by_column + .iter() + .map(|c| { + c.filter(|(ch, _)| *ch != ' ') + .map(|(ch, s)| (ch, s.glyph_size, s.glyph_offset, s.is_color)) + }) + .collect() + }; + assert_eq!(cells(&got), cells(&want)); + assert!(got.get(7, '[').is_some() && got.get(8, '<').is_some()); + } + + /// `set_font_size` rebuilds the dense table: nothing stays cached at + /// the old size, so the next line goes through the slow path. + #[test] + fn set_font_size_resets_fast_table() { + let (_device, queue, mut rasterizer) = make_rasterizer(); + let _ = rasterizer.shape_line(&queue, "abc", false); + assert!(rasterizer.try_shape_line_fast("abc").is_some()); + rasterizer.set_font_size(20.0, DEFAULT_LINE_HEIGHT_RATIO); + assert!(rasterizer.try_shape_line_fast("abc").is_none()); + assert_eq!(rasterizer.line_memo_len(), 0); + } } diff --git a/crates/toastty-render/src/text/instance.rs b/crates/toastty-render/src/text/instance.rs index 0cba33ed..31c6dfbb 100644 --- a/crates/toastty-render/src/text/instance.rs +++ b/crates/toastty-render/src/text/instance.rs @@ -56,6 +56,20 @@ pub struct CellRenderOpts { /// [`DecorationMetrics::fallback`] for the cell height (tests and /// GPU-less callers). pub decor: Option, + /// Full builder only: leave out the background quads of cells whose + /// (post-selection) background is exactly `theme.bg`. Only valid when + /// the frame starts with `LoadOp::Clear(theme.bg)`, which has already + /// painted those pixels with the same premultiplied value the quad + /// would write (the bg pass blends with REPLACE). That only holds + /// while no other bg quad overlaps the cell, so callers must leave + /// this off whenever edge bleed is active: an edge cell's grown quad + /// reaches over neighbouring (partial) rows, and a default-bg quad + /// drawn after it is what paints over that bleed. On a typical text + /// screen most visible cells are on the default bg, so this roughly + /// halves the instances built and uploaded per full redraw. The + /// partial-redraw builder ignores it: under `LoadOp::Load` its bg + /// quads erase the previous frame. + pub skip_default_bg: bool, } impl Default for CellRenderOpts { @@ -63,6 +77,7 @@ impl Default for CellRenderOpts { Self { blink_on: true, decor: None, + skip_default_bg: false, } } } @@ -167,6 +182,16 @@ pub struct EdgeBleed<'a> { pub col_fills: &'a [bool], } +/// Whether a full build may use [`CellRenderOpts::skip_default_bg`]. +/// Not while edge bleed is active: a grown edge quad reaches over the +/// neighbouring (partial) rows, and the default-bg quads drawn after it +/// are what paint over that bleed. The SIZE-9 below-cell-bg frames drop +/// default-bg quads regardless (the image must show through them). +#[must_use] +pub fn may_skip_default_bg(bleed: &EdgeBleed<'_>, below_bg_active: bool) -> bool { + !bleed.active || below_bg_active +} + /// Grow an edge cell's background quad outward into the padding gutter so /// it reaches the physical window edge. /// @@ -645,7 +670,23 @@ fn derive_selection_bg(bg: [f32; 4], fg: [f32; 4]) -> [f32; 4] { } /// Convert one sRGB byte channel to linear-light float. +/// +/// Table lookup: the instance builders resolve truecolor (`Rgb`) and, +/// without an OSC 4 palette, 256-colour cells through this for both fg +/// and bg of every cell, and three `powf`s per colour was a measurable +/// share of a full-screen truecolor repaint. The table holds exactly +/// [`srgb_channel_to_linear_exact`]'s values. +#[inline] pub(crate) fn srgb_channel_to_linear(v: u8) -> f32 { + static LUT: std::sync::LazyLock<[f32; 256]> = std::sync::LazyLock::new(|| { + #[allow(clippy::cast_possible_truncation)] // i < 256 + std::array::from_fn(|i| srgb_channel_to_linear_exact(i as u8)) + }); + LUT[usize::from(v)] +} + +/// The sRGB → linear transfer function behind [`srgb_channel_to_linear`]. +fn srgb_channel_to_linear_exact(v: u8) -> f32 { let c = f32::from(v) / 255.0; if c <= 0.040_45 { c / 12.92 @@ -797,6 +838,41 @@ pub fn glyph_fg(cell: &Cell, fg: [f32; 4], bg: [f32; 4]) -> [f32; 4] { } } +/// [`glyph_fg`] for a stream of cells, remembering the last faint +/// colour. [`dim_color`] costs nine `powf`s and faint text comes in +/// runs of the same colours, so a screen of it would otherwise spend +/// milliseconds per frame recomputing one value. Keys compare bit for +/// bit, so the result is always exactly `dim_color`'s. +#[derive(Debug, Default)] +struct DimMemo(Option<([u32; 8], [f32; 4])>); + +impl DimMemo { + #[inline] + fn glyph_fg(&mut self, cell: &Cell, fg: [f32; 4], bg: [f32; 4]) -> [f32; 4] { + if !cell.style.flags.dim() { + return fg; + } + let key = [ + fg[0].to_bits(), + fg[1].to_bits(), + fg[2].to_bits(), + fg[3].to_bits(), + bg[0].to_bits(), + bg[1].to_bits(), + bg[2].to_bits(), + bg[3].to_bits(), + ]; + match self.0 { + Some((k, v)) if k == key => v, + _ => { + let v = dim_color(fg, bg); + self.0 = Some((key, v)); + v + } + } + } +} + /// Emit a cell's glyph(s) and decoration strips, shared by the full and /// partial-redraw builders so the two can't drift. /// @@ -1038,6 +1114,7 @@ pub fn build_instances_into_opts( 0.0 }; + let mut dim_memo = DimMemo::default(); let bottom_id = term.bottom_id(); let view_offset_lines = term.view_offset_lines(); let needed = usize::from(rows_rendered) * usize::from(cols); @@ -1081,7 +1158,7 @@ pub fn build_instances_into_opts( let pos = [f32::from(c) * cell_w, f32::from(r) * cell_h + y_translate]; let (fg, bg) = resolve_cell_colors(cell, theme, ext_palette); let bg = if selected { theme.selection_bg } else { bg }; - let fg = glyph_fg(cell, fg, bg); + let fg = dim_memo.glyph_fg(cell, fg, bg); // Width-2 (CJK) primaries: widen the bg quad to span both // columns so the continuation half isn't left showing the @@ -1106,29 +1183,35 @@ pub fn build_instances_into_opts( && bleed .vertical .gate(bleed.col_fills.get(c as usize).copied().unwrap_or(false)); - let (bg_pos, bg_size) = extend_edge_quad( - pos, - [bg_w, cell_h], - c, - r, - cols, - rows, - pixel_extra, - is_wide, - bleed.pad, - bleed_h, - bleed_v, - ); - out.push(CellInstance { - pos: bg_pos, - size: bg_size, - uv_min: [0.0, 0.0], - uv_max: [0.0, 0.0], - fg, - bg, - flags: FLAG_NO_GLYPH, - pad: [0; 3], - }); + // Exact compare is intended: default-bg cells carry + // `theme.bg` verbatim (see `CellRenderOpts::skip_default_bg`). + #[allow(clippy::float_cmp)] + let bg_is_clear = opts.skip_default_bg && bg == theme.bg; + if !bg_is_clear { + let (bg_pos, bg_size) = extend_edge_quad( + pos, + [bg_w, cell_h], + c, + r, + cols, + rows, + pixel_extra, + is_wide, + bleed.pad, + bleed_h, + bleed_v, + ); + out.push(CellInstance { + pos: bg_pos, + size: bg_size, + uv_min: [0.0, 0.0], + uv_max: [0.0, 0.0], + fg, + bg, + flags: FLAG_NO_GLYPH, + pad: [0; 3], + }); + } push_cell_foreground( out, @@ -1268,6 +1351,12 @@ pub fn build_dirty_instances_into_opts( // extra (blank) row is emitted. let (rows, _) = term.size(); let content_h = f32::from(rows) * cell_size.1; + // Keep every bg quad: this builder's callers may be on a + // `LoadOp::Load` frame. + let opts = CellRenderOpts { + skip_default_bg: false, + ..*opts + }; build_instances_into_opts( out, term, @@ -1278,7 +1367,7 @@ pub fn build_dirty_instances_into_opts( is_selected, bleed, content_h, - opts, + &opts, ); if !cursor_visible { // Drop the trailing cursor instance the full builder @@ -1310,6 +1399,7 @@ pub fn build_dirty_instances_into_opts( } else { 0.0 }; + let mut dim_memo = DimMemo::default(); let bottom_id = term.bottom_id(); let view_offset_lines = term.view_offset_lines(); @@ -1350,7 +1440,7 @@ pub fn build_dirty_instances_into_opts( let pos = [f32::from(c) * cell_w, f32::from(r) * cell_h + y_translate]; let (fg, bg) = resolve_cell_colors(cell, theme, ext_palette); let bg = if selected { theme.selection_bg } else { bg }; - let fg = glyph_fg(cell, fg, bg); + let fg = dim_memo.glyph_fg(cell, fg, bg); // Width-2 (CJK) primaries: widen the bg quad to span both // columns so the continuation half isn't left showing the @@ -3466,6 +3556,7 @@ mod tests { let opts = CellRenderOpts { blink_on: true, decor: Some(metrics), + skip_default_bg: false, }; let v = build_sgr(&term_with(b"\x1b[9;53mA"), &opts, false); let strike = with_flag(&v, FLAG_STRIKETHROUGH); @@ -3598,6 +3689,7 @@ mod tests { let off = CellRenderOpts { blink_on: false, decor: None, + skip_default_bg: false, }; let v = build_sgr(&t, &on, false); assert_eq!(glyphs(&v).len(), 2); @@ -3620,6 +3712,7 @@ mod tests { CellRenderOpts { blink_on: false, decor: Some(DecorationMetrics::new(16.0, 13.0, None)), + skip_default_bg: false, }, ] { let full = build_sgr(&t, &opts, false); @@ -3642,4 +3735,146 @@ mod tests { assert!(!row_has_blink(term_with(b"a\x1b[5;8mb").row(0))); assert!(!row_has_blink(term_with(b"a\x1b[5;25mb").row(0))); } + + fn build_skipping(term: &Term, theme: &Theme, selected: &[u16]) -> Vec { + let mut v = Vec::new(); + build_instances_into_opts( + &mut v, + term, + (10.0, 20.0), + theme, + None, + |_, _, _, _| None, + |_, c| selected.contains(&c), + EdgeBleed::default(), + 20.0, + &CellRenderOpts { + skip_default_bg: true, + ..CellRenderOpts::default() + }, + ); + v + } + + /// `skip_default_bg` leaves out exactly the default-bg cell quads: + /// SGR backgrounds, reverse video, selection and the cursor keep + /// theirs, and the glyph-pass instances are untouched. + #[test] + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + fn skip_default_bg_keeps_non_default_backgrounds() { + let theme = Theme::default_dark(); + let mut term = Term::new(1, 10, 0); + // cols 0-1 default text, 2-3 red bg, 4-5 reverse, 6 blank, 7-8 + // selected blanks, cursor parked at col 9. + feed(&mut term, b"ab\x1b[41mcd\x1b[0m\x1b[7mef\x1b[0m\x1b[1;10H"); + let bg_cols = |v: &[CellInstance]| -> Vec { + v.iter() + .filter(|i| i.flags == FLAG_NO_GLYPH) + .map(|i| (i.pos[0] / 10.0) as u32) + .collect() + }; + let mut all = Vec::new(); + build_instances_into( + &mut all, + &term, + (10.0, 20.0), + &theme, + None, + |_, _, _, _| None, + |_, c| c == 7 || c == 8, + EdgeBleed::default(), + 20.0, + ); + assert_eq!(bg_cols(&all), vec![0, 1, 2, 3, 4, 5, 7, 8]); + let skipped = build_skipping(&term, &theme, &[7, 8]); + assert_eq!(bg_cols(&skipped), vec![2, 3, 4, 5, 7, 8]); + assert!(skipped.iter().any(|i| i.flags & FLAG_CURSOR != 0)); + // Same instances otherwise, in the same order. + let rest = |v: &[CellInstance]| -> Vec { + v.iter() + .filter(|i| !(i.flags == FLAG_NO_GLYPH && i.bg == theme.bg)) + .copied() + .collect() + }; + assert_eq!(rest(&all), skipped); + } + + /// A transparent theme bg is matched exactly too: the clear paints + /// the same premultiplied colour the quad would. + #[test] + fn skip_default_bg_matches_transparent_default() { + let theme = Theme { + bg: [0.1, 0.1, 0.1, 0.5], + ..Theme::default_dark() + }; + let mut term = Term::new(1, 4, 0); + feed(&mut term, b"ab\x1b[48;2;1;2;3mc"); + let v = build_skipping(&term, &theme, &[]); + let bgs: Vec<_> = v.iter().filter(|i| i.flags == FLAG_NO_GLYPH).collect(); + assert_eq!(bgs.len(), 1, "only the RGB-bg cell keeps its quad"); + assert!((bgs[0].pos[0] - 20.0).abs() < 1e-6); + } + + /// The dirty builder's `damage.all` fallback keeps every bg quad even + /// when asked to skip: it may be drawing under `LoadOp::Load`. + #[test] + fn dirty_builder_ignores_skip_default_bg() { + let theme = Theme::default_dark(); + let mut term = Term::new(1, 4, 0); + feed(&mut term, b"ab"); + term.mark_all_dirty(); + let opts = CellRenderOpts { + skip_default_bg: true, + ..CellRenderOpts::default() + }; + let mut v = Vec::new(); + build_dirty_instances_into_opts( + &mut v, + &term, + term.damage(), + (10.0, 20.0), + &theme, + None, + true, + |_, _, _, _| None, + |_, _| false, + EdgeBleed::default(), + &opts, + ); + assert_eq!(v.iter().filter(|i| i.flags == FLAG_NO_GLYPH).count(), 2); + } + + #[test] + fn srgb_lut_matches_transfer_function() { + for v in 0..=255u8 { + assert_eq!( + srgb_channel_to_linear(v).to_bits(), + srgb_channel_to_linear_exact(v).to_bits(), + "channel {v}" + ); + } + } + + #[test] + fn dim_memo_matches_dim_color() { + let mut term = Term::new(1, 4, 0); + feed(&mut term, b"\x1b[2;31ma\x1b[2;32mb"); + let dim = *term.row(0).cells().first().unwrap(); + let plain = Cell::BLANK; + let mut memo = DimMemo::default(); + let red = [0.8, 0.1, 0.1, 1.0]; + let dark = [0.07, 0.07, 0.09, 1.0]; + let green = [0.1, 0.8, 0.1, 1.0]; + let pairs = [ + (red, dark), + (red, dark), + (green, dark), + (red, dark), + (red, green), + ]; + for (fg, bg) in pairs { + assert_eq!(memo.glyph_fg(&dim, fg, bg), dim_color(fg, bg)); + assert_eq!(memo.glyph_fg(&plain, fg, bg), fg); + } + } } diff --git a/crates/toastty-render/src/text/mod.rs b/crates/toastty-render/src/text/mod.rs index f94a5fc8..d9acbd07 100644 --- a/crates/toastty-render/src/text/mod.rs +++ b/crates/toastty-render/src/text/mod.rs @@ -10,6 +10,7 @@ //! - [`viewport`] — smooth-scroll state (pure). //! - [`presentation`] — UTS #51 emoji-vs-text presentation selection. //! - [`glyph_rasterizer`] — cosmic-text + swash + GPU atlas upload. +//! - [`row_shaper`] — per-frame row shaping + the row-shape cache. //! - [`pipeline`] — wgpu pipeline that consumes `[CellInstance]`. //! //! See [`docs/decisions/text-stack.md`](../../../docs/decisions/text-stack.md). @@ -23,3 +24,4 @@ pub mod viewport; pub mod glyph_rasterizer; pub mod pipeline; +pub mod row_shaper; diff --git a/crates/toastty-render/src/text/row_shaper.rs b/crates/toastty-render/src/text/row_shaper.rs new file mode 100644 index 00000000..70efc419 --- /dev/null +++ b/crates/toastty-render/src/text/row_shaper.rs @@ -0,0 +1,223 @@ +//! Per-row shaping for `Renderer::render_term`: which rendered rows to +//! (re-)shape this frame, the text fed to the shaper for each, and the +//! row-shape cache the instance builders read glyph slots from. +//! +//! Lives outside `lib.rs` so the `render_term` criterion bench drives the +//! exact code the renderer runs. + +use std::sync::Arc; + +use toastty_term::Term; +use wgpu::Queue; + +use crate::text::glyph_rasterizer::{GlyphRasterizer, LineGlyphs}; +use crate::text::instance::{GlyphSlot, row_has_blink}; + +/// Row-shape cache plus the per-frame shaping loop. +#[derive(Debug, Default)] +pub struct RowShaper { + /// `line_cache[r]` holds the shaped glyphs for rendered row `r`; + /// `None` slots are re-shaped on the next frame. Shared with the + /// rasterizer's row-text memo (see + /// [`GlyphRasterizer::shape_line_memo`]), so a row costs one + /// allocation however many places hold it. + /// + /// This is the minimum-viable subset of decision #7 / M9 needed to + /// kill the per-keystroke render cost — shape only dirty rows, reuse + /// cached glyphs for the rest. + line_cache: Vec>>, + /// `row_blink[r]` is true when rendered row `r` holds a visible + /// SGR-blink cell. Parallel to `line_cache` and refreshed for the + /// same (dirty) rows, so finding blinking text costs nothing on + /// clean frames. Drives the renderer's text-blink timer. + row_blink: Vec, + /// Reusable per-row text buffer, cleared and refilled for each dirty + /// row instead of allocating a fresh `String` (which showed up in + /// profiles as per-row alloc churn). + text: String, +} + +impl RowShaper { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Drop every cached row (font or size change: the slots point into + /// an atlas that no longer exists). + pub fn invalidate(&mut self) { + self.line_cache.clear(); + } + + /// Re-shape the dirty rows of the `rows_rendered` rows drawn this + /// frame (and any row with no cached shape); clean rows keep their + /// cached glyphs. The atlas never shrinks, so a clean row's slots + /// stay valid across frames. Returns how many rows were shaped. + /// + /// `suppress[r]` marks rendered rows showing a rich-block image + /// slice: those draw the image instead of their text glyphs (the + /// cells keep the text for selection / copy / the evicted fallback), + /// so they shape as blanks. + pub fn shape_rows( + &mut self, + rasterizer: &mut GlyphRasterizer, + queue: &Queue, + term: &Term, + rows_rendered: u16, + suppress: &[bool], + ) -> u32 { + // Two extra slots beyond `rows`: one for the partial *top* row + // during sub-pixel scrolling, one for the partial *bottom* row + // when the content area isn't a whole number of cells tall. + // Sizing for the worst case avoids reallocation churn as the + // scroll offset crosses cell boundaries. Growth is dirty (new + // entries are `None`); shrinking just drops old slots. + let cache_len = usize::from(term.size().0) + 2; + if self.line_cache.len() != cache_len { + self.line_cache.resize(cache_len, None); + } + self.row_blink.resize(cache_len, false); + + let damage = term.damage(); + let mode_2027 = term.grapheme_cluster_mode(); + let mut shaped = 0u32; + for r in 0..rows_rendered { + let ri = usize::from(r); + let is_dirty = damage.all + || damage.rows.get(ri).is_some_and(|rd| !rd.is_empty()) + || self.line_cache[ri].is_none(); + if !is_dirty { + continue; + } + let row = term.view_row(r); + self.row_blink[ri] = fill_row_text( + &mut self.text, + term, + row, + suppress.get(ri).copied().unwrap_or(false), + ); + self.line_cache[ri] = Some(rasterizer.shape_line_memo(queue, &self.text, mode_2027)); + shaped += 1; + } + // Keep about a screenful of recently seen rows beyond the ones on + // screen: enough to scroll back and forth without re-shaping. + rasterizer.trim_line_memo(usize::from(rows_rendered)); + shaped + } + + /// Glyph slot for `(row, col)` holding `ch`, from the cached shapes. + #[inline] + #[must_use] + pub fn glyph(&self, row: u16, col: u16, ch: char) -> Option { + self.line_cache + .get(usize::from(row))? + .as_ref()? + .get(col, ch) + } + + /// Per-rendered-row "has visible blinking text" flags. + #[must_use] + pub fn row_blink(&self) -> &[bool] { + &self.row_blink + } +} + +/// Fill `out` with the text the shaper sees for `row`, and return +/// whether the row holds visible blinking text (`row_has_blink`, folded +/// into the same pass over the cells). +/// +/// Continuation cells are excluded: they're the second half of a width-2 +/// cluster whose primary cell already contributes its full multi-cell +/// glyph to the shaper. Feeding the continuation in as a space would +/// insert an extra glyph cosmic-text would shape, shifting every +/// downstream cluster's snapped column by one. +fn fill_row_text(out: &mut String, term: &Term, row: &toastty_term::Row, suppress: bool) -> bool { + out.clear(); + let mut blink = false; + for c in row.cells() { + blink |= c.style.flags.blink() && !c.style.flags.hidden(); + if c.is_continuation { + continue; + } + if suppress { + out.push(' '); + continue; + } + // Replace Kitty Unicode placeholder cells (U+10EEEE) with a space + // before shaping. The codepoint shapes to `.notdef` and its + // cluster width can throw off the snap of neighboring chars — we + // don't want it in the glyph cache and the image pipeline draws + // the real pixels anyway. + let ch = if c.ch == '\0' || c.ch == toastty_term::PLACEHOLDER { + ' ' + } else { + c.ch + }; + out.push(ch); + // The rest of a multi-codepoint grapheme (combining marks, ZWJ + // tail, VS16) so it shapes as one cluster; its extra glyphs come + // back as secondary glyphs of this cell (see + // `instance::secondary_glyph_key`). + if c.extra.is_some() && ch != ' ' { + out.push_str(term.grapheme_tail(c)); + } + } + debug_assert_eq!(blink, row_has_blink(row)); + blink +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::text::glyph_rasterizer::DEFAULT_LINE_HEIGHT_RATIO; + use toastty_parser::Parser; + + const TEST_FONT: &[u8] = include_bytes!("../../fonts/FiraMono-Medium.ttf"); + + fn setup() -> (Queue, GlyphRasterizer, Term) { + let (device, queue) = crate::test_gpu::device_and_queue(); + let rasterizer = GlyphRasterizer::new( + &device, + 16.0, + DEFAULT_LINE_HEIGHT_RATIO, + Some("Fira Mono"), + Some(TEST_FONT), + ); + let mut term = Term::new(3, 12, 0); + Parser::new().advance(&mut term, "ab 你好\r\ncd\r\n\x1b[5mblink".as_bytes()); + (queue, rasterizer, term) + } + + /// Only dirty (or never-shaped) rows are shaped; clean rows keep + /// their cached glyphs. + #[test] + fn shapes_only_dirty_rows() { + let (queue, mut rasterizer, mut term) = setup(); + let mut rows = RowShaper::new(); + assert_eq!(rows.shape_rows(&mut rasterizer, &queue, &term, 3, &[]), 3); + assert!(rows.glyph(0, 0, 'a').is_some()); + assert!( + rows.glyph(0, 3, '你').is_some(), + "wide char keyed at its column" + ); + assert_eq!(rows.row_blink()[..3], [false, false, true]); + term.clear_damage(); + assert_eq!(rows.shape_rows(&mut rasterizer, &queue, &term, 3, &[]), 0); + term.mark_cell_dirty(1, 0); + assert_eq!(rows.shape_rows(&mut rasterizer, &queue, &term, 3, &[]), 1); + assert!(rows.glyph(1, 1, 'd').is_some()); + term.clear_damage(); + rows.invalidate(); + assert_eq!(rows.shape_rows(&mut rasterizer, &queue, &term, 3, &[]), 3); + } + + /// A rich-slice row shapes as blanks, so its text draws no glyphs. + #[test] + fn suppressed_rows_shape_blank() { + let (queue, mut rasterizer, term) = setup(); + let mut rows = RowShaper::new(); + rows.shape_rows(&mut rasterizer, &queue, &term, 3, &[true, false]); + assert!(rows.glyph(0, 0, 'a').is_none()); + assert!(rows.glyph(1, 0, 'c').is_some()); + } +} diff --git a/crates/toastty-render/tests/common/mod.rs b/crates/toastty-render/tests/common/mod.rs index 0f1b63de..56a5b2b2 100644 --- a/crates/toastty-render/tests/common/mod.rs +++ b/crates/toastty-render/tests/common/mod.rs @@ -14,8 +14,9 @@ use image::RgbaImage; use image_compare::Algorithm; use pollster::block_on; use toastty_render::text::glyph_rasterizer::GlyphRasterizer; -use toastty_render::text::instance::Theme; +use toastty_render::text::instance::{EdgeBleed, ExtendCondition, Theme}; use toastty_render::text::pipeline::{self, GlobalsUbo, TextPipeline}; +use toastty_render::text::row_shaper::RowShaper; use toastty_render::{instance_descriptor, instance_flags_for_tests}; use toastty_term::Term; use wgpu::{ @@ -59,13 +60,49 @@ fn shared_device() -> (wgpu::Device, wgpu::Queue) { } /// Render `term` into a `width × height` RGBA8 image, headless. -#[allow(clippy::too_many_lines)] // test harness — clarity > brevity pub(crate) fn render_term_offscreen(term: &Term, width: u32, height: u32) -> RgbaImage { - let (device, queue) = shared_device(); + render_term_offscreen_with(term, width, height, &Offscreen::default()) +} - // Offscreen render target. RGBA8 (not sRGB) so saved PNG bytes are - // directly comparable across runs without sRGB-decode drift. - let format = TextureFormat::Rgba8Unorm; +/// Knobs for [`render_term_offscreen_with`]. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Offscreen { + /// Render target format. RGBA8 (not sRGB) by default so saved PNG + /// bytes are directly comparable across runs without sRGB-decode + /// drift; the app's swapchain is usually `Bgra8UnormSrgb`. + pub format: TextureFormat, + /// `CellRenderOpts::skip_default_bg`. + pub skip_default_bg: bool, + /// Window padding `[top, right, bottom, left]` in px; the grid is + /// inset by it, as in the renderer. + pub pad: [u32; 4], + /// Edge bleed (`extend_background = always` on both axes). + pub bleed: bool, +} + +impl Default for Offscreen { + fn default() -> Self { + Self { + format: TextureFormat::Rgba8Unorm, + skip_default_bg: true, + pad: [0; 4], + bleed: false, + } + } +} + +/// [`render_term_offscreen`] with explicit [`Offscreen`] options. Rows +/// are shaped by the renderer's own `RowShaper` (viewport rows, so a +/// scrolled-back term draws its glyphs). +#[allow(clippy::too_many_lines, clippy::cast_precision_loss)] // test harness — clarity > brevity +pub(crate) fn render_term_offscreen_with( + term: &Term, + width: u32, + height: u32, + o: &Offscreen, +) -> RgbaImage { + let (device, queue) = shared_device(); + let format = o.format; let target = device.create_texture(&TextureDescriptor { label: Some("snapshot target"), size: Extent3d { @@ -117,44 +154,59 @@ pub(crate) fn render_term_offscreen(term: &Term, width: u32, height: u32) -> Rgb let color_view = pipeline::default_view(rasterizer.color_texture()); let bind_group = text_pipeline.make_bind_group(&device, &mask_view, &color_view); - // Shape every visible row. + // Content area inside the padding, and the rows drawn into it (the + // renderer's own math), shaped like the renderer does. let cell_size = rasterizer.cell_size(); let atlas_dims = rasterizer.atlas_dims(); - let (rows, _cols) = term.size(); - let mut row_glyphs = Vec::with_capacity(rows as usize); - for r in 0..rows { - let row = term.row(r); - let line_text: String = row - .cells() - .iter() - .map(|c| if c.ch == '\0' { ' ' } else { c.ch }) - .collect(); - row_glyphs.push(rasterizer.shape_line(&queue, &line_text, term.grapheme_cluster_mode())); - } + let (rows, cols) = term.size(); + let [pad_t, pad_r, pad_b, pad_l] = o.pad.map(|p| p as f32); + let content_w = width as f32 - pad_l - pad_r; + let content_h = height as f32 - pad_t - pad_b; + let rows_rendered = toastty_render::text::instance::rows_to_render( + rows, + term.view_offset_pixel(), + cell_size.1, + content_h, + ); + let mut shaper = RowShaper::new(); + shaper.shape_rows(&mut rasterizer, &queue, term, rows_rendered, &[]); + // Bleed distance per edge: padding plus the floor-divide leftover on + // the trailing edges (top-left grid alignment). + let bleed = EdgeBleed { + pad: [ + pad_t, + pad_r + (content_w - f32::from(cols) * cell_size.0), + pad_b + (content_h - f32::from(rows) * cell_size.1), + pad_l, + ], + active: o.bleed, + horizontal: ExtendCondition::Always, + vertical: ExtendCondition::Always, + col_fills: &[], + }; // Build instances. let theme = Theme::default_dark(); - let row_glyphs_ref = &row_glyphs; + let skip_default_bg = o.skip_default_bg; // Font-derived decoration geometry, as the live renderer uses. let opts = toastty_render::text::instance::CellRenderOpts { blink_on: true, decor: Some(rasterizer.decoration_metrics()), + // The pass below clears to `theme.bg`, as the renderer's + // full-redraw frames do. + skip_default_bg, }; let mut instances = Vec::new(); - let (rows, _) = term.size(); toastty_render::text::instance::build_instances_into_opts( &mut instances, term, cell_size, &theme, None, - |row, col, ch, _style| { - let lg = row_glyphs_ref.get(row as usize)?; - lg.get(col, ch) - }, + |row, col, ch, _style| shaper.glyph(row, col, ch), |_, _| false, - toastty_render::text::instance::EdgeBleed::default(), - f32::from(rows) * cell_size.1, + bleed, + content_h, &opts, ); @@ -215,7 +267,7 @@ pub(crate) fn render_term_offscreen(term: &Term, width: u32, height: u32) -> Rgb ], cursor_rect: toastty_render::text::instance::cursor_pixel_rect(term, cell_size), cursor_color: theme.cursor, - content_origin: [0.0; 4], + content_origin: [pad_l, pad_t, 0.0, 0.0], }; text_pipeline.upload(&device, &queue, globals, &instances); @@ -254,13 +306,22 @@ pub(crate) fn render_term_offscreen(term: &Term, width: u32, height: u32) -> Rgb } let data = slice.get_mapped_range(); - // Repack into a tight RGBA8 buffer (skip row padding). + // Repack into a tight RGBA8 buffer (skip row padding; swizzle BGRA). + let bgra = matches!( + format, + TextureFormat::Bgra8Unorm | TextureFormat::Bgra8UnormSrgb + ); let mut out = Vec::with_capacity((row_bytes * height) as usize); for y in 0..height { let row_start = (y * padded_row_bytes) as usize; let row_end = row_start + row_bytes as usize; out.extend_from_slice(&data[row_start..row_end]); } + if bgra { + for px in out.chunks_exact_mut(4) { + px.swap(0, 2); + } + } drop(data); readback.unmap(); diff --git a/crates/toastty-render/tests/skip_default_bg.rs b/crates/toastty-render/tests/skip_default_bg.rs new file mode 100644 index 00000000..f7b97001 --- /dev/null +++ b/crates/toastty-render/tests/skip_default_bg.rs @@ -0,0 +1,136 @@ +//! `CellRenderOpts::skip_default_bg` must not change a single pixel of a +//! frame that starts with `LoadOp::Clear(theme.bg)`, whenever the +//! renderer's gate (`may_skip_default_bg`) allows it. + +mod common; + +use common::Offscreen; +use toastty_parser::Parser; +use toastty_render::text::instance::{EdgeBleed, may_skip_default_bg}; +use toastty_term::Term; +use wgpu::TextureFormat; + +fn term_from(rows: u16, cols: u16, bytes: &[u8]) -> Term { + let mut term = Term::new(rows, cols, 100); + Parser::new().advance(&mut term, bytes); + term +} + +/// Pixels that differ between two renders of `term`. +fn diff(term: &Term, width: u32, height: u32, one: &Offscreen, other: &Offscreen) -> usize { + let lhs = common::render_term_offscreen_with(term, width, height, one); + let rhs = common::render_term_offscreen_with(term, width, height, other); + lhs.pixels() + .zip(rhs.pixels()) + .filter(|(l, r)| l != r) + .count() +} + +/// The renderer's skip decision for a frame with `bleed` on or off. +fn gate(bleed: bool) -> bool { + may_skip_default_bg( + &EdgeBleed { + active: bleed, + ..EdgeBleed::default() + }, + false, + ) +} + +/// Several screens of coloured, decorated and wide text, in both the +/// test format and the app's swapchain format. +#[test] +fn skipping_default_bg_quads_is_pixel_identical() { + let scenes: [&[u8]; 4] = [ + b"plain text on the default background\r\n indented \x1b[1mbold\x1b[0m", + b"\x1b[31mred\x1b[0m \x1b[42;30m green bg \x1b[0m \x1b[7mreversed\x1b[0m \ + \x1b[38;5;208;48;5;24m256\x1b[0m \x1b[48;2;10;20;30mrgb\x1b[0m", + b"\x1b[4munder\x1b[0m \x1b[9mstrike\x1b[0m \x1b[53mover\x1b[0m \x1b[2mdim\x1b[0m\r\n\ + \x1b[44m\x1b[K full-width blue row\x1b[0m\r\n", + "wide 你好 \u{1F680} box │├─┤ ░▒▓ cursor here".as_bytes(), + ]; + for format in [TextureFormat::Rgba8Unorm, TextureFormat::Bgra8UnormSrgb] { + for (i, bytes) in scenes.iter().enumerate() { + let term = term_from(6, 48, bytes); + let skip = Offscreen { + format, + skip_default_bg: gate(false), + ..Offscreen::default() + }; + let keep = Offscreen { + skip_default_bg: false, + ..skip + }; + assert!(skip.skip_default_bg); + let n = diff(&term, 520, 120, &skip, &keep); + assert_eq!(n, 0, "{format:?} scene {i}: {n} pixels differ"); + } + } +} + +/// Scrolled back into history by part of a row, with and without +/// padding + edge bleed: rows are y-translated, a partial row hangs +/// above the top, and the grid leaves a sliver at the bottom. With +/// bleed on, the edge rows' grown quads reach over the partial rows, +/// so skipping default-bg quads *would* change pixels — the gate must +/// refuse it. +#[test] +fn skipping_default_bg_quads_is_pixel_identical_mid_row() { + // Alternate full-width coloured rows (`\e[K` fills the row) with + // plain ones, so an edge row's bleed always meets a default-bg row. + let mut bytes = Vec::new(); + for i in 0..20 { + let line = if i % 2 == 0 { + format!("\x1b[4{}m{i:02} line\x1b[K\x1b[0m\r\n", i % 7 + 1) + } else { + format!("{i:02} \x1b[3{}mplain\x1b[0m\r\n", i % 7 + 1) + }; + bytes.extend_from_slice(line.as_bytes()); + } + for lines in [2, 3] { + let mut term = term_from(6, 12, &bytes); + // 19 px rows (16 px font x 1.2): `lines` and 7 px into history. + term.scroll_view_by(lines, 7.0, 19.0); + term.force_snap_view(); + assert!(term.view_offset_pixel() > 0.0); + mid_row_scene(&term); + } +} + +fn mid_row_scene(term: &Term) { + // 6 rows x 19 px = 114 px of grid in a 130 - 16 = 114 px content + // area; 136 - 16 = 120 leaves a 6 px sliver below the grid. + for (pad, h) in [([0; 4], 120), ([8; 4], 130), ([8; 4], 136)] { + for format in [TextureFormat::Rgba8Unorm, TextureFormat::Bgra8UnormSrgb] { + for bleed in [false, true] { + let keep = Offscreen { + format, + skip_default_bg: false, + pad, + bleed, + }; + let gated = Offscreen { + skip_default_bg: gate(bleed), + ..keep + }; + let n = diff(term, 140, h, &gated, &keep); + assert_eq!(n, 0, "{format:?} pad {pad:?} h {h} bleed {bleed}: {n} px"); + } + } + // The gate is load-bearing: forcing the skip under bleed changes + // pixels once there is padding to bleed into. + if pad != [0; 4] { + let keep = Offscreen { + skip_default_bg: false, + pad, + bleed: true, + ..Offscreen::default() + }; + let forced = Offscreen { + skip_default_bg: true, + ..keep + }; + assert!(diff(term, 140, h, &forced, &keep) > 0, "pad {pad:?} h {h}"); + } + } +}