From ea83520c96fa47cc9d0d7dce560da43a869a5548 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:15:14 -0700 Subject: [PATCH 01/11] bench: paced full-screen repaint scenarios bench/paced.py runs inside toastty and emits one full-screen synchronized-output frame every 20 ms, so frames actually render (unlike the dense_cells flood) and frame_us shows the per-frame render cost. paced_text: new random coloured words every frame (no row text repeats); paced_tui: an htop-like screen whose text mostly stays put while colours and a few numbers change. Both at 1280x800 and 2560x1400 (_big). --- bench/README.md | 1 + bench/paced.py | 119 ++++++++++++++++++++++++++++++++++++++++++++++++ bench/run.py | 40 ++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 bench/paced.py diff --git a/bench/README.md b/bench/README.md index 845ae891..b4dde226 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` / `_tui` (+ `_big`) | `bench/paced.py` inside toastty: one full-screen synchronized-output frame every 20 ms. `text` is new random coloured words each frame, `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..a49272a0 --- /dev/null +++ b/bench/paced.py @@ -0,0 +1,119 @@ +#!/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. +- `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() + + +def text_frame(rng: random.Random, cols: int, rows: int) -> str: + out = [] + for r in range(rows): + out.append(f"\x1b[{r + 1};1H") + w = 0 + while w < cols: + word = rng.choice(WORDS)[: 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 += len(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", "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): + body = text_frame(rng, cols, rows) if args.mode == "text" else tui_frame(rng, cols, rows, f) + 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..6ab6ce90 100755 --- a/bench/run.py +++ b/bench/run.py @@ -521,6 +521,42 @@ 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_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 +575,10 @@ def close(self): "key_latency", "scroll_wheel", "scroll_wheel_big", + "paced_text", + "paced_text_big", + "paced_tui", + "paced_tui_big", ] From 57b5a408dfb3d14936078b0180195fba4717d7ce Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:16:05 -0700 Subject: [PATCH 02/11] render: dense char table for the shaping fast path The fast path did up to three SipHash lookups per char (uncacheable_chars, char_cache_misses, char_cache) plus a width test, and grew LineGlyphs::by_column one resize per glyph. A row of 200 ASCII chars took ~24 us to "shape" that way. Mirror the three sets into a dense FastChar table for chars below U+3000 (Latin through box drawing, blocks and Braille): one indexed load per char gives Bail / Skip / Cached(slot). The sets stay the source of truth; every mutation goes through a helper that recomputes the char's entry with the old precedence, and set_font_size rebuilds the table. Chars above U+3000 keep the hashed lookups. by_column is sized once from the row with trailing blanks trimmed, with the same final contents as before. A test checks the table against the old hashed fast path on mixed input and for every char in the table. --- .../src/text/glyph_rasterizer.rs | 314 +++++++++++++++--- 1 file changed, 270 insertions(+), 44 deletions(-) diff --git a/crates/toastty-render/src/text/glyph_rasterizer.rs b/crates/toastty-render/src/text/glyph_rasterizer.rs index 849a39ea..d053238e 100644 --- a/crates/toastty-render/src/text/glyph_rasterizer.rs +++ b/crates/toastty-render/src/text/glyph_rasterizer.rs @@ -12,6 +12,7 @@ use cosmic_text::{ SwashContent, Weight, fontdb, }; use std::collections::HashMap; +use std::collections::hash_map::Entry; use toastty_protocols::unicode_core::cluster_cell_width; use unicode_width::UnicodeWidthChar; use wgpu::{Device, Extent3d, Queue, Texture, TextureFormat}; @@ -28,6 +29,29 @@ 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), +} + /// 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 +345,12 @@ 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, } impl GlyphRasterizer { @@ -440,6 +470,8 @@ impl GlyphRasterizer { presentation_override: HashMap::new(), vs16_color_override: HashMap::new(), requested_family_available, + fast_chars: initial_fast_chars(), + fast_slots: Vec::new(), } } @@ -482,6 +514,76 @@ impl GlyphRasterizer { self.atlas = Atlas::new(ATLAS_W, ATLAS_H); self.placements.clear(); self.char_cache.clear(); + self.rebuild_fast_chars(); + } + + /// 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); + } + new + } + + /// Record `ch` as contextually shaped (see `uncacheable_chars`) and + /// evict its `char_cache` entry. Returns true if it was new. + 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); + } + new } /// Glyph id this char would produce shaped alone, with no @@ -794,54 +896,77 @@ 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) { - 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 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))); } - Some(out) + // `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)) } /// Slow path: cosmic-text shaping. Populates `char_cache` / @@ -998,12 +1123,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 +1145,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 +1169,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", @@ -1180,6 +1303,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 +1997,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 +2461,92 @@ 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:?}"); + } + } + + /// `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()); + } } From d7c10ce411d1410e8e43c8b6d2608439ada0cb08 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:17:02 -0700 Subject: [PATCH 03/11] render: memoise row shapes by text; move row shaping into RowShaper Every full redraw (scroll, viewport change, a TUI repainting in new colours) marks all rows dirty, and every dirty row was shaped again even when its text was unchanged. For rows that need cosmic-text (CJK, emoji, ligatures) that is ~0.5 ms a row: a full repaint of a mixed-script 200x60 screen took ~33 ms of CPU. GlyphRasterizer::shape_line_memo keys shaped rows by their exact text (one map per mode-2027 state) and hands back a shared Arc, so the row-shape cache and the memo share one allocation. It is cleared whenever an earlier shape could go stale: a char turning uncacheable or becoming a recorded miss, an atlas shelf eviction, a size change. A row where a glyph got no atlas slot (atlas full even after evicting) is not memoised, so it is retried the next time it is dirty, as before. trim_line_memo, called once per rendered frame, keeps it to about a screenful of recently used rows beyond the visible ones. The per-row text building and dirty-row loop move from render_term into text::row_shaper::RowShaper, which the render_term criterion bench now drives directly instead of keeping its own copy. The bench gains fullframe_unicode_200x60 and scroll_{ascii,unicode}_200x60, and TOASTTY_BENCH_CPU_ONLY=1 to time shaping + instance building without the GPU. The rich-block suppress list is only allocated when rich blocks are on screen. A regression test checks that a neutral-only word shaped at RTL level (mirrored brackets) doesn't leak into an LTR row with the same word. --- crates/toastty-render/benches/render_term.rs | 197 ++++++++++------ crates/toastty-render/src/lib.rs | 146 +++--------- .../src/text/glyph_rasterizer.rs | 209 ++++++++++++++++- crates/toastty-render/src/text/mod.rs | 2 + crates/toastty-render/src/text/row_shaper.rs | 218 ++++++++++++++++++ 5 files changed, 589 insertions(+), 183 deletions(-) create mode 100644 crates/toastty-render/src/text/row_shaper.rs diff --git a/crates/toastty-render/benches/render_term.rs b/crates/toastty-render/benches/render_term.rs index dc1f88a4..a1dcf71f 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,10 @@ 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::glyph_rasterizer::GlyphRasterizer; use toastty_render::text::instance::{CellInstance, EdgeBleed, Theme, build_instances_into}; 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 +57,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 +145,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 +163,25 @@ 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; + let rows_ref = &self.rows; build_instances_into( &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, ); + if self.cpu_only { + return; + } let instances = &self.instances; let mut encoder = self @@ -273,35 +263,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,17 +290,14 @@ impl Harness { // Phase C: build_instances. let t_build = Instant::now(); let theme = self.theme; - let line_cache_ref = &self.line_cache; + let rows_ref = &self.rows; build_instances_into( &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, @@ -546,6 +514,102 @@ 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); +} + /// 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 +658,9 @@ 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, ); criterion_main!(benches); diff --git a/crates/toastty-render/src/lib.rs b/crates/toastty-render/src/lib.rs index d6024d8f..d801879c 100644 --- a/crates/toastty-render/src/lib.rs +++ b/crates/toastty-render/src/lib.rs @@ -52,9 +52,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 @@ -126,7 +127,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, @@ -137,10 +138,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, @@ -159,7 +157,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, ) { @@ -171,10 +169,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, @@ -902,28 +897,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 { @@ -1208,10 +1186,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 @@ -1251,7 +1227,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; } @@ -2288,7 +2264,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; @@ -2419,7 +2400,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 @@ -2452,19 +2433,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 @@ -2476,81 +2444,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 @@ -2708,7 +2620,7 @@ impl Renderer { cell_size, &theme, ext_palette, - &text.line_cache, + &text.rows, bleed, content_dims.1, &cell_opts, @@ -2722,7 +2634,7 @@ impl Renderer { &theme, ext_palette, cursor_visible, - &text.line_cache, + &text.rows, bleed, &cell_opts, ); diff --git a/crates/toastty-render/src/text/glyph_rasterizer.rs b/crates/toastty-render/src/text/glyph_rasterizer.rs index d053238e..42d40b59 100644 --- a/crates/toastty-render/src/text/glyph_rasterizer.rs +++ b/crates/toastty-render/src/text/glyph_rasterizer.rs @@ -13,6 +13,7 @@ use cosmic_text::{ }; 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}; @@ -52,6 +53,14 @@ enum FastChar { 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; @@ -351,6 +360,19 @@ pub struct GlyphRasterizer { /// 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 { @@ -472,6 +494,9 @@ impl GlyphRasterizer { requested_family_available, fast_chars: initial_fast_chars(), fast_slots: Vec::new(), + line_memo: [HashMap::new(), HashMap::new()], + memo_frame: 0, + slot_failed: false, } } @@ -515,6 +540,7 @@ impl GlyphRasterizer { 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 @@ -571,21 +597,34 @@ impl GlyphRasterizer { 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. + /// 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 /// surrounding context to trigger GSUB ligatures or contextual /// alternates. `None` means the font has no glyph for this @@ -969,6 +1008,68 @@ impl GlyphRasterizer { 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; + } + 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); + } + } + + /// 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` / /// `char_cache_misses` with whatever cosmic-text resolves so that /// future calls with the same characters can take the fast path. @@ -1188,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; @@ -1267,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) => { @@ -2539,6 +2649,102 @@ mod tests { } } + /// 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] @@ -2548,5 +2754,6 @@ mod tests { 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/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..462b7d51 --- /dev/null +++ b/crates/toastty-render/src/text/row_shaper.rs @@ -0,0 +1,218 @@ +//! 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] = row_has_blink(row); + 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`. +/// +/// 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) { + out.clear(); + for c in row.cells() { + 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)); + } + } +} + +#[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()); + } +} From ec7b727c1b630e7fd3d2ed7784f96402128d1483 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:17:31 -0700 Subject: [PATCH 04/11] render: skip default-bg cell quads on full-clear frames A full redraw starts with LoadOp::Clear(theme.bg), and the bg pass writes each quad with a REPLACE blend, so a quad in exactly theme.bg that no other bg quad overlaps rewrites the value the clear already put there. On a typical text screen most cells are on the default bg: dropping those quads roughly halves the instances built, uploaded and drawn per full redraw. The full builder gets CellRenderOpts::skip_default_bg. The renderer turns it on through may_skip_default_bg: only when edge bleed is off, because a grown edge quad reaches over the neighbouring (partial) rows and a default-bg quad drawn after it is what paints over that bleed. The SIZE-9 frames (a z < INT32_MIN/2 image on screen) keep dropping default-bg quads regardless, as before, so the post-pass that did that (drop_default_bg_quads) goes and its tests move to instance.rs. The dirty builder's damage.all fallback forces the option off, since it may run under LoadOp::Load. tests/skip_default_bg.rs renders colour, reverse, decoration, wide and emoji scenes, and a view scrolled back by part of a row with and without padding + bleed, in Rgba8Unorm and the app's Bgra8UnormSrgb, and checks that the gated option changes no pixel (and that forcing it under bleed would). The snapshot harness now shapes viewport rows with RowShaper and can inset the grid and bleed like the renderer. --- crates/toastty-render/benches/render_term.rs | 22 +- crates/toastty-render/src/lib.rs | 124 ++--------- crates/toastty-render/src/text/instance.rs | 196 +++++++++++++++--- crates/toastty-render/tests/common/mod.rs | 117 ++++++++--- .../toastty-render/tests/skip_default_bg.rs | 136 ++++++++++++ 5 files changed, 436 insertions(+), 159 deletions(-) create mode 100644 crates/toastty-render/tests/skip_default_bg.rs diff --git a/crates/toastty-render/benches/render_term.rs b/crates/toastty-render/benches/render_term.rs index a1dcf71f..5db3d46d 100644 --- a/crates/toastty-render/benches/render_term.rs +++ b/crates/toastty-render/benches/render_term.rs @@ -27,7 +27,9 @@ use pollster::block_on; use toastty_parser::Parser; use toastty_render::DEFAULT_LINE_HEIGHT; use toastty_render::text::glyph_rasterizer::GlyphRasterizer; -use toastty_render::text::instance::{CellInstance, EdgeBleed, Theme, build_instances_into}; +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}; @@ -168,7 +170,13 @@ impl Harness { let theme = self.theme; let rows_ref = &self.rows; - build_instances_into( + // 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, @@ -178,6 +186,7 @@ impl Harness { |_, _| false, EdgeBleed::default(), f32::from(term.size().0) * cell_size.1, + &opts, ); if self.cpu_only { return; @@ -291,7 +300,13 @@ impl Harness { let t_build = Instant::now(); let theme = self.theme; let rows_ref = &self.rows; - build_instances_into( + // 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, @@ -301,6 +316,7 @@ impl Harness { |_, _| 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(); diff --git a/crates/toastty-render/src/lib.rs b/crates/toastty-render/src/lib.rs index d801879c..536b9172 100644 --- a/crates/toastty-render/src/lib.rs +++ b/crates/toastty-render/src/lib.rs @@ -85,35 +85,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 @@ -2604,6 +2585,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()) @@ -2625,7 +2609,7 @@ impl Renderer { 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, @@ -4062,93 +4046,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/instance.rs b/crates/toastty-render/src/text/instance.rs index 2edb4707..28588182 100644 --- a/crates/toastty-render/src/text/instance.rs +++ b/crates/toastty-render/src/text/instance.rs @@ -52,6 +52,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 { @@ -59,6 +73,7 @@ impl Default for CellRenderOpts { Self { blink_on: true, decor: None, + skip_default_bg: false, } } } @@ -163,6 +178,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. /// @@ -1102,29 +1127,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, @@ -1264,6 +1295,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, @@ -1274,7 +1311,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 @@ -3462,6 +3499,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); @@ -3594,6 +3632,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); @@ -3616,6 +3655,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); @@ -3638,4 +3678,112 @@ 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); + } } 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}"); + } + } +} From 0e38d51ef7c11aeba5b90626e7f7f74d35f699e0 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:28:10 -0700 Subject: [PATCH 05/11] render: table lookup for sRGB to linear conversion The instance builders resolve truecolor cells (and, without an OSC 4 palette, 256-colour ones) through srgb_channel_to_linear for the fg and bg of every cell: six powf calls per truecolor cell. Serve the 256 possible inputs from a lazily built table holding exactly the same values (a test compares every entry bit for bit). Halves the CPU of a full-screen repaint of new coloured text in the render_term bench (newtext 200x60: 727 -> 350 us). --- crates/toastty-render/src/text/instance.rs | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/toastty-render/src/text/instance.rs b/crates/toastty-render/src/text/instance.rs index 28588182..d17b918c 100644 --- a/crates/toastty-render/src/text/instance.rs +++ b/crates/toastty-render/src/text/instance.rs @@ -666,7 +666,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 @@ -3786,4 +3802,15 @@ mod tests { ); 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}" + ); + } + } } From a0fe3aa7f2cf04434735b8e2ece2999654c00ea5 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:28:10 -0700 Subject: [PATCH 06/11] bench: render_term min-time mode TOASTTY_BENCH_MIN=1 renders 400 CPU-only frames per scenario (repaint of unchanged text, new 256-colour and truecolor text every frame at 200x60 and 300x120, unicode repaint and scroll) and prints the minimum and median frame time. The minimum stays usable while other processes load the machine, where criterion's means swing by 2x. --- crates/toastty-render/benches/render_term.rs | 108 ++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/crates/toastty-render/benches/render_term.rs b/crates/toastty-render/benches/render_term.rs index 5db3d46d..5a8f0ca9 100644 --- a/crates/toastty-render/benches/render_term.rs +++ b/crates/toastty-render/benches/render_term.rs @@ -547,7 +547,11 @@ fn unicode_line(i: usize, cols: u16) -> String { // 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 }; + let cw = if ch.is_ascii() || ('\u{2190}'..='\u{2bff}').contains(&ch) { + 1 + } else { + 2 + }; if w + cw > usize::from(cols) { break; } @@ -626,6 +630,107 @@ fn bench_scroll_unicode(c: &mut Criterion) { bench_scroll(c, "scroll_unicode_200x60", true); } +/// 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; + } + const FRAMES: usize = 400; + let report = |name: &str, mut t: Vec| { + t.sort_by(f64::total_cmp); + println!( + "min-time {name:<26} min {:>8.1} us p50 {:>8.1} us", + t[0], + t[t.len() / 2] + ); + }; + let lorem: Vec<&str> = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do \ + eiusmod tempor incididunt ut labore et dolore magna aliqua" + .split(' ') + .collect(); + 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 mut t = Vec::with_capacity(FRAMES); + for _ in 0..FRAMES { + term.mark_all_dirty(); + let t0 = Instant::now(); + h.render_term(&term); + t.push(t0.elapsed().as_secs_f64() * 1e6); + term.clear_damage(); + } + report(&format!("repaint_{cols}x{rows}"), t); + // Every row new text every frame (no memo hits), coloured words + // (256-colour, then truecolor). + for truecolor in [false, true] { + let mut parser = Parser::new(); + let mut seed = 1u32; + let mut t = Vec::with_capacity(FRAMES); + for f in 0..FRAMES { + let mut screen = String::from("\x1b[H"); + for r in 0..rows { + screen.push_str(&format!("\x1b[{};1H", r + 1)); + let mut w = 0usize; + while w + 12 < usize::from(cols) { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); + let word = lorem[(seed >> 16) as usize % lorem.len()]; + if truecolor { + let (r, g, b) = (seed >> 8 & 255, seed >> 12 & 255, seed >> 4 & 255); + screen.push_str(&format!("\x1b[38;2;{r};{g};{b}m{word} ")); + } else { + screen.push_str(&format!("\x1b[38;5;{}m{word} ", (seed >> 8) % 256)); + } + w += word.len() + 1; + } + screen.push_str(&format!("{f}\x1b[K")); + } + parser.advance(&mut term, screen.as_bytes()); + let t0 = Instant::now(); + h.render_term(&term); + t.push(t0.elapsed().as_secs_f64() * 1e6); + term.clear_damage(); + } + let kind = if truecolor { "rgb" } else { "256" }; + report(&format!("newtext_{kind}_{cols}x{rows}"), t); + } + } + let (width, height) = viewport_pixels(ROWS, COLS); + let mut h = Harness::new(width, height); + h.cpu_only = true; + let mut term = make_term_unicode(ROWS, COLS); + h.render_term(&term); + let mut t = Vec::with_capacity(FRAMES); + for _ in 0..FRAMES { + term.mark_all_dirty(); + let t0 = Instant::now(); + h.render_term(&term); + t.push(t0.elapsed().as_secs_f64() * 1e6); + term.clear_damage(); + } + report("repaint_unicode_200x60", t); + let mut parser = Parser::new(); + parser.advance(&mut term, b"\x1b[60;1H"); + let mut t = Vec::with_capacity(FRAMES); + for i in 0..FRAMES { + parser.advance(&mut term, b"\r\n"); + parser.advance(&mut term, unicode_line(i + 1000, COLS).as_bytes()); + let t0 = Instant::now(); + h.render_term(&term); + t.push(t0.elapsed().as_secs_f64() * 1e6); + term.clear_damage(); + } + report("scroll_unicode_200x60", t); + 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 @@ -678,5 +783,6 @@ criterion_group!( bench_scroll_ascii, bench_scroll_unicode, bench_phase_breakdown, + bench_min_times, ); criterion_main!(benches); From 4e5c4dec467e677da4fd5d8a91db4b096e686e89 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:44:25 -0700 Subject: [PATCH 07/11] bench: CJK repaint scenarios paced.py --mode cjk (run.py paced_cjk) mixes CJK words and emoji into the paced text frames, and the render_term min-time mode gains newtext_cjk at both sizes: every row needs cosmic-text, so these time the shaper's slow path. Text mode output is unchanged. --- bench/README.md | 2 +- bench/paced.py | 26 +++++++++++--- bench/run.py | 4 +++ crates/toastty-render/benches/render_term.rs | 38 +++++++++++++++----- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/bench/README.md b/bench/README.md index b4dde226..47b563ae 100644 --- a/bench/README.md +++ b/bench/README.md @@ -28,7 +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` / `_tui` (+ `_big`) | `bench/paced.py` inside toastty: one full-screen synchronized-output frame every 20 ms. `text` is new random coloured words each frame, `tui` an htop-like screen whose text mostly stays put while colours and a few numbers change | frame ms p50/p99/mean, CPU s | +| `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 index a49272a0..bce93f42 100644 --- a/bench/paced.py +++ b/bench/paced.py @@ -12,6 +12,8 @@ - `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. @@ -36,19 +38,30 @@ ).split() -def text_frame(rng: random.Random, cols: int, rows: int) -> str: +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(WORDS)[: cols - w] + 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 += len(word) + w += width(word) if w < cols: out.append(" ") w += 1 @@ -87,7 +100,7 @@ def tui_frame(rng: random.Random, cols: int, rows: int, f: int) -> str: def main() -> int: ap = argparse.ArgumentParser() - ap.add_argument("--mode", choices=("text", "tui"), default="text") + 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) @@ -102,7 +115,10 @@ def main() -> int: time.sleep(0.5) t0 = time.monotonic() for f in range(args.frames): - body = text_frame(rng, cols, rows) if args.mode == "text" else tui_frame(rng, cols, rows, f) + 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 diff --git a/bench/run.py b/bench/run.py index 6ab6ce90..46947e1c 100755 --- a/bench/run.py +++ b/bench/run.py @@ -551,6 +551,9 @@ def paced_text(self): 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") @@ -577,6 +580,7 @@ def close(self): "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 5a8f0ca9..8636521d 100644 --- a/crates/toastty-render/benches/render_term.rs +++ b/crates/toastty-render/benches/render_term.rs @@ -630,6 +630,20 @@ 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] = [ + "你好", + "世界", + "日本語", + "テキスト", + "한글", + "漢字", + "中文", + "进度", + "🚀", + "👍", +]; + /// 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 @@ -668,27 +682,36 @@ fn bench_min_times(c: &mut Criterion) { term.clear_damage(); } report(&format!("repaint_{cols}x{rows}"), t); - // Every row new text every frame (no memo hits), coloured words - // (256-colour, then truecolor). - for truecolor in [false, true] { + // 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 truecolor = kind == "rgb"; + let frames = if kind == "cjk" { FRAMES / 4 } else { FRAMES }; let mut parser = Parser::new(); let mut seed = 1u32; - let mut t = Vec::with_capacity(FRAMES); - for f in 0..FRAMES { + let mut t = Vec::with_capacity(frames); + for f in 0..frames { let mut screen = String::from("\x1b[H"); for r in 0..rows { screen.push_str(&format!("\x1b[{};1H", r + 1)); let mut w = 0usize; while w + 12 < usize::from(cols) { seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); - let word = lorem[(seed >> 16) as usize % lorem.len()]; + let (word, word_w) = if kind == "cjk" && (seed >> 20) % 10 < 3 { + let w = CJK_WORDS[(seed >> 16) as usize % CJK_WORDS.len()]; + (w, 2 * w.chars().count()) + } else { + let w = lorem[(seed >> 16) as usize % lorem.len()]; + (w, w.len()) + }; if truecolor { let (r, g, b) = (seed >> 8 & 255, seed >> 12 & 255, seed >> 4 & 255); screen.push_str(&format!("\x1b[38;2;{r};{g};{b}m{word} ")); } else { screen.push_str(&format!("\x1b[38;5;{}m{word} ", (seed >> 8) % 256)); } - w += word.len() + 1; + w += word_w + 1; } screen.push_str(&format!("{f}\x1b[K")); } @@ -698,7 +721,6 @@ fn bench_min_times(c: &mut Criterion) { t.push(t0.elapsed().as_secs_f64() * 1e6); term.clear_damage(); } - let kind = if truecolor { "rgb" } else { "256" }; report(&format!("newtext_{kind}_{cols}x{rows}"), t); } } From 963822ebf18cd76b98a028bc93cf4f8cee7cd621 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:50:10 -0700 Subject: [PATCH 08/11] render: find blinking text in the row-text pass RowShaper walked every dirty row's cells twice: once for row_has_blink, once to build the shaper text. Fold the blink test into the text pass (a debug assertion checks it against row_has_blink). ~5% off a full-repaint frame in the render_term min-time bench. --- crates/toastty-render/src/text/row_shaper.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/toastty-render/src/text/row_shaper.rs b/crates/toastty-render/src/text/row_shaper.rs index 462b7d51..70efc419 100644 --- a/crates/toastty-render/src/text/row_shaper.rs +++ b/crates/toastty-render/src/text/row_shaper.rs @@ -90,8 +90,7 @@ impl RowShaper { continue; } let row = term.view_row(r); - self.row_blink[ri] = row_has_blink(row); - fill_row_text( + self.row_blink[ri] = fill_row_text( &mut self.text, term, row, @@ -123,16 +122,20 @@ impl RowShaper { } } -/// Fill `out` with the text the shaper sees for `row`. +/// 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) { +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; } @@ -159,6 +162,8 @@ fn fill_row_text(out: &mut String, term: &Term, row: &toastty_term::Row, suppres out.push_str(term.grapheme_tail(c)); } } + debug_assert_eq!(blink, row_has_blink(row)); + blink } #[cfg(test)] From 784464ba648f36d6e3550997c8cfe6b5cf3a6dd8 Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:52:43 -0700 Subject: [PATCH 09/11] render: remember the last faint colour while building instances dim_color is nine powf calls (sRGB round trip per channel) and both instance builders ran it for every SGR 2 cell. Faint text comes in runs of the same fg/bg, so keep a one-entry memo per build, keyed on the exact f32 bits. A full screen of faint text, 200x60: 1.40 -> 0.23 ms per repaint (render_term min-time repaint_dim). --- crates/toastty-render/src/text/instance.rs | 64 +++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/toastty-render/src/text/instance.rs b/crates/toastty-render/src/text/instance.rs index d17b918c..92cff470 100644 --- a/crates/toastty-render/src/text/instance.rs +++ b/crates/toastty-render/src/text/instance.rs @@ -834,6 +834,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. /// @@ -1075,6 +1110,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); @@ -1118,7 +1154,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 @@ -1359,6 +1395,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(); @@ -1399,7 +1436,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 @@ -3813,4 +3850,27 @@ mod tests { ); } } + + #[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); + } + } } From 61e5e8e4059ba232d598cdd038e124be025e934d Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 02:52:43 -0700 Subject: [PATCH 10/11] bench: faint-text repaint in render_term min-time mode --- crates/toastty-render/benches/render_term.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/toastty-render/benches/render_term.rs b/crates/toastty-render/benches/render_term.rs index 8636521d..1955494f 100644 --- a/crates/toastty-render/benches/render_term.rs +++ b/crates/toastty-render/benches/render_term.rs @@ -727,6 +727,24 @@ fn bench_min_times(c: &mut Criterion) { 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(|c| c.ch)) + .collect(); + Parser::new().advance(&mut term, text.as_bytes()); + h.render_term(&term); + let mut t = Vec::with_capacity(FRAMES); + for _ in 0..FRAMES { + term.mark_all_dirty(); + let t0 = Instant::now(); + h.render_term(&term); + t.push(t0.elapsed().as_secs_f64() * 1e6); + term.clear_damage(); + } + report("repaint_dim_200x60", t); let mut term = make_term_unicode(ROWS, COLS); h.render_term(&term); let mut t = Vec::with_capacity(FRAMES); From a60427b6cd2c7a618b4fc987d5481e123a1d43ae Mon Sep 17 00:00:00 2001 From: EDToaster Date: Thu, 22 Oct 2026 09:49:12 -0700 Subject: [PATCH 11/11] bench: factor the render_term min-time harness Split bench_min_times into report / frame-timing / screen-generator helpers (it had grown past clippy's line limit) and build the new-text screens with write! instead of format!-and-push. --- crates/toastty-render/benches/render_term.rs | 192 +++++++++++-------- 1 file changed, 107 insertions(+), 85 deletions(-) diff --git a/crates/toastty-render/benches/render_term.rs b/crates/toastty-render/benches/render_term.rs index 1955494f..1235cf6f 100644 --- a/crates/toastty-render/benches/render_term.rs +++ b/crates/toastty-render/benches/render_term.rs @@ -644,6 +644,90 @@ 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 @@ -653,19 +737,7 @@ fn bench_min_times(c: &mut Criterion) { if std::env::var_os("TOASTTY_BENCH_MIN").is_none() { return; } - const FRAMES: usize = 400; - let report = |name: &str, mut t: Vec| { - t.sort_by(f64::total_cmp); - println!( - "min-time {name:<26} min {:>8.1} us p50 {:>8.1} us", - t[0], - t[t.len() / 2] - ); - }; - let lorem: Vec<&str> = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do \ - eiusmod tempor incididunt ut labore et dolore magna aliqua" - .split(' ') - .collect(); + 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); @@ -673,55 +745,24 @@ fn bench_min_times(c: &mut Criterion) { // Full repaint of unchanged text. let mut term = make_term_filled(rows, cols); h.render_term(&term); - let mut t = Vec::with_capacity(FRAMES); - for _ in 0..FRAMES { - term.mark_all_dirty(); - let t0 = Instant::now(); - h.render_term(&term); - t.push(t0.elapsed().as_secs_f64() * 1e6); - term.clear_damage(); - } - report(&format!("repaint_{cols}x{rows}"), t); + 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 truecolor = kind == "rgb"; - let frames = if kind == "cjk" { FRAMES / 4 } else { FRAMES }; + let frames = if kind == "cjk" { + MIN_FRAMES / 4 + } else { + MIN_FRAMES + }; let mut parser = Parser::new(); let mut seed = 1u32; - let mut t = Vec::with_capacity(frames); - for f in 0..frames { - let mut screen = String::from("\x1b[H"); - for r in 0..rows { - screen.push_str(&format!("\x1b[{};1H", r + 1)); - let mut w = 0usize; - while w + 12 < usize::from(cols) { - seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); - let (word, word_w) = if kind == "cjk" && (seed >> 20) % 10 < 3 { - let w = CJK_WORDS[(seed >> 16) as usize % CJK_WORDS.len()]; - (w, 2 * w.chars().count()) - } else { - let w = lorem[(seed >> 16) as usize % lorem.len()]; - (w, w.len()) - }; - if truecolor { - let (r, g, b) = (seed >> 8 & 255, seed >> 12 & 255, seed >> 4 & 255); - screen.push_str(&format!("\x1b[38;2;{r};{g};{b}m{word} ")); - } else { - screen.push_str(&format!("\x1b[38;5;{}m{word} ", (seed >> 8) % 256)); - } - w += word_w + 1; - } - screen.push_str(&format!("{f}\x1b[K")); - } - parser.advance(&mut term, screen.as_bytes()); - let t0 = Instant::now(); - h.render_term(&term); - t.push(t0.elapsed().as_secs_f64() * 1e6); - term.clear_damage(); - } - report(&format!("newtext_{kind}_{cols}x{rows}"), t); + 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); @@ -732,42 +773,23 @@ fn bench_min_times(c: &mut Criterion) { 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(|c| c.ch)) + .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 mut t = Vec::with_capacity(FRAMES); - for _ in 0..FRAMES { - term.mark_all_dirty(); - let t0 = Instant::now(); - h.render_term(&term); - t.push(t0.elapsed().as_secs_f64() * 1e6); - term.clear_damage(); - } - report("repaint_dim_200x60", t); + 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 mut t = Vec::with_capacity(FRAMES); - for _ in 0..FRAMES { - term.mark_all_dirty(); - let t0 = Instant::now(); - h.render_term(&term); - t.push(t0.elapsed().as_secs_f64() * 1e6); - term.clear_damage(); - } - report("repaint_unicode_200x60", t); + 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 mut t = Vec::with_capacity(FRAMES); - for i in 0..FRAMES { - parser.advance(&mut term, b"\r\n"); - parser.advance(&mut term, unicode_line(i + 1000, COLS).as_bytes()); - let t0 = Instant::now(); - h.render_term(&term); - t.push(t0.elapsed().as_secs_f64() * 1e6); - term.clear_damage(); - } - report("scroll_unicode_200x60", t); + 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(|| {})); }