From 5cce8ea80a5a085d8d469d5ec2c3b63b7942ca39 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:24:33 +0700 Subject: [PATCH 01/17] Add public owned TerminalSnapshot with atomic live capture Live capture reads grid, cursor, dimensions, title, and resolved palette under one terminal-state lock. Default capture records raw visual data; highlighted capture needs an explicit option. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/engine.rs | 53 ++ crates/tui-test/src/lib.rs | 7 + crates/tui-test/src/runtime.rs | 22 + crates/tui-test/src/snapshot.rs | 1368 +++++++++++++++++++++++++++++++ 4 files changed, 1450 insertions(+) create mode 100644 crates/tui-test/src/snapshot.rs diff --git a/crates/tui-test/src/engine.rs b/crates/tui-test/src/engine.rs index 07a80fb5..673db059 100644 --- a/crates/tui-test/src/engine.rs +++ b/crates/tui-test/src/engine.rs @@ -35,6 +35,10 @@ use crate::session::{ capture_visual_state, scroll_emulator_and_record, try_capture_visual_state, Session as TerminalSession, TermState, TextHighlight, }; +use crate::snapshot::{ + capture_live, capture_time_ms, LiveHighlight, SnapshotMetadata, SnapshotOptions, SnapshotScope, + SnapshotSource, TerminalSnapshot, +}; use crate::terminal::cell::{rows_to_strings, Attrs, Color, EmuCell}; use crate::terminal::emu::{ ClipboardType, CursorShape, Emulator, KeyboardMode, MouseMode, TerminalMode, @@ -1472,6 +1476,55 @@ impl Engine { } } + /// Capture an owned snapshot of the selected scope. + /// + /// Reads grid, cursor, dimensions, title, and resolved colors while it + /// holds one terminal-state lock, so the snapshot is one atomic terminal + /// moment. Records raw visual data; locator highlighting never leaks in. + /// The snapshot is fully owned and stays usable after the session closes. + pub fn capture_snapshot(&self, scope: SnapshotScope) -> Result { + self.capture_snapshot_with(&SnapshotOptions::new(scope)) + } + + /// Capture an owned snapshot with explicit options. + /// + /// Same atomic capture as [`Engine::capture_snapshot`]; pass + /// [`SnapshotOptions::with_highlight`] to fold locator highlighting into + /// the captured cells instead of recording raw visual data. + pub fn capture_snapshot_with( + &self, + options: &SnapshotOptions, + ) -> Result { + let guard = self.lock_session(); + let session = guard.as_ref().ok_or_else(TuiTestError::no_session)?; + if let Some(fault) = session.fault() { + return Err( + TuiTestError::internal(fault.clone()).with_report(FailureReport::new( + "terminal.operation", + None, + FailureReason::EmulatorFault, + fault, + )), + ); + } + let state = session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let metadata = SnapshotMetadata { + captured_at_ms: capture_time_ms(), + revision: state.visual_revision, + backend: session.backend.as_str().to_string(), + session: self.name.clone(), + source: SnapshotSource::Live, + }; + let highlight = state.highlight.as_ref().map(|selected| LiveHighlight { + cells: &selected.cells, + }); + capture_live(state.emu.as_ref(), highlight, options, metadata) + .map_err(crate::snapshot::SnapshotError::into) + } + pub fn frame(&self) -> Option { let live = self .live diff --git a/crates/tui-test/src/lib.rs b/crates/tui-test/src/lib.rs index 230b7c20..35cb3bbb 100644 --- a/crates/tui-test/src/lib.rs +++ b/crates/tui-test/src/lib.rs @@ -11,6 +11,7 @@ pub mod record; pub mod render; pub mod runtime; pub mod shell; +pub mod snapshot; pub mod terminal; pub mod trace; @@ -24,4 +25,10 @@ pub use runtime::{ global_registry, Locator, LocatorClickOptions, LocatorExpectOptions, LocatorFilterOptions, Session, SessionHandle, SessionRegistry, }; +pub use snapshot::{ + SnapshotCell, SnapshotColor, SnapshotCursor, SnapshotCursorShape, SnapshotError, + SnapshotMetadata, SnapshotOptions, SnapshotParts, SnapshotScope, SnapshotSource, + SnapshotUnderline, TerminalSnapshot, MAX_CELL_TEXT_BYTES, MAX_LINK_BYTES, MAX_SNAPSHOT_CELLS, + MAX_SNAPSHOT_DIM, MAX_TITLE_BYTES, SNAPSHOT_SCHEMA_VERSION, +}; pub use terminal::backend::Backend; diff --git a/crates/tui-test/src/runtime.rs b/crates/tui-test/src/runtime.rs index daaa83bd..97c6e903 100644 --- a/crates/tui-test/src/runtime.rs +++ b/crates/tui-test/src/runtime.rs @@ -13,6 +13,7 @@ use crate::api::{ use crate::diagnostics::ExecutionContext; use crate::engine::Engine; use crate::logger::Logger; +use crate::snapshot::{SnapshotOptions, SnapshotScope, TerminalSnapshot}; const MAX_COMPLETED_RECORDINGS: usize = 1024; @@ -446,6 +447,27 @@ impl Session { self.execute(Operation::Close).map(|_| ()) } + /// Capture an owned snapshot of the selected scope through the session's + /// engine. + /// + /// Reads grid, cursor, dimensions, title, and resolved colors under one + /// terminal-state lock. Records raw visual data; the snapshot is fully + /// owned and stays usable after the session closes. + pub fn capture_snapshot(&self, scope: SnapshotScope) -> Result { + self.engine.capture_snapshot(scope) + } + + /// Capture an owned snapshot with explicit options. + /// + /// Pass [`SnapshotOptions::with_highlight`] to fold locator highlighting + /// into the captured cells instead of recording raw visual data. + pub fn capture_snapshot_with( + &self, + options: &SnapshotOptions, + ) -> Result { + self.engine.capture_snapshot_with(options) + } + /// Cancel pending operations, including startup readiness, and terminate the /// child without waiting for the operation queue or PTY input writer. pub fn interrupt(&self) { diff --git a/crates/tui-test/src/snapshot.rs b/crates/tui-test/src/snapshot.rs new file mode 100644 index 00000000..a1c9cd43 --- /dev/null +++ b/crates/tui-test/src/snapshot.rs @@ -0,0 +1,1368 @@ +//! A public owned snapshot of terminal state. +//! +//! [`TerminalSnapshot`] is the single owned model for live capture and offline +//! construction. Every field is private; read-only accessors expose the data. +//! Capture reads grid, cursor, dimensions, title, and resolved colors while it +//! holds one terminal-state lock, so a snapshot never mixes data from two +//! terminal moments. Observation metadata (capture time, revision, backend and +//! session identity, source kind) travels with the snapshot but stays out of +//! canonical equality and hashing: two snapshots are canonically equal when +//! their terminal state matches, however they were observed. +//! +//! The default capture records raw visual data. Locator highlighting never +//! leaks into it; a highlighted capture needs an explicit opt-in through +//! [`SnapshotOptions::with_highlight`]. + +use std::collections::HashSet; +use std::fmt; +use std::hash::{Hash, Hasher}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::profile::{ColorSlot, Rgb}; +use crate::terminal::cell::{Attrs, Color, UnderlineStyle, CONTINUATION}; +use crate::terminal::emu::{CursorShape, Emulator}; + +/// Wire schema understood by [`TerminalSnapshot`] deserialization. +/// +/// Bumped only when the serialized form changes incompatibly; older payloads +/// keep failing closed with [`SnapshotError::UnsupportedSchema`] instead of +/// being misread as the current layout. +pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; + +/// Largest accepted snapshot dimension in columns or rows. +/// +/// Matches the downstream image-dimension bound so a snapshot can always be +/// rendered without a second size check. +pub const MAX_SNAPSHOT_DIM: u32 = 32_768; + +/// Largest accepted cell count (`cols * rows`) in one snapshot. +/// +/// Matches the downstream per-sample cell bound. +pub const MAX_SNAPSHOT_CELLS: u64 = 1_000_000; + +/// Largest accepted UTF-8 byte length of one cell grapheme. +/// +/// Matches the downstream per-cell string bound. +pub const MAX_CELL_TEXT_BYTES: usize = 4_096; + +/// Largest accepted UTF-8 byte length of one hyperlink URI or link identity. +pub const MAX_LINK_BYTES: usize = 4_096; + +/// Largest accepted UTF-8 byte length of the window title. +pub const MAX_TITLE_BYTES: usize = 65_536; + +/// Which terminal region a snapshot covers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SnapshotScope { + /// The rows currently visible in the viewport. + Viewport, + /// The active screen, independent of viewport scrolling. + ActiveScreen, + /// Scrollback history followed by the active screen. + FullHistory, +} + +impl SnapshotScope { + /// The name this scope goes by on the wire. + pub const fn name(self) -> &'static str { + match self { + SnapshotScope::Viewport => "viewport", + SnapshotScope::ActiveScreen => "active_screen", + SnapshotScope::FullHistory => "full_history", + } + } +} + +/// How a snapshot cell refers to a color. +/// +/// `Default` means the terminal default and stays distinct from an explicit +/// [`SnapshotColor::Rgb`] even when both resolve to the same painted color. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SnapshotColor { + /// The terminal default foreground or background. + Default, + /// A palette entry, with indices 0-15 using the recorded SGR + /// normalization (both `SGR 31` and `SGR 38;5;1` land on slot 1). + Indexed(u8), + /// An explicit RGB color (`SGR 38;2;r;g;b`). + Rgb(u8, u8, u8), +} + +impl SnapshotColor { + /// The name or spelling this reference goes by on the wire. + pub fn wire_name(&self) -> String { + match self { + SnapshotColor::Default => "default".to_string(), + SnapshotColor::Indexed(index) => index.to_string(), + SnapshotColor::Rgb(r, g, b) => Rgb::new(*r, *g, *b).to_hex(), + } + } +} + +impl From for SnapshotColor { + fn from(color: Color) -> Self { + match color { + Color::Named(named) => SnapshotColor::Indexed(named.index()), + Color::Idx(index) => SnapshotColor::Indexed(index), + Color::Rgb(r, g, b) => SnapshotColor::Rgb(r, g, b), + } + } +} + +impl From> for SnapshotColor { + fn from(color: Option) -> Self { + color + .map(SnapshotColor::from) + .unwrap_or(SnapshotColor::Default) + } +} + +impl Serialize for SnapshotColor { + fn serialize(&self, serializer: S) -> Result { + match self { + SnapshotColor::Default => serializer.serialize_str("default"), + SnapshotColor::Indexed(index) => serializer.serialize_u8(*index), + SnapshotColor::Rgb(r, g, b) => serializer.serialize_str(&Rgb::new(*r, *g, *b).to_hex()), + } + } +} + +impl<'de> Deserialize<'de> for SnapshotColor { + fn deserialize>(deserializer: D) -> Result { + struct ColorVisitor; + + impl serde::de::Visitor<'_> for ColorVisitor { + type Value = SnapshotColor; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("\"default\", a palette index 0-255, or a #rrggbb color") + } + + fn visit_str(self, value: &str) -> Result { + if value == "default" { + return Ok(SnapshotColor::Default); + } + Rgb::parse(value) + .map(|rgb| SnapshotColor::Rgb(rgb.r, rgb.g, rgb.b)) + .map_err(|_| E::custom(format!("invalid snapshot color (got {value:?})"))) + } + + fn visit_u64(self, value: u64) -> Result { + u8::try_from(value) + .map(SnapshotColor::Indexed) + .map_err(|_| E::custom(format!("palette index out of range (got {value})"))) + } + + fn visit_i64(self, value: i64) -> Result { + u8::try_from(value) + .map(SnapshotColor::Indexed) + .map_err(|_| E::custom(format!("palette index out of range (got {value})"))) + } + } + + deserializer.deserialize_any(ColorVisitor) + } +} + +/// The shape of a cell underline, mirroring [`UnderlineStyle`] wire names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SnapshotUnderline { + None, + Single, + Double, + Curly, + Dotted, + Dashed, +} + +impl SnapshotUnderline { + /// The name this shape goes by on the wire. + pub const fn name(self) -> &'static str { + match self { + SnapshotUnderline::None => "none", + SnapshotUnderline::Single => "single", + SnapshotUnderline::Double => "double", + SnapshotUnderline::Curly => "curly", + SnapshotUnderline::Dotted => "dotted", + SnapshotUnderline::Dashed => "dashed", + } + } +} + +impl From for SnapshotUnderline { + fn from(style: UnderlineStyle) -> Self { + match style { + UnderlineStyle::None => SnapshotUnderline::None, + UnderlineStyle::Single => SnapshotUnderline::Single, + UnderlineStyle::Double => SnapshotUnderline::Double, + UnderlineStyle::Curly => SnapshotUnderline::Curly, + UnderlineStyle::Dotted => SnapshotUnderline::Dotted, + UnderlineStyle::Dashed => SnapshotUnderline::Dashed, + } + } +} + +/// One captured cell: grapheme, width, colors, attributes, and hyperlink. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct SnapshotCell { + text: String, + width: u16, + fg: SnapshotColor, + bg: SnapshotColor, + bold: bool, + dim: bool, + italic: bool, + inverse: bool, + invisible: bool, + strike: bool, + blink: bool, + underline: SnapshotUnderline, + underline_color: SnapshotColor, + link_uri: Option, + link_id: Option, +} + +impl SnapshotCell { + /// Build a cell, rejecting oversize text and links. + /// + /// Continuation placement (empty text away from column zero) and grid + /// bounds are checked by [`TerminalSnapshot::try_new`], which knows each + /// cell's coordinates. + #[allow(clippy::too_many_arguments)] + pub fn new( + text: String, + width: u16, + fg: SnapshotColor, + bg: SnapshotColor, + attrs: Attrs, + underline: SnapshotUnderline, + underline_color: SnapshotColor, + link_uri: Option, + link_id: Option, + ) -> Result { + if text.len() > MAX_CELL_TEXT_BYTES { + return Err(SnapshotError::OversizeText { + bytes: text.len(), + limit: MAX_CELL_TEXT_BYTES, + }); + } + if let Some(uri) = link_uri.as_deref() { + if uri.len() > MAX_LINK_BYTES { + return Err(SnapshotError::OversizeLink { + bytes: uri.len(), + limit: MAX_LINK_BYTES, + }); + } + } + if let Some(id) = link_id.as_deref() { + if id.len() > MAX_LINK_BYTES { + return Err(SnapshotError::OversizeLink { + bytes: id.len(), + limit: MAX_LINK_BYTES, + }); + } + } + Ok(SnapshotCell { + text, + width, + fg, + bg, + bold: attrs.contains(Attrs::BOLD), + dim: attrs.contains(Attrs::DIM), + italic: attrs.contains(Attrs::ITALIC), + inverse: attrs.contains(Attrs::INVERSE), + invisible: attrs.contains(Attrs::INVISIBLE), + strike: attrs.contains(Attrs::STRIKE), + blink: attrs.contains(Attrs::BLINK), + underline, + underline_color, + link_uri, + link_id, + }) + } + + /// The cell grapheme; empty for a continuation cell trailing a wide char. + pub fn text(&self) -> &str { + &self.text + } + + /// Terminal columns this cell occupies; zero for continuation cells. + pub fn width(&self) -> u16 { + self.width + } + + /// Whether this cell continues the wide character in the previous column. + pub fn is_continuation(&self) -> bool { + self.text.is_empty() + } + + /// Foreground color reference (`Default` is the terminal default). + pub fn fg(&self) -> SnapshotColor { + self.fg + } + + /// Background color reference (`Default` is the terminal default). + pub fn bg(&self) -> SnapshotColor { + self.bg + } + + /// Whether the cell carries the named boolean attribute. + pub fn has(&self, attr: Attrs) -> bool { + let mut probe = Attrs::empty(); + if self.bold { + probe |= Attrs::BOLD; + } + if self.dim { + probe |= Attrs::DIM; + } + if self.italic { + probe |= Attrs::ITALIC; + } + if self.inverse { + probe |= Attrs::INVERSE; + } + if self.invisible { + probe |= Attrs::INVISIBLE; + } + if self.strike { + probe |= Attrs::STRIKE; + } + if self.blink { + probe |= Attrs::BLINK; + } + probe.contains(attr) + } + + /// The underline shape; [`SnapshotUnderline::None`] when not underlined. + pub fn underline(&self) -> SnapshotUnderline { + self.underline + } + + /// Underline color reference (`Default` follows the cell foreground). + pub fn underline_color(&self) -> SnapshotColor { + self.underline_color + } + + /// The OSC 8 hyperlink URI, if the cell sits inside a link. + pub fn link_uri(&self) -> Option<&str> { + self.link_uri.as_deref() + } + + /// The link `id=` parameter, if the sequence carried one. + pub fn link_id(&self) -> Option<&str> { + self.link_id.as_deref() + } + + fn check_at(&self, col: u32, row: u32) -> Result<(), SnapshotError> { + if self.text.len() > MAX_CELL_TEXT_BYTES { + return Err(SnapshotError::CellTextTooLarge { + col, + row, + bytes: self.text.len(), + limit: MAX_CELL_TEXT_BYTES, + }); + } + if self.text.is_empty() && (col == 0 || self.width != 0) { + return Err(SnapshotError::BadContinuation { col, row }); + } + for uri in self.link_uri.iter().chain(self.link_id.iter()) { + if uri.len() > MAX_LINK_BYTES { + return Err(SnapshotError::LinkTooLarge { + col, + row, + bytes: uri.len(), + limit: MAX_LINK_BYTES, + }); + } + } + Ok(()) + } +} + +/// The shape the snapshot cursor is drawn as. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SnapshotCursorShape { + Block, + Underline, + Bar, +} + +impl SnapshotCursorShape { + /// The name this shape goes by on the wire. + pub const fn name(self) -> &'static str { + match self { + SnapshotCursorShape::Block => "block", + SnapshotCursorShape::Underline => "underline", + SnapshotCursorShape::Bar => "bar", + } + } +} + +impl From for SnapshotCursorShape { + fn from(shape: CursorShape) -> Self { + match shape { + CursorShape::Block => SnapshotCursorShape::Block, + CursorShape::Underline => SnapshotCursorShape::Underline, + CursorShape::Bar => SnapshotCursorShape::Bar, + } + } +} + +/// The captured cursor: position, visibility, shape, color, and blink intent. +/// +/// Position is in full-grid coordinates (history rows first, active screen +/// last), never clamped: a cursor outside the captured region keeps its real +/// position and reports [`SnapshotCursor::in_capture`] as `false`. Terminal +/// cursor visibility stays separate from capture clipping: a hidden cursor +/// still has a position, and a visible cursor can sit outside the capture. +/// +/// Blink is `None` when the backend does not report blink intent. An unknown +/// blink stays unknown; it is never normalized to `false`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +pub struct SnapshotCursor { + col: u32, + row: u32, + in_capture: bool, + visible: bool, + shape: SnapshotCursorShape, + color: Rgb, + blink: Option, +} + +impl SnapshotCursor { + /// Build a cursor value. Bounds against the capture grid are checked by + /// [`TerminalSnapshot::try_new`], which knows the grid dimensions. + #[allow(clippy::too_many_arguments)] + pub fn new( + col: u32, + row: u32, + in_capture: bool, + visible: bool, + shape: SnapshotCursorShape, + color: Rgb, + blink: Option, + ) -> Self { + SnapshotCursor { + col, + row, + in_capture, + visible, + shape, + color, + blink, + } + } + + /// Full-grid column (history rows share the same column frame). + pub fn col(&self) -> u32 { + self.col + } + + /// Full-grid row, counting from the oldest history row as zero. + pub fn row(&self) -> u32 { + self.row + } + + /// Whether the cursor position falls inside the captured grid. + pub fn in_capture(&self) -> bool { + self.in_capture + } + + /// Whether the terminal draws the cursor (`DECTCEM`). + pub fn visible(&self) -> bool { + self.visible + } + + /// The shape the terminal draws the cursor as. + pub fn shape(&self) -> SnapshotCursorShape { + self.shape + } + + /// The cursor color as currently resolved (`OSC 12` applied). + pub fn color(&self) -> Rgb { + self.color + } + + /// Blink intent: `Some(true)` blinking, `Some(false)` steady, `None` + /// when the backend does not report it. + pub fn blink(&self) -> Option { + self.blink + } + + fn hash_fields(&self, state: &mut H) { + self.col.hash(state); + self.row.hash(state); + self.in_capture.hash(state); + self.visible.hash(state); + self.shape.hash(state); + hash_rgb(&self.color, state); + self.blink.hash(state); + } + + fn check_in(&self, cols: u32, rows: u32, origin: u64) -> Result<(), SnapshotError> { + if !self.in_capture { + return Ok(()); + } + let out_of_bounds = || SnapshotError::CursorOutOfBounds { + col: self.col, + row: self.row, + cols, + rows, + }; + let local_row = u64::from(self.row) + .checked_sub(origin) + .ok_or_else(out_of_bounds)?; + if self.col >= cols || local_row >= u64::from(rows) { + return Err(SnapshotError::CursorOutOfBounds { + col: self.col, + row: self.row, + cols, + rows, + }); + } + Ok(()) + } +} + +/// Where a snapshot came from. Kept as observation metadata, outside +/// canonical equality and hashing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SnapshotSource { + /// Captured from a live terminal under one state lock. + Live, + /// Built offline through [`TerminalSnapshot::try_new`] or deserialization. + #[default] + Offline, +} + +/// Observation metadata: when and where a snapshot was taken. +/// +/// Every field is observation-only: changing metadata never changes canonical +/// equality or hashing. There are no invariants, so the fields are public. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case", deny_unknown_fields, default)] +pub struct SnapshotMetadata { + /// Milliseconds since the Unix epoch when the snapshot was taken. + pub captured_at_ms: u64, + /// Terminal visual revision at capture time (live captures only). + pub revision: u64, + /// Backend that produced the grid (`alacritty`, `ghostty`, ...). + pub backend: String, + /// Session the grid was captured from (live captures only). + pub session: String, + /// Whether the snapshot came from a live terminal or offline input. + pub source: SnapshotSource, +} + +/// The validated input bundle for [`TerminalSnapshot::try_new`]. +/// +/// Plain data with public fields; every invariant is checked by `try_new`, +/// which reports the offending field and coordinates. +#[derive(Debug, Clone)] +pub struct SnapshotParts { + /// Logical terminal columns. + pub cols: u32, + /// Logical terminal rows for the selected scope. + pub rows: u32, + /// Which terminal region the cells cover. + pub scope: SnapshotScope, + /// Full-grid index of the first captured row. + pub viewport_origin: u64, + /// Row-major cells, exactly `cols * rows` entries. + pub cells: Vec, + /// Every resolved palette entry, indices 0-255. + pub palette: [Rgb; 256], + /// Resolved default foreground (`OSC 10` applied). + pub default_foreground: Rgb, + /// Resolved default background (`OSC 11` applied). + pub default_background: Rgb, + /// Resolved cursor color (`OSC 12` applied). + pub default_cursor: Rgb, + /// The captured cursor, in full-grid coordinates. + pub cursor: SnapshotCursor, + /// Window title (`None` when the program set none). + pub title: Option, + /// Observation metadata, outside canonical equality. + pub metadata: SnapshotMetadata, +} + +/// A snapshot validation failure, naming the field or coordinates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SnapshotError { + /// No columns or rows (a snapshot always covers a non-empty grid). + EmptyDimensions, + /// A dimension exceeds [`MAX_SNAPSHOT_DIM`]. + DimensionTooLarge { cols: u64, rows: u64, limit: u32 }, + /// `cols * rows` exceeds [`MAX_SNAPSHOT_CELLS`]. + TooManyCells { cells: u64, limit: u64 }, + /// The cell vector length is not `cols * rows`. + CellCountMismatch { expected: u64, actual: usize }, + /// A wire palette that does not hold exactly 256 resolved entries. + PaletteLengthMismatch { actual: usize }, + /// A grid row does not hold exactly `cols` cells (live capture only). + MalformedRow { + row: u32, + expected: u32, + actual: usize, + }, + /// A cell grapheme exceeds [`MAX_CELL_TEXT_BYTES`]. + CellTextTooLarge { + col: u32, + row: u32, + bytes: usize, + limit: usize, + }, + /// A cell grapheme exceeds [`MAX_CELL_TEXT_BYTES`] (coordinate-free). + OversizeText { bytes: usize, limit: usize }, + /// A continuation cell (empty text) at column zero, or a non-continuation + /// width on empty text. + BadContinuation { col: u32, row: u32 }, + /// A hyperlink URI or identity exceeds [`MAX_LINK_BYTES`]. + LinkTooLarge { + col: u32, + row: u32, + bytes: usize, + limit: usize, + }, + /// A hyperlink URI or identity exceeds [`MAX_LINK_BYTES`] + /// (coordinate-free). + OversizeLink { bytes: usize, limit: usize }, + /// An in-capture cursor outside the captured grid. + CursorOutOfBounds { + col: u32, + row: u32, + cols: u32, + rows: u32, + }, + /// A nonzero viewport origin on a full-history snapshot. + BadViewportOrigin { scope: SnapshotScope, origin: u64 }, + /// The window title exceeds [`MAX_TITLE_BYTES`]. + TitleTooLarge { bytes: usize, limit: usize }, + /// A count that does not fit its narrow terminal interface. + CountOverflow { field: &'static str }, + /// A wire payload with an unknown schema version. + UnsupportedSchema { version: u32 }, + /// A wire color that is neither `default`, an index, nor `#rrggbb`. + InvalidColor { value: String }, +} + +impl fmt::Display for SnapshotError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SnapshotError::EmptyDimensions => f.write_str("snapshot dimensions must be nonzero"), + SnapshotError::DimensionTooLarge { cols, rows, limit } => { + write!( + f, + "snapshot dimensions {cols}x{rows} exceed the limit of {limit}" + ) + } + SnapshotError::TooManyCells { cells, limit } => { + write!( + f, + "snapshot cell count {cells} exceeds the limit of {limit}" + ) + } + SnapshotError::CellCountMismatch { expected, actual } => { + write!( + f, + "snapshot holds {actual} cells for a {expected}-cell grid" + ) + } + SnapshotError::PaletteLengthMismatch { actual } => { + write!(f, "snapshot palette holds {actual} entries, expected 256") + } + SnapshotError::MalformedRow { + row, + expected, + actual, + } => { + write!( + f, + "snapshot row {row} holds {actual} cells, expected {expected}" + ) + } + SnapshotError::CellTextTooLarge { + col, + row, + bytes, + limit, + } => { + write!( + f, + "snapshot cell ({col}, {row}) holds {bytes} bytes, limit {limit}" + ) + } + SnapshotError::OversizeText { bytes, limit } => { + write!(f, "snapshot cell text holds {bytes} bytes, limit {limit}") + } + SnapshotError::BadContinuation { col, row } => { + write!( + f, + "snapshot cell ({col}, {row}) is not a valid continuation cell" + ) + } + SnapshotError::LinkTooLarge { + col, + row, + bytes, + limit, + } => { + write!( + f, + "snapshot cell ({col}, {row}) link holds {bytes} bytes, limit {limit}" + ) + } + SnapshotError::OversizeLink { bytes, limit } => { + write!(f, "snapshot link holds {bytes} bytes, limit {limit}") + } + SnapshotError::CursorOutOfBounds { + col, + row, + cols, + rows, + } => { + write!( + f, + "in-capture cursor ({col}, {row}) falls outside a {cols}x{rows} grid" + ) + } + SnapshotError::BadViewportOrigin { scope, origin } => { + write!( + f, + "snapshot scope {} must start at origin 0 (got {origin})", + scope.name() + ) + } + SnapshotError::TitleTooLarge { bytes, limit } => { + write!(f, "snapshot title holds {bytes} bytes, limit {limit}") + } + SnapshotError::CountOverflow { field } => { + write!( + f, + "snapshot count {field} does not fit its terminal interface" + ) + } + SnapshotError::UnsupportedSchema { version } => { + write!( + f, + "unsupported snapshot schema {version} (this build reads {SNAPSHOT_SCHEMA_VERSION})" + ) + } + SnapshotError::InvalidColor { value } => { + write!(f, "invalid snapshot color (got {value:?})") + } + } + } +} + +impl std::error::Error for SnapshotError {} + +impl From for crate::api::TuiTestError { + fn from(error: SnapshotError) -> Self { + crate::api::TuiTestError::internal(error.to_string()) + } +} + +/// The owned terminal snapshot: validated grid, colors, cursor, and title. +/// +/// Fields are private; read-only accessors expose the data. Canonical +/// equality and hashing cover the terminal state (dimensions, cells, palette, +/// defaults, cursor, title, scope, origin) and skip [`SnapshotMetadata`]: +/// re-observing the same terminal state compares equal however it was +/// observed. +#[derive(Debug, Clone)] +pub struct TerminalSnapshot { + cols: u32, + rows: u32, + scope: SnapshotScope, + viewport_origin: u64, + cells: Vec, + palette: [Rgb; 256], + default_foreground: Rgb, + default_background: Rgb, + default_cursor: Rgb, + cursor: SnapshotCursor, + title: Option, + metadata: SnapshotMetadata, +} + +impl TerminalSnapshot { + /// Build a validated snapshot from parts. + /// + /// Rejects empty dimensions, oversize input, ragged cells, invalid + /// continuations, out-of-bounds in-capture cursors, misplaced origins, + /// and oversize titles and links, using checked arithmetic throughout. + /// Cell geometry is recorded, never recomputed from UTF-8 byte length. + pub fn try_new(parts: SnapshotParts) -> Result { + assemble(parts) + } + + /// Logical terminal columns. + pub fn cols(&self) -> u32 { + self.cols + } + + /// Logical terminal rows for the captured scope. + pub fn rows(&self) -> u32 { + self.rows + } + + /// Which terminal region the cells cover. + pub fn scope(&self) -> SnapshotScope { + self.scope + } + + /// Full-grid index of the first captured row. + pub fn viewport_origin(&self) -> u64 { + self.viewport_origin + } + + /// Row-major cells, exactly `cols * rows` entries. + pub fn cells(&self) -> &[SnapshotCell] { + &self.cells + } + + /// One grid row, or `None` past the last row. + pub fn row(&self, row: u32) -> Option<&[SnapshotCell]> { + let start = (u64::from(row)).checked_mul(u64::from(self.cols))?; + let start = usize::try_from(start).ok()?; + let end = start.checked_add(self.cols as usize)?; + self.cells.get(start..end) + } + + /// The cell at `(col, row)`, or `None` outside the grid. + pub fn cell_at(&self, col: u32, row: u32) -> Option<&SnapshotCell> { + self.row(row)?.get(col as usize) + } + + /// Every resolved palette entry, indices 0-255. + pub fn palette(&self) -> &[Rgb; 256] { + &self.palette + } + + /// Resolved default foreground (`OSC 10` applied). + pub fn default_foreground(&self) -> Rgb { + self.default_foreground + } + + /// Resolved default background (`OSC 11` applied). + pub fn default_background(&self) -> Rgb { + self.default_background + } + + /// Resolved cursor color (`OSC 12` applied). + pub fn default_cursor(&self) -> Rgb { + self.default_cursor + } + + /// The captured cursor, in full-grid coordinates. + pub fn cursor(&self) -> &SnapshotCursor { + &self.cursor + } + + /// Capture-local cursor coordinates, or `None` outside the capture. + pub fn cursor_local(&self) -> Option<(u32, u32)> { + if !self.cursor.in_capture { + return None; + } + let row64 = u64::from(self.cursor.row).checked_sub(self.viewport_origin)?; + let row = u32::try_from(row64).ok()?; + Some((self.cursor.col, row)) + } + + /// Window title (`None` when the program set none). + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + /// Observation metadata (capture time, revision, identities, source). + pub fn metadata(&self) -> &SnapshotMetadata { + &self.metadata + } +} + +impl TryFrom for TerminalSnapshot { + type Error = SnapshotError; + + fn try_from(parts: SnapshotParts) -> Result { + TerminalSnapshot::try_new(parts) + } +} + +impl PartialEq for TerminalSnapshot { + fn eq(&self, other: &Self) -> bool { + self.cols == other.cols + && self.rows == other.rows + && self.scope == other.scope + && self.viewport_origin == other.viewport_origin + && self.cells == other.cells + && self.palette == other.palette + && self.default_foreground == other.default_foreground + && self.default_background == other.default_background + && self.default_cursor == other.default_cursor + && self.cursor == other.cursor + && self.title == other.title + } +} + +impl Eq for TerminalSnapshot {} + +/// Hash one resolved color field by field (`Rgb` itself has no `Hash` impl). +fn hash_rgb(color: &Rgb, state: &mut H) { + color.r.hash(state); + color.g.hash(state); + color.b.hash(state); +} + +impl Hash for TerminalSnapshot { + fn hash(&self, state: &mut H) { + self.cols.hash(state); + self.rows.hash(state); + self.scope.hash(state); + self.viewport_origin.hash(state); + self.cells.hash(state); + for entry in &self.palette { + hash_rgb(entry, state); + } + hash_rgb(&self.default_foreground, state); + hash_rgb(&self.default_background, state); + hash_rgb(&self.default_cursor, state); + self.cursor.hash_fields(state); + self.title.hash(state); + } +} + +impl Hash for SnapshotCursor { + fn hash(&self, state: &mut H) { + self.hash_fields(state); + } +} + +/// Versioned wire form. Private: Serde on [`TerminalSnapshot`] goes through +/// it, so constructors and deserialization share [`assemble`] validation. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields)] +struct WireSnapshot { + #[serde(default)] + schema: u32, + cols: u32, + rows: u32, + scope: SnapshotScope, + viewport_origin: u64, + cells: Vec, + /// A sequence on the wire: serde has no array impl for 256 entries. + palette: Vec, + default_foreground: Rgb, + default_background: Rgb, + default_cursor: Rgb, + cursor: SnapshotCursor, + title: Option, + #[serde(default)] + metadata: SnapshotMetadata, +} + +impl Serialize for TerminalSnapshot { + fn serialize(&self, serializer: S) -> Result { + WireSnapshot { + schema: SNAPSHOT_SCHEMA_VERSION, + cols: self.cols, + rows: self.rows, + scope: self.scope, + viewport_origin: self.viewport_origin, + cells: self.cells.clone(), + palette: self.palette.to_vec(), + default_foreground: self.default_foreground, + default_background: self.default_background, + default_cursor: self.default_cursor, + cursor: self.cursor.clone(), + title: self.title.clone(), + metadata: self.metadata.clone(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for TerminalSnapshot { + fn deserialize>(deserializer: D) -> Result { + let wire = WireSnapshot::deserialize(deserializer)?; + if wire.schema != SNAPSHOT_SCHEMA_VERSION { + return Err(serde::de::Error::custom( + SnapshotError::UnsupportedSchema { + version: wire.schema, + } + .to_string(), + )); + } + let palette: [Rgb; 256] = wire.palette.try_into().map_err(|palette: Vec| { + serde::de::Error::custom( + SnapshotError::PaletteLengthMismatch { + actual: palette.len(), + } + .to_string(), + ) + })?; + assemble(SnapshotParts { + cols: wire.cols, + rows: wire.rows, + scope: wire.scope, + viewport_origin: wire.viewport_origin, + cells: wire.cells, + palette, + default_foreground: wire.default_foreground, + default_background: wire.default_background, + default_cursor: wire.default_cursor, + cursor: wire.cursor, + title: wire.title, + metadata: wire.metadata, + }) + .map_err(serde::de::Error::custom) + } +} + +/// Shared validation for [`TerminalSnapshot::try_new`] and deserialization. +fn assemble(parts: SnapshotParts) -> Result { + let SnapshotParts { + cols, + rows, + scope, + viewport_origin, + cells, + palette, + default_foreground, + default_background, + default_cursor, + cursor, + title, + metadata, + } = parts; + if cols == 0 || rows == 0 { + return Err(SnapshotError::EmptyDimensions); + } + if cols > MAX_SNAPSHOT_DIM || rows > MAX_SNAPSHOT_DIM { + return Err(SnapshotError::DimensionTooLarge { + cols: u64::from(cols), + rows: u64::from(rows), + limit: MAX_SNAPSHOT_DIM, + }); + } + let count = + u64::from(cols) + .checked_mul(u64::from(rows)) + .ok_or(SnapshotError::CountOverflow { + field: "cols * rows", + })?; + if count > MAX_SNAPSHOT_CELLS { + return Err(SnapshotError::TooManyCells { + cells: count, + limit: MAX_SNAPSHOT_CELLS, + }); + } + if cells.len() as u64 != count { + return Err(SnapshotError::CellCountMismatch { + expected: count, + actual: cells.len(), + }); + } + for (index, cell) in cells.iter().enumerate() { + let col = (index as u32) % cols; + let row = (index as u32) / cols; + cell.check_at(col, row)?; + } + if scope == SnapshotScope::FullHistory && viewport_origin != 0 { + return Err(SnapshotError::BadViewportOrigin { + scope, + origin: viewport_origin, + }); + } + if let Some(title) = title.as_deref() { + if title.len() > MAX_TITLE_BYTES { + return Err(SnapshotError::TitleTooLarge { + bytes: title.len(), + limit: MAX_TITLE_BYTES, + }); + } + } + cursor.check_in(cols, rows, viewport_origin)?; + Ok(TerminalSnapshot { + cols, + rows, + scope, + viewport_origin, + cells, + palette, + default_foreground, + default_background, + default_cursor, + cursor, + title, + metadata, + }) +} + +/// Capture options: scope plus an explicit highlighting opt-in. +/// +/// The default capture records raw visual data. A highlighted capture needs +/// [`SnapshotOptions::with_highlight`] set to `true`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnapshotOptions { + scope: SnapshotScope, + highlight: bool, +} + +impl SnapshotOptions { + /// Capture the selected scope with raw visual data. + pub fn new(scope: SnapshotScope) -> Self { + SnapshotOptions { + scope, + highlight: false, + } + } + + /// Which terminal region to capture. + pub fn scope(&self) -> SnapshotScope { + self.scope + } + + /// Whether locator highlighting is folded into the captured cells. + pub fn highlighted(&self) -> bool { + self.highlight + } + + /// Opt into (or out of) folding locator highlighting into the capture. + pub fn with_highlight(mut self, highlight: bool) -> Self { + self.highlight = highlight; + self + } +} + +impl From for SnapshotOptions { + fn from(scope: SnapshotScope) -> Self { + SnapshotOptions::new(scope) + } +} + +/// Highlighted cells in full-grid coordinates, borrowed from terminal state. +/// +/// The caller holds the terminal-state lock for the whole capture; this only +/// carries the reference across. +#[derive(Debug, Clone, Copy)] +pub(crate) struct LiveHighlight<'a> { + pub cells: &'a [(usize, usize)], +} + +/// Capture from a live emulator. The caller holds the terminal-state lock; +/// every field below (grid, cursor, dimensions, title, resolved colors) is +/// read under that one lock, so the snapshot is one atomic terminal moment. +/// +/// Highlighting applies only when `options.highlighted()` is set and a +/// highlight is supplied; the default capture keeps raw visual data. +pub(crate) fn capture_live( + emu: &dyn Emulator, + highlight: Option>, + options: &SnapshotOptions, + metadata: SnapshotMetadata, +) -> Result { + use crate::terminal::cell::display_width; + + let (cols16, _) = emu.size(); + let cols = u32::from(cols16); + if cols == 0 { + return Err(SnapshotError::EmptyDimensions); + } + if cols > MAX_SNAPSHOT_DIM { + return Err(SnapshotError::DimensionTooLarge { + cols: u64::from(cols), + rows: 0, + limit: MAX_SNAPSHOT_DIM, + }); + } + let viewport = emu.viewport(); + let history = viewport.history as u64; + let (rows_data, origin) = match options.scope() { + SnapshotScope::Viewport => (emu.viewable_rows(), viewport.origin() as u64), + SnapshotScope::ActiveScreen => (emu.active_rows(), history), + SnapshotScope::FullHistory => (emu.full_rows(), 0), + }; + let row_count = rows_data.len() as u64; + if row_count == 0 { + return Err(SnapshotError::EmptyDimensions); + } + if row_count > u64::from(MAX_SNAPSHOT_DIM) { + return Err(SnapshotError::DimensionTooLarge { + cols: u64::from(cols), + rows: row_count, + limit: MAX_SNAPSHOT_DIM, + }); + } + let rows = row_count as u32; + let count = row_count + .checked_mul(u64::from(cols)) + .ok_or(SnapshotError::CountOverflow { + field: "cols * rows", + })?; + if count > MAX_SNAPSHOT_CELLS { + return Err(SnapshotError::TooManyCells { + cells: count, + limit: MAX_SNAPSHOT_CELLS, + }); + } + + let highlighted: Option> = if options.highlighted() { + highlight.map(|selected| selected.cells.iter().map(|&(x, y)| (x, y as u64)).collect()) + } else { + None + }; + + let mut cells = Vec::new(); + cells + .try_reserve(rows_data.len().saturating_mul(cols as usize)) + .map_err(|_| SnapshotError::CountOverflow { + field: "snapshot cell buffer", + })?; + for (row_index, row) in rows_data.iter().enumerate() { + if row.len() as u64 != u64::from(cols) { + return Err(SnapshotError::MalformedRow { + row: row_index as u32, + expected: cols, + actual: row.len(), + }); + } + let full_row = origin.saturating_add(row_index as u64); + for (col_index, emu_cell) in row.iter().enumerate() { + let mut attrs = emu_cell.attrs; + if let Some(selected) = highlighted.as_ref() { + if selected.contains(&(col_index, full_row)) { + attrs.toggle(Attrs::INVERSE); + } + } + let (text, width) = if emu_cell.ch.as_str() == CONTINUATION { + (String::new(), 0) + } else { + let width = u16::try_from(display_width(emu_cell.ch.as_str())).map_err(|_| { + SnapshotError::CountOverflow { + field: "cell width", + } + })?; + (emu_cell.ch.to_string(), width) + }; + cells.push( + SnapshotCell::new( + text, + width, + SnapshotColor::from(emu_cell.fg), + SnapshotColor::from(emu_cell.bg), + attrs, + SnapshotUnderline::from(emu_cell.underline), + SnapshotColor::from(emu_cell.underline_color), + emu_cell.hyperlink.as_ref().map(|link| link.uri.to_string()), + emu_cell + .hyperlink + .as_ref() + .and_then(|link| link.id.as_deref()) + .map(str::to_string), + ) + .map_err(|error| match error { + SnapshotError::OversizeText { bytes, limit } => { + SnapshotError::CellTextTooLarge { + col: col_index as u32, + row: row_index as u32, + bytes, + limit, + } + } + SnapshotError::OversizeLink { bytes, limit } => SnapshotError::LinkTooLarge { + col: col_index as u32, + row: row_index as u32, + bytes, + limit, + }, + other => other, + })?, + ); + } + } + + let (cursor_x, cursor_y) = emu.cursor(); + let cursor_row = + history + .checked_add(u64::from(cursor_y)) + .ok_or(SnapshotError::CountOverflow { + field: "cursor row", + })?; + let cursor_row32: u32 = + cursor_row + .try_into() + .map_err(|_| SnapshotError::CountOverflow { + field: "cursor row", + })?; + let cursor_col = u32::from(cursor_x); + let in_capture = match options.scope() { + SnapshotScope::Viewport => { + cursor_row + .checked_sub(origin) + .is_some_and(|local| local < row_count) + && cursor_col < cols + } + SnapshotScope::ActiveScreen => { + if u64::from(cursor_y) >= row_count || cursor_col >= cols { + return Err(SnapshotError::CursorOutOfBounds { + col: cursor_col, + row: cursor_row32, + cols, + rows, + }); + } + true + } + SnapshotScope::FullHistory => { + if cursor_row >= row_count || cursor_col >= cols { + return Err(SnapshotError::CursorOutOfBounds { + col: cursor_col, + row: cursor_row32, + cols, + rows, + }); + } + true + } + }; + + let table = emu.colors(); + assemble(SnapshotParts { + cols, + rows, + scope: options.scope(), + viewport_origin: origin, + cells, + palette: table.palette, + default_foreground: table.foreground, + default_background: table.background, + default_cursor: table.cursor, + cursor: SnapshotCursor::new( + cursor_col, + cursor_row32, + in_capture, + emu.cursor_visible(), + SnapshotCursorShape::from(emu.cursor_shape()), + emu.color(ColorSlot::Cursor), + None, + ), + title: emu.title(), + metadata, + }) +} + +/// Current Unix time in milliseconds; zero when the clock is unavailable. +pub(crate) fn capture_time_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis().try_into().unwrap_or(u64::MAX)) + .unwrap_or(0) +} From f7acf831bd1506fedfa890d56fd554f8c1f0df6b Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:28:39 +0700 Subject: [PATCH 02/17] Cover snapshot validation, corpus, and lock-atomic capture with tests Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/snapshot.rs | 601 +++++++++++++++++++++++++++++++- 1 file changed, 595 insertions(+), 6 deletions(-) diff --git a/crates/tui-test/src/snapshot.rs b/crates/tui-test/src/snapshot.rs index a1c9cd43..a0128c32 100644 --- a/crates/tui-test/src/snapshot.rs +++ b/crates/tui-test/src/snapshot.rs @@ -1296,12 +1296,11 @@ pub(crate) fn capture_live( .ok_or(SnapshotError::CountOverflow { field: "cursor row", })?; - let cursor_row32: u32 = - cursor_row - .try_into() - .map_err(|_| SnapshotError::CountOverflow { - field: "cursor row", - })?; + let cursor_row32: u32 = cursor_row + .try_into() + .map_err(|_| SnapshotError::CountOverflow { + field: "cursor row", + })?; let cursor_col = u32::from(cursor_x); let in_capture = match options.scope() { SnapshotScope::Viewport => { @@ -1366,3 +1365,593 @@ pub(crate) fn capture_time_ms() -> u64 { .map(|elapsed| elapsed.as_millis().try_into().unwrap_or(u64::MAX)) .unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::{Profile, Rgb}; + use crate::terminal::alacritty::AlacrittyEmu; + use std::collections::hash_map::DefaultHasher; + use std::sync::{Arc, Barrier, Mutex}; + + const COLS: u16 = 10; + const ROWS: u16 = 6; + + fn profile() -> Profile { + Profile::default() + } + + fn resolved_palette(profile: &Profile) -> [Rgb; 256] { + std::array::from_fn(|index| profile.colors.rgb(index as u8)) + } + + fn blank_parts(profile: &Profile, cols: u32, rows: u32) -> SnapshotParts { + let palette = resolved_palette(profile); + SnapshotParts { + cols, + rows, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells: (0..cols * rows) + .map(|_| { + SnapshotCell::new( + " ".to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("blank cell is valid") + }) + .collect(), + palette, + default_foreground: palette[0], + default_background: palette[0], + default_cursor: palette[0], + cursor: SnapshotCursor::new( + 0, + 0, + true, + true, + SnapshotCursorShape::Block, + palette[0], + None, + ), + title: None, + metadata: SnapshotMetadata::default(), + } + } + + fn canonical_hash(snapshot: &TerminalSnapshot) -> u64 { + let mut hasher = DefaultHasher::new(); + snapshot.hash(&mut hasher); + hasher.finish() + } + + /// Feed the Unicode corpus: combining marks, wide char with continuation, + /// bold/italic/bold-italic, and a wide character at the last column. + fn feed_corpus(emu: &mut AlacrittyEmu) { + emu.process(b"\x1b[2J\x1b[H"); + // e + combining acute, a + combining diaeresis, wide char, styles. + emu.process("é ä 東 \x1b[1mbold\x1b[0m".as_bytes()); + emu.process(b"\r\n\x1b[3mitalic\x1b[0m \x1b[1;3mbi\x1b[0m"); + // Wide character exactly filling the last two columns. The earlier + // wrap consumed an extra row, so the grid is one row deeper here. + emu.process("\r\n 東".as_bytes()); + // Park the cursor over the wide character. + emu.process(b"\x1b[4;9H"); + } + + #[test] + fn corpus_cells_keep_recorded_geometry() { + let mut emu = AlacrittyEmu::new(COLS, ROWS, &profile()); + feed_corpus(&mut emu); + let snapshot = capture_live( + &emu, + None, + &SnapshotOptions::new(SnapshotScope::Viewport), + SnapshotMetadata::default(), + ) + .expect("corpus capture succeeds"); + assert_eq!( + (snapshot.cols(), snapshot.rows()), + (u32::from(COLS), u32::from(ROWS)) + ); + + let first = snapshot.row(0).expect("first row exists"); + assert_eq!(first[0].text(), "é"); + assert_eq!(first[0].width(), 1); + assert_eq!(first[2].text(), "ä"); + assert_eq!(first[4].text(), "東"); + assert_eq!(first[4].width(), 2); + assert!(first[5].is_continuation()); + assert_eq!(first[5].width(), 0); + + let bold = first + .iter() + .find(|cell| cell.text() == "b") + .expect("bold text"); + assert!(bold.has(Attrs::BOLD)); + assert!(!bold.has(Attrs::ITALIC)); + + // Style runs may wrap onto later rows; find them grid-wide. + let styled: Vec<&SnapshotCell> = snapshot.cells().iter().collect(); + let bold_only = styled + .iter() + .find(|cell| cell.text() == "o" && cell.has(Attrs::BOLD) && !cell.has(Attrs::ITALIC)) + .expect("bold-only text"); + let _ = bold_only; + let italic_only = styled + .iter() + .find(|cell| cell.text() == "t" && cell.has(Attrs::ITALIC) && !cell.has(Attrs::BOLD)) + .expect("italic-only text"); + let _ = italic_only; + let both = styled + .iter() + .find(|cell| cell.text() == "i" && cell.has(Attrs::BOLD | Attrs::ITALIC)) + .expect("bold-italic text"); + let _ = both; + + // Wide character at the last column boundary plus cursor over it. + let wide_row = snapshot.row(3).expect("wide row exists"); + assert_eq!(wide_row[8].text(), "東"); + assert_eq!(wide_row[8].width(), 2); + assert!(wide_row[9].is_continuation()); + assert_eq!(snapshot.cursor_local(), Some((8, 3))); + // Blink is unknown at this backend: recorded as unknown, not false. + assert_eq!(snapshot.cursor().blink(), None); + } + + #[test] + fn sgr_index_normalization_is_recorded() { + let mut emu = AlacrittyEmu::new(COLS, ROWS, &profile()); + emu.process(b"\x1b[31mA\x1b[38;5;1mB\x1b[38;2;10;20;30mC\x1b[0mD"); + let snapshot = capture_live( + &emu, + None, + &SnapshotOptions::new(SnapshotScope::Viewport), + SnapshotMetadata::default(), + ) + .expect("sgr capture succeeds"); + let row = snapshot.row(0).expect("first row exists"); + // Both SGR spellings of slot 1 land on the same recorded reference. + assert_eq!(row[0].fg(), SnapshotColor::Indexed(1)); + assert_eq!(row[1].fg(), SnapshotColor::Indexed(1)); + assert_eq!(row[2].fg(), SnapshotColor::Rgb(10, 20, 30)); + assert_eq!(row[3].fg(), SnapshotColor::Default); + } + + #[test] + fn default_capture_ignores_highlighting() { + let mut emu = AlacrittyEmu::new(COLS, ROWS, &profile()); + emu.process(b"highlight me"); + let selected = [(0usize, 0usize), (1, 0)]; + let highlight = LiveHighlight { cells: &selected }; + let metadata = SnapshotMetadata::default(); + let raw = capture_live( + &emu, + Some(highlight), + &SnapshotOptions::new(SnapshotScope::Viewport), + metadata.clone(), + ) + .expect("raw capture succeeds"); + let marked = capture_live( + &emu, + Some(highlight), + &SnapshotOptions::new(SnapshotScope::Viewport).with_highlight(true), + metadata, + ) + .expect("highlighted capture succeeds"); + assert!(!raw.cell_at(0, 0).expect("cell").has(Attrs::INVERSE)); + assert!(marked.cell_at(0, 0).expect("cell").has(Attrs::INVERSE)); + assert!(marked.cell_at(1, 0).expect("cell").has(Attrs::INVERSE)); + assert!(!marked.cell_at(2, 0).expect("cell").has(Attrs::INVERSE)); + assert_ne!(raw, marked); + } + + /// U02-A: transitions change grid, cursor, dimensions, and palette while + /// the terminal-state lock is held; barriers pin each capture between two + /// locked changes, so every capture must equal one permitted state. + #[test] + fn atomic_capture_matches_one_permitted_state() { + struct Transition { + apply: Box, + scope: SnapshotScope, + } + + let profile = profile(); + let emu = Arc::new(Mutex::new(AlacrittyEmu::new(COLS, ROWS, &profile))); + let barrier = Arc::new(Barrier::new(2)); + + let transitions = Arc::new(vec![ + Transition { + apply: Box::new(|emu| { + feed_corpus(emu); + emu.process(b"\x1b[1;1H\x1b[7mR\x1b[0m"); + emu.process(b"\x1b]4;1;#112233\x1b\\"); + emu.process(b"\x1b]10;#010203\x1b\\\x1b]11;#040506\x1b\\\x1b]12;#070809\x1b\\"); + emu.process(b"\x1b]0;snap-title\x1b\\"); + emu.process(b"\x1b]8;id=u02a;https://example.com\x1b\\L\x1b]8;;\x1b\\"); + }), + scope: SnapshotScope::Viewport, + }, + Transition { + apply: Box::new(|emu| { + emu.process(b"\x1b]4;1;#445566\x1b\\\x1b]4;200;#aabbcc\x1b\\"); + emu.process(b"\x1b]10;#0a0b0c\x1b\\"); + emu.process(b"\x1b[5;5H\x1b[?25l"); + emu.process(b"\x1b]0;snap-title-2\x1b\\"); + }), + scope: SnapshotScope::Viewport, + }, + Transition { + apply: Box::new(|emu| { + emu.resize(20, 8); + for line in 0..12 { + emu.process(format!("line {line:02}\r\n").as_bytes()); + } + emu.scroll(crate::api::ScrollTarget::Lines(2)); + }), + scope: SnapshotScope::Viewport, + }, + ]); + + let writer_emu = Arc::clone(&emu); + let writer_barrier = Arc::clone(&barrier); + let writer_transitions = Arc::clone(&transitions); + let writer = std::thread::spawn(move || { + for transition in writer_transitions.iter() { + { + let mut guard = writer_emu.lock().expect("writer locks the emulator"); + (transition.apply)(&mut guard); + } + writer_barrier.wait(); + writer_barrier.wait(); + } + }); + + let mut permitted: Vec = Vec::new(); + for transition in transitions.iter() { + barrier.wait(); + let options = SnapshotOptions::new(transition.scope); + let first = { + let guard = emu.lock().expect("reader locks the emulator"); + capture_live( + &*guard, + None, + &options, + SnapshotMetadata { + captured_at_ms: 1, + revision: permitted.len() as u64, + backend: "alacritty".to_string(), + session: "u02-a".to_string(), + source: SnapshotSource::Live, + }, + ) + .expect("capture under lock succeeds") + }; + let second = { + let guard = emu.lock().expect("reader locks the emulator"); + capture_live( + &*guard, + None, + &options, + SnapshotMetadata { + captured_at_ms: 2, + revision: 100 + permitted.len() as u64, + backend: "other".to_string(), + session: "u02-a".to_string(), + source: SnapshotSource::Live, + }, + ) + .expect("recapture under lock succeeds") + }; + // Same locked state, observation-only metadata differs: canonical + // equality and hashing ignore it (C07). + assert_eq!(first, second, "metadata-only change passes"); + assert_eq!(canonical_hash(&first), canonical_hash(&second)); + // The capture is stable: no tearing between the two locked reads. + assert!(permitted + .iter() + .all(|known: &TerminalSnapshot| *known != first)); + assert!(permitted + .iter() + .all(|known: &TerminalSnapshot| *known != second)); + permitted.push(first); + barrier.wait(); + } + writer.join().expect("writer finishes"); + + assert_eq!(permitted.len(), 3); + + // C04: corpus text, continuation, styles, and cursor survived T0. + let base = &permitted[0]; + assert_eq!((base.cols(), base.rows()), (10, 6)); + assert_eq!(base.title(), Some("snap-title")); + assert_eq!(base.palette()[1], Rgb::new(0x11, 0x22, 0x33)); + assert_eq!(base.default_foreground(), Rgb::new(0x01, 0x02, 0x03)); + assert_eq!(base.default_background(), Rgb::new(0x04, 0x05, 0x06)); + assert_eq!(base.default_cursor(), Rgb::new(0x07, 0x08, 0x09)); + let reversed = base.cell_at(0, 0).expect("reversed cell"); + assert_eq!(reversed.text(), "R"); + assert!(reversed.has(Attrs::INVERSE)); + // Full-history coordinates use u32, never u16. + let full = { + let guard = emu.lock().expect("reader locks the emulator"); + capture_live( + &*guard, + None, + &SnapshotOptions::new(SnapshotScope::FullHistory), + SnapshotMetadata::default(), + ) + .expect("full-history capture succeeds") + }; + assert_eq!(full.scope(), SnapshotScope::FullHistory); + assert_eq!(full.viewport_origin(), 0); + assert!(full.rows() >= base.rows()); + + // C05: T1 changed palette, defaults, cursor visibility, and title. + let changed = &permitted[1]; + assert_eq!(changed.palette()[1], Rgb::new(0x44, 0x55, 0x66)); + assert_eq!(changed.palette()[200], Rgb::new(0xaa, 0xbb, 0xcc)); + assert_eq!(changed.default_foreground(), Rgb::new(0x0a, 0x0b, 0x0c)); + assert!(!changed.cursor().visible()); + assert_eq!(changed.title(), Some("snap-title-2")); + + // T2 resized, grew history, and scrolled: the cursor is recorded + // outside the viewport capture, unclamped, with no local coords. + let scrolled = &permitted[2]; + assert_eq!((scrolled.cols(), scrolled.rows()), (20, 8)); + assert!(!scrolled.cursor().in_capture()); + assert_eq!(scrolled.cursor_local(), None); + // The position is explicit, not clamped into the 20x8 grid. + assert!( + u64::from(scrolled.cursor().row()) + >= scrolled.viewport_origin() + u64::from(scrolled.rows()) + ); + assert_eq!(scrolled.viewport_origin(), { + let guard = emu.lock().expect("reader locks the emulator"); + guard.viewport().origin() as u64 + }); + for row in 0..scrolled.rows() { + assert_eq!( + scrolled.row(row).expect("row exists").len() as u32, + scrolled.cols(), + "rectangular geometry after resize and scroll" + ); + } + } + + #[test] + fn constructor_and_json_reject_identical_invalid_geometry() { + let profile = profile(); + // Row-major length does not match cols * rows. + let mut mismatch = blank_parts(&profile, 2, 2); + mismatch.cells.pop(); + assert_eq!( + TerminalSnapshot::try_new(mismatch.clone()), + Err(SnapshotError::CellCountMismatch { + expected: 4, + actual: 3 + }) + ); + + // Continuation cell at column zero. + let mut bad_continuation = blank_parts(&profile, 2, 1); + bad_continuation.cells[0] = SnapshotCell::new( + String::new(), + 0, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("coordinate-free construction succeeds"); + assert_eq!( + TerminalSnapshot::try_new(bad_continuation), + Err(SnapshotError::BadContinuation { col: 0, row: 0 }) + ); + + // In-capture cursor outside the grid. + let mut bad_cursor = blank_parts(&profile, 2, 2); + bad_cursor.cursor = SnapshotCursor::new( + 7, + 0, + true, + true, + SnapshotCursorShape::Block, + Rgb::new(0, 0, 0), + None, + ); + assert_eq!( + TerminalSnapshot::try_new(bad_cursor), + Err(SnapshotError::CursorOutOfBounds { + col: 7, + row: 0, + cols: 2, + rows: 2 + }) + ); + + // Full-history snapshots start at origin zero. + let mut bad_origin = blank_parts(&profile, 2, 2); + bad_origin.scope = SnapshotScope::FullHistory; + bad_origin.viewport_origin = 3; + assert_eq!( + TerminalSnapshot::try_new(bad_origin), + Err(SnapshotError::BadViewportOrigin { + scope: SnapshotScope::FullHistory, + origin: 3 + }) + ); + + // The same malformed grids fail through JSON deserialization (U02-F). + let valid = TerminalSnapshot::try_new(blank_parts(&profile, 2, 2)).expect("valid"); + let mut wire = serde_json::to_value(&valid).expect("serializes"); + wire["cells"].as_array_mut().expect("cells array").pop(); + assert!(serde_json::from_value::(wire).is_err()); + + let mut wire = serde_json::to_value(&valid).expect("serializes"); + wire["schema"] = serde_json::json!(999); + assert_eq!( + serde_json::from_value::(wire) + .expect_err("unsupported schema fails") + .to_string() + .contains("unsupported snapshot schema"), + true + ); + } + + #[test] + fn oversize_input_is_rejected() { + let profile = profile(); + assert_eq!( + TerminalSnapshot::try_new(SnapshotParts { + cols: MAX_SNAPSHOT_DIM + 1, + ..blank_parts(&profile, 2, 2) + }), + Err(SnapshotError::DimensionTooLarge { + cols: u64::from(MAX_SNAPSHOT_DIM + 1), + rows: 2, + limit: MAX_SNAPSHOT_DIM + }) + ); + assert_eq!( + TerminalSnapshot::try_new(SnapshotParts { + cols: 0, + ..blank_parts(&profile, 2, 2) + }), + Err(SnapshotError::EmptyDimensions) + ); + // 32768 x 32768 exceeds the cell budget without exceeding a dimension. + assert_eq!( + TerminalSnapshot::try_new(SnapshotParts { + cols: MAX_SNAPSHOT_DIM, + rows: MAX_SNAPSHOT_DIM, + ..blank_parts(&profile, 2, 2) + }), + Err(SnapshotError::TooManyCells { + cells: u64::from(MAX_SNAPSHOT_DIM) * u64::from(MAX_SNAPSHOT_DIM), + limit: MAX_SNAPSHOT_CELLS + }) + ); + assert!(SnapshotCell::new( + "x".repeat(MAX_CELL_TEXT_BYTES + 1), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .is_err()); + } + + #[test] + fn default_and_explicit_rgb_stay_distinct() { + let profile = profile(); + let palette = resolved_palette(&profile); + let mut defaulted = blank_parts(&profile, 1, 1); + defaulted.default_foreground = palette[3]; + defaulted.cells[0] = SnapshotCell::new( + "x".to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("cell is valid"); + let mut explicit = defaulted.clone(); + explicit.cells[0] = SnapshotCell::new( + "x".to_string(), + 1, + SnapshotColor::Rgb(palette[3].r, palette[3].g, palette[3].b), + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("cell is valid"); + let a = TerminalSnapshot::try_new(defaulted).expect("valid"); + let b = TerminalSnapshot::try_new(explicit).expect("valid"); + // Equal resolved colors do not erase different color-reference kinds. + assert_ne!(a, b); + assert_eq!(a.cells()[0].fg(), SnapshotColor::Default); + } + + #[test] + fn unreferenced_palette_entry_changes_canonical_state() { + let profile = profile(); + let left = TerminalSnapshot::try_new(blank_parts(&profile, 1, 1)).expect("valid"); + let mut parts = blank_parts(&profile, 1, 1); + parts.palette[77] = Rgb::new(1, 2, 3); + let right = TerminalSnapshot::try_new(parts).expect("valid"); + assert_ne!(left, right); + assert_ne!(canonical_hash(&left), canonical_hash(&right)); + } + + #[test] + fn round_trip_preserves_palette_and_links() { + let profile = profile(); + let mut parts = blank_parts(&profile, 3, 1); + parts.palette[1] = Rgb::new(0x11, 0x22, 0x33); + parts.palette[200] = Rgb::new(0xaa, 0xbb, 0xcc); + parts.title = Some("round-trip".to_string()); + parts.cursor = SnapshotCursor::new( + 2, + 0, + true, + true, + SnapshotCursorShape::Underline, + Rgb::new(9, 9, 9), + Some(true), + ); + parts.cells[0] = SnapshotCell::new( + "L".to_string(), + 1, + SnapshotColor::Indexed(200), + SnapshotColor::Rgb(1, 2, 3), + Attrs::BOLD | Attrs::INVERSE, + SnapshotUnderline::Curly, + SnapshotColor::Indexed(1), + Some("https://example.com/x".to_string()), + Some("wrap-id".to_string()), + ) + .expect("cell is valid"); + let snapshot = TerminalSnapshot::try_new(parts).expect("valid"); + let json = serde_json::to_string(&snapshot).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(snapshot, back); + assert_eq!(canonical_hash(&snapshot), canonical_hash(&back)); + assert_eq!(back.palette()[200], Rgb::new(0xaa, 0xbb, 0xcc)); + assert_eq!( + back.cell_at(0, 0).expect("cell").link_uri(), + Some("https://example.com/x") + ); + assert_eq!(back.cell_at(0, 0).expect("cell").link_id(), Some("wrap-id")); + assert_eq!(back.cursor().blink(), Some(true)); + assert!(back + .cell_at(0, 0) + .expect("cell") + .has(Attrs::BOLD | Attrs::INVERSE)); + assert_eq!( + back.cell_at(0, 0).expect("cell").underline(), + SnapshotUnderline::Curly + ); + } +} From 9e13fc1173279c14022d42e41053ae55ecda1c3c Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:31:33 +0700 Subject: [PATCH 03/17] Add owned_snapshot acceptance tests U02-A through U02-K Signed-off-by: Alexey Zhokhov --- crates/tui-test/tests/owned_snapshot.rs | 618 ++++++++++++++++++++++++ 1 file changed, 618 insertions(+) create mode 100644 crates/tui-test/tests/owned_snapshot.rs diff --git a/crates/tui-test/tests/owned_snapshot.rs b/crates/tui-test/tests/owned_snapshot.rs new file mode 100644 index 00000000..95b3d44b --- /dev/null +++ b/crates/tui-test/tests/owned_snapshot.rs @@ -0,0 +1,618 @@ +//! U02 acceptance: the public owned snapshot over live sessions and offline input. +//! +//! Live content is produced by the child (`printf` at startup, then `sleep`), +//! so no PTY echo path can mangle escape sequences. Each capture is polled to +//! a known marker before asserting, so no assertion depends on timing. + +#![cfg(unix)] + +use std::time::{Duration, Instant}; + +use tui_test::profile::{Profile, Rgb}; +use tui_test::terminal::cell::Attrs; +use tui_test::{ + OpenOptions, Operation, OperationResult, RunOptions, ScrollTarget, Session, SnapshotCell, + SnapshotColor, SnapshotCursor, SnapshotCursorShape, SnapshotError, SnapshotMetadata, + SnapshotOptions, SnapshotParts, SnapshotScope, SnapshotSource, SnapshotUnderline, + TerminalSnapshot, MAX_CELL_TEXT_BYTES, MAX_SNAPSHOT_CELLS, MAX_SNAPSHOT_DIM, +}; + +fn session_with_script(test: &str, script: &str, cols: u16, rows: u16) -> Session { + let session = Session::new(format!("owned-snapshot-{test}-{}", std::process::id())); + let defaults = OpenOptions::default(); + session + .run(RunOptions { + backend: defaults.backend, + program: "sh".to_string(), + args: vec!["-c".to_string(), format!("printf '{script}'; sleep 30")], + profile: defaults.profile, + style: defaults.style, + cols, + rows, + cwd: defaults.cwd, + env: defaults.env, + wait_ready: Some(false), + restart: defaults.restart, + timeouts: defaults.timeouts, + recording: defaults.recording, + }) + .expect("session starts"); + session +} + +fn live_text(session: &Session) -> String { + match session.execute(Operation::Text { full: false }) { + Ok(OperationResult::Text(text)) => text, + other => panic!("text operation gave {other:?}"), + } +} + +fn poll_text(session: &Session, needle: &str) { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + if live_text(session).contains(needle) { + return; + } + if Instant::now() >= deadline { + panic!( + "timed out waiting for {needle:?}; got {:?}", + live_text(session) + ); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn close(session: &Session) { + session.close().expect("session closes"); +} + +fn resolved_palette() -> [Rgb; 256] { + let profile = Profile::default(); + std::array::from_fn(|index| profile.colors.rgb(index as u8)) +} + +fn blank_parts(cols: u32, rows: u32) -> SnapshotParts { + let palette = resolved_palette(); + SnapshotParts { + cols, + rows, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells: (0..cols * rows) + .map(|_| { + SnapshotCell::new( + " ".to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("blank cell is valid") + }) + .collect(), + palette, + default_foreground: Rgb::new(1, 2, 3), + default_background: Rgb::new(4, 5, 6), + default_cursor: Rgb::new(7, 8, 9), + cursor: SnapshotCursor::new( + 0, + 0, + true, + true, + SnapshotCursorShape::Block, + Rgb::new(7, 8, 9), + None, + ), + title: None, + metadata: SnapshotMetadata::default(), + } +} + +fn assert_rectangular(snapshot: &TerminalSnapshot) { + assert_ne!((snapshot.cols(), snapshot.rows()), (0, 0)); + assert_eq!( + snapshot.cells().len() as u64, + u64::from(snapshot.cols()) * u64::from(snapshot.rows()) + ); + for row in 0..snapshot.rows() { + assert_eq!( + snapshot.row(row).expect("row exists").len() as u32, + snapshot.cols(), + "row {row} keeps rectangular geometry" + ); + } + if snapshot.cursor().in_capture() { + let (col, row) = snapshot.cursor_local().expect("local cursor"); + assert!(col < snapshot.cols()); + assert!(row < snapshot.rows()); + } +} + +/// U02-A: live transitions across the Unicode corpus, styles, palette, +/// defaults, underline, reverse, cursor, links, and title stay coherent. +#[test] +fn u02_a_live_transitions_stay_coherent() { + let script = concat!( + "\\033[2J\\033[H", + "é ä 東 \\033[1mbold\\033[0m\\r\\n", + "\\033[3mitalic\\033[0m \\033[1;3mbi\\033[0m\\r\\n", + "\\033[31mR1\\033[38;5;2mR2\\033[38;2;9;9;9mR3\\033[0m\\r\\n", + "\\033[4:3mU\\033[58;5;200mV\\033[7mW\\033[0m\\r\\n", + " 東\\r\\n", + "\\033]4;7;#123456\\033\\\\", + "\\033]10;#111111\\033\\\\\\033]11;#222222\\033\\\\\\033]12;#333333\\033\\\\", + "\\033]8;id=live;https://example.com/live\\033\\\\LINK\\033]8;;\\033\\\\\\r\\n", + "\\033]0;live-title\\033\\\\", + "\\033[5;79H", + ); + let session = session_with_script("a", script, 80, 24); + poll_text(&session, "LINK"); + + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("viewport capture succeeds"); + assert_rectangular(&snapshot); + assert_eq!((snapshot.cols(), snapshot.rows()), (80, 24)); + assert_eq!(snapshot.scope(), SnapshotScope::Viewport); + + // C04: combining marks, wide char with continuation, last-column wide char. + assert_eq!(snapshot.cell_at(0, 0).expect("cell").text(), "é"); + assert_eq!(snapshot.cell_at(2, 0).expect("cell").text(), "ä"); + assert_eq!(snapshot.cell_at(4, 0).expect("cell").text(), "東"); + assert_eq!(snapshot.cell_at(4, 0).expect("cell").width(), 2); + assert!(snapshot.cell_at(5, 0).expect("cell").is_continuation()); + let wide = snapshot.cell_at(78, 4).expect("last-column wide char"); + assert_eq!(wide.text(), "東"); + assert_eq!(wide.width(), 2); + assert!(snapshot.cell_at(79, 4).expect("cell").is_continuation()); + assert_eq!(snapshot.cursor_local(), Some((78, 4))); + + // Bold, italic, and bold-italic are recorded. + let cells = snapshot.cells(); + assert!(cells + .iter() + .any(|cell| cell.text() == "b" && cell.has(Attrs::BOLD) && !cell.has(Attrs::ITALIC))); + assert!(cells + .iter() + .any(|cell| cell.text() == "t" && cell.has(Attrs::ITALIC) && !cell.has(Attrs::BOLD))); + assert!(cells + .iter() + .any(|cell| cell.text() == "i" && cell.has(Attrs::BOLD | Attrs::ITALIC))); + + // SGR 0-15 normalization kept: 31 and 38;5;2 are distinct recorded slots. + assert!(cells + .iter() + .any(|cell| cell.text() == "R" && cell.fg() == SnapshotColor::Indexed(1))); + assert!(cells + .iter() + .any(|cell| cell.text() == "R" && cell.fg() == SnapshotColor::Indexed(2))); + assert!(cells + .iter() + .any(|cell| cell.text() == "R" && cell.fg() == SnapshotColor::Rgb(9, 9, 9))); + assert!(cells + .iter() + .any(|cell| cell.text() == "3" && cell.fg() == SnapshotColor::Rgb(9, 9, 9))); + + // Curly underline with explicit color, reverse video, hyperlink, title. + let underlined = cells + .iter() + .find(|cell| cell.text() == "U") + .expect("U cell"); + assert_eq!(underlined.underline(), SnapshotUnderline::Curly); + assert_eq!(underlined.underline_color(), SnapshotColor::Default); + let colored_underline = cells + .iter() + .find(|cell| cell.text() == "V") + .expect("V cell"); + assert_eq!(colored_underline.underline(), SnapshotUnderline::Curly); + assert_eq!( + colored_underline.underline_color(), + SnapshotColor::Indexed(200) + ); + assert!(cells + .iter() + .any(|cell| cell.text() == "W" && cell.has(Attrs::INVERSE))); + let linked = cells + .iter() + .find(|cell| cell.text() == "L") + .expect("link cell"); + assert_eq!(linked.link_uri(), Some("https://example.com/live")); + assert_eq!(linked.link_id(), Some("live")); + assert_eq!(snapshot.title(), Some("live-title")); + + // C05: palette and default overrides are recorded through Emulator::colors. + assert_eq!(snapshot.palette()[7], Rgb::new(0x12, 0x34, 0x56)); + assert_eq!(snapshot.default_foreground(), Rgb::new(0x11, 0x11, 0x11)); + assert_eq!(snapshot.default_background(), Rgb::new(0x22, 0x22, 0x22)); + assert_eq!(snapshot.default_cursor(), Rgb::new(0x33, 0x33, 0x33)); + assert!(snapshot.cursor().visible()); + // Blink intent is unknown at this backend: recorded as unknown, not false. + assert_eq!(snapshot.cursor().blink(), None); + assert_eq!(snapshot.metadata().source, SnapshotSource::Live); + + // C07: re-observing the same state compares equal (metadata is skipped). + let again = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("recapture succeeds"); + assert_eq!(snapshot, again); + + // Scopes agree on the shared terminal state. + let active = session + .capture_snapshot(SnapshotScope::ActiveScreen) + .expect("active capture succeeds"); + assert_rectangular(&active); + assert_eq!(active.title(), Some("live-title")); + assert_eq!(active.palette()[7], Rgb::new(0x12, 0x34, 0x56)); + let full = session + .capture_snapshot(SnapshotScope::FullHistory) + .expect("history capture succeeds"); + assert_rectangular(&full); + assert_eq!(full.viewport_origin(), 0); + assert!(full.rows() >= active.rows()); + + close(&session); +} + +/// U02-B: snapshots stay usable after the session closes (fully owned data). +#[test] +fn u02_b_snapshot_outlives_session_close() { + let session = session_with_script("b", "owned-data\\r\\n", 80, 24); + poll_text(&session, "owned-data"); + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("capture succeeds"); + let json_before = serde_json::to_string(&snapshot).expect("serializes"); + close(&session); + + assert!(snapshot.cells().iter().any(|cell| cell.text() == "o")); + assert_rectangular(&snapshot); + let json_after = serde_json::to_string(&snapshot).expect("serializes after close"); + assert_eq!(json_before, json_after); + let back: TerminalSnapshot = serde_json::from_str(&json_after).expect("deserializes"); + assert_eq!(snapshot, back); +} + +/// U02-C: locator highlighting never leaks into the default snapshot. +#[test] +fn u02_c_highlighting_needs_explicit_option() { + let session = session_with_script("c", "findable marker here\\r\\n", 80, 24); + poll_text(&session, "findable"); + session + .get_by_text("findable") + .highlight() + .expect("highlight applies"); + + let raw = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("default capture succeeds"); + let marked = session + .capture_snapshot_with(&SnapshotOptions::new(SnapshotScope::Viewport).with_highlight(true)) + .expect("highlighted capture succeeds"); + let highlighted_cells: Vec<_> = marked + .cells() + .iter() + .filter(|cell| cell.has(Attrs::INVERSE)) + .collect(); + assert!(!highlighted_cells.is_empty(), "highlight selects cells"); + for (index, cell) in raw.cells().iter().enumerate() { + if cell.text() == "f" && marked.cells()[index].has(Attrs::INVERSE) { + assert!(!cell.has(Attrs::INVERSE), "default capture stays raw"); + } + } + assert_ne!(raw, marked); + close(&session); +} + +/// U02-D: alternate screen, history coordinates, and resize keep geometry. +#[test] +fn u02_d_scopes_keep_rectangular_geometry() { + let session = session_with_script("d-alt", "\\033[?1049hALTSCREEN\\r\\n", 80, 24); + poll_text(&session, "ALTSCREEN"); + let alt = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("alt-screen capture succeeds"); + assert_rectangular(&alt); + assert_eq!((alt.cols(), alt.rows()), (80, 24)); + assert!(alt.cells().iter().any(|cell| cell.text() == "A")); + close(&session); + + let mut lines = String::from("\\033[2J\\033[H"); + for line in 0..40 { + lines.push_str(&format!("history line {line:02}\\r\\n")); + } + let session = session_with_script("d-hist", &lines, 80, 24); + poll_text(&session, "history line 39"); + + let viewport = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("viewport capture succeeds"); + let full = session + .capture_snapshot(SnapshotScope::FullHistory) + .expect("history capture succeeds"); + assert_rectangular(&viewport); + assert_rectangular(&full); + assert!(full.rows() > viewport.rows(), "history extends the grid"); + assert_eq!(full.viewport_origin(), 0); + // History coordinates use u32, never u16 or a clamped stand-in. + assert!(full.rows() < 60); + assert!(u64::from(full.cursor().row()) < full.viewport_origin() + u64::from(full.rows()) + 100); + + // Scroll the viewport up: the cursor stays recorded, outside, unclamped. + session + .execute(Operation::Scroll { + target: ScrollTarget::Lines(5), + }) + .expect("scroll succeeds"); + let scrolled = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("scrolled capture succeeds"); + assert_rectangular(&scrolled); + assert!(!scrolled.cursor().in_capture()); + assert_eq!(scrolled.cursor_local(), None); + assert!(u64::from(scrolled.cursor().row()) >= scrolled.viewport_origin()); + + // Resize keeps rectangular geometry at the new dimensions. + session + .execute(Operation::Resize { + cols: 100, + rows: 30, + }) + .expect("resize succeeds"); + let resized = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("resized capture succeeds"); + assert_rectangular(&resized); + assert_eq!((resized.cols(), resized.rows()), (100, 30)); + close(&session); +} + +/// U02-E: serialization keeps palette changes and OSC 8 links. +#[test] +fn u02_e_serialization_keeps_palette_and_links() { + let session = session_with_script( + "e", + "\\033]4;5;#654321\\033\\\\\\033]8;id=e2e;https://example.com/kept\\033\\\\KEPT\\033]8;;\\033\\\\\\r\\n", + 80, + 24, + ); + poll_text(&session, "KEPT"); + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("capture succeeds"); + assert_eq!(snapshot.palette()[5], Rgb::new(0x65, 0x43, 0x21)); + let linked = snapshot + .cells() + .iter() + .find(|cell| cell.text() == "K") + .expect("link cell"); + assert_eq!(linked.link_uri(), Some("https://example.com/kept")); + assert_eq!(linked.link_id(), Some("e2e")); + + let json = serde_json::to_string(&snapshot).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(snapshot, back); + assert_eq!(back.palette()[5], Rgb::new(0x65, 0x43, 0x21)); + let kept = back + .cells() + .iter() + .find(|cell| cell.text() == "K") + .expect("link cell"); + assert_eq!(kept.link_uri(), Some("https://example.com/kept")); + assert_eq!(kept.link_id(), Some("e2e")); + close(&session); +} + +/// U02-F: constructor and JSON reject the same invalid geometry. +#[test] +fn u02_f_constructor_and_json_reject_same_geometry() { + fn round_trips(parts: &SnapshotParts) -> Result { + let snapshot = + TerminalSnapshot::try_new(parts.clone()).map_err(|error| error.to_string())?; + let json = serde_json::to_string(&snapshot).expect("serializes"); + serde_json::from_str(&json).map_err(|error| error.to_string()) + } + + // Ragged cell vector. + let mut parts = blank_parts(2, 2); + parts.cells.pop(); + assert_eq!( + TerminalSnapshot::try_new(parts.clone()), + Err(SnapshotError::CellCountMismatch { + expected: 4, + actual: 3 + }) + ); + assert!(round_trips(&parts).is_err()); + + // Continuation at column zero. + let mut parts = blank_parts(2, 1); + parts.cells[0] = SnapshotCell::new( + String::new(), + 0, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("coordinate-free construction succeeds"); + assert_eq!( + TerminalSnapshot::try_new(parts.clone()), + Err(SnapshotError::BadContinuation { col: 0, row: 0 }) + ); + assert!(round_trips(&parts).is_err()); + + // In-capture cursor outside the grid. + let mut parts = blank_parts(2, 2); + parts.cursor = SnapshotCursor::new( + 5, + 5, + true, + true, + SnapshotCursorShape::Block, + Rgb::new(0, 0, 0), + None, + ); + assert_eq!( + TerminalSnapshot::try_new(parts.clone()), + Err(SnapshotError::CursorOutOfBounds { + col: 5, + row: 5, + cols: 2, + rows: 2 + }) + ); + assert!(round_trips(&parts).is_err()); +} + +/// U02-G: unsupported schemas and oversize input return errors. +#[test] +fn u02_g_schemas_and_limits_are_rejected() { + let valid = TerminalSnapshot::try_new(blank_parts(2, 2)).expect("valid"); + let mut wire = serde_json::to_value(&valid).expect("serializes"); + wire["schema"] = serde_json::json!(2); + let error = serde_json::from_value::(wire).expect_err("schema 2 fails"); + assert!( + error.to_string().contains("unsupported snapshot schema"), + "{error}" + ); + + let mut wire = serde_json::to_value(&valid).expect("serializes"); + wire["cols"] = serde_json::json!(MAX_SNAPSHOT_DIM + 1); + assert!(serde_json::from_value::(wire).is_err()); + + // 32768 x 32768 exceeds the cell budget without exceeding a dimension. + let mut too_many = blank_parts(2, 2); + too_many.cols = MAX_SNAPSHOT_DIM; + too_many.rows = MAX_SNAPSHOT_DIM; + assert_eq!( + TerminalSnapshot::try_new(too_many), + Err(SnapshotError::TooManyCells { + cells: u64::from(MAX_SNAPSHOT_DIM) * u64::from(MAX_SNAPSHOT_DIM), + limit: MAX_SNAPSHOT_CELLS + }) + ); + + assert!(SnapshotCell::new( + "x".repeat(MAX_CELL_TEXT_BYTES + 1), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .is_err()); +} + +/// U02-H: capture and serialization keep palette changes at indices 1 and 200. +#[test] +fn u02_h_palette_slots_1_and_200_survive() { + let session = session_with_script( + "h", + "\\033]4;1;#0a0a0a\\033\\\\\\033]4;200;#c8c8c8\\033\\\\READY\\r\\n", + 80, + 24, + ); + poll_text(&session, "READY"); + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("capture succeeds"); + assert_eq!(snapshot.palette()[1], Rgb::new(0x0a, 0x0a, 0x0a)); + assert_eq!(snapshot.palette()[200], Rgb::new(0xc8, 0xc8, 0xc8)); + let json = serde_json::to_string(&snapshot).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(back.palette()[1], Rgb::new(0x0a, 0x0a, 0x0a)); + assert_eq!(back.palette()[200], Rgb::new(0xc8, 0xc8, 0xc8)); + close(&session); + + let mut parts = blank_parts(1, 1); + parts.palette[1] = Rgb::new(1, 1, 1); + parts.palette[200] = Rgb::new(2, 2, 2); + let offline = TerminalSnapshot::try_new(parts).expect("valid"); + let json = serde_json::to_string(&offline).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(offline, back); +} + +/// U02-I: capture and serialization keep default fg/bg/cursor changes. +#[test] +fn u02_i_default_colors_survive() { + let session = session_with_script( + "i", + "\\033]10;#0b0b0b\\033\\\\\\033]11;#1c1c1c\\033\\\\\\033]12;#2d2d2d\\033\\\\READY\\r\\n", + 80, + 24, + ); + poll_text(&session, "READY"); + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("capture succeeds"); + assert_eq!(snapshot.default_foreground(), Rgb::new(0x0b, 0x0b, 0x0b)); + assert_eq!(snapshot.default_background(), Rgb::new(0x1c, 0x1c, 0x1c)); + assert_eq!(snapshot.default_cursor(), Rgb::new(0x2d, 0x2d, 0x2d)); + let json = serde_json::to_string(&snapshot).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(snapshot, back); + close(&session); +} + +/// U02-J: equal resolved colors do not erase different color-reference kinds. +#[test] +fn u02_j_default_and_rgb_kinds_stay_distinct() { + let palette = resolved_palette(); + let mut defaulted = blank_parts(1, 1); + defaulted.default_foreground = palette[3]; + defaulted.cells[0] = SnapshotCell::new( + "x".to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("cell is valid"); + let mut explicit = defaulted.clone(); + explicit.cells[0] = SnapshotCell::new( + "x".to_string(), + 1, + SnapshotColor::Rgb(palette[3].r, palette[3].g, palette[3].b), + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("cell is valid"); + let a = TerminalSnapshot::try_new(defaulted).expect("valid"); + let b = TerminalSnapshot::try_new(explicit).expect("valid"); + assert_ne!(a, b); + for snapshot in [&a, &b] { + let json = serde_json::to_string(snapshot).expect("serializes"); + let back: TerminalSnapshot = serde_json::from_str(&json).expect("deserializes"); + assert_eq!(*snapshot, back); + } +} + +/// U02-K: an unreferenced palette entry still changes canonical state. +#[test] +fn u02_k_unreferenced_palette_change_counts() { + let left = TerminalSnapshot::try_new(blank_parts(2, 2)).expect("valid"); + let mut parts = blank_parts(2, 2); + parts.palette[77] = Rgb::new(9, 9, 9); + let right = TerminalSnapshot::try_new(parts).expect("valid"); + assert_ne!(left, right); +} From 364719ac544b9b6d80fa912d6a71f007b27e4a8b Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:34:15 +0700 Subject: [PATCH 04/17] Fix bool assert lint and document try_new with an example Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/snapshot.rs | 51 +++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/tui-test/src/snapshot.rs b/crates/tui-test/src/snapshot.rs index a0128c32..6b18ae27 100644 --- a/crates/tui-test/src/snapshot.rs +++ b/crates/tui-test/src/snapshot.rs @@ -800,6 +800,47 @@ impl TerminalSnapshot { /// continuations, out-of-bounds in-capture cursors, misplaced origins, /// and oversize titles and links, using checked arithmetic throughout. /// Cell geometry is recorded, never recomputed from UTF-8 byte length. + /// + /// ``` + /// use tui_test::profile::Rgb; + /// use tui_test::terminal::cell::Attrs; + /// use tui_test::{ + /// SnapshotCell, SnapshotColor, SnapshotCursor, SnapshotCursorShape, + /// SnapshotMetadata, SnapshotParts, SnapshotScope, TerminalSnapshot, + /// }; + /// + /// let palette = [Rgb::new(0, 0, 0); 256]; + /// let cell = SnapshotCell::new( + /// "x".to_string(), + /// 1, + /// SnapshotColor::Default, + /// SnapshotColor::Indexed(1), + /// Attrs::BOLD, + /// tui_test::SnapshotUnderline::None, + /// SnapshotColor::Default, + /// None, + /// None, + /// )?; + /// let snapshot = TerminalSnapshot::try_new(SnapshotParts { + /// cols: 1, + /// rows: 1, + /// scope: SnapshotScope::Viewport, + /// viewport_origin: 0, + /// cells: vec![cell], + /// palette, + /// default_foreground: Rgb::new(255, 255, 255), + /// default_background: Rgb::new(0, 0, 0), + /// default_cursor: Rgb::new(255, 255, 255), + /// cursor: SnapshotCursor::new( + /// 0, 0, true, true, SnapshotCursorShape::Block, + /// Rgb::new(255, 255, 255), None, + /// ), + /// title: None, + /// metadata: SnapshotMetadata::default(), + /// })?; + /// assert_eq!(snapshot.cell_at(0, 0).unwrap().text(), "x"); + /// # Ok::<(), tui_test::SnapshotError>(()) + /// ``` pub fn try_new(parts: SnapshotParts) -> Result { assemble(parts) } @@ -1800,12 +1841,10 @@ mod tests { let mut wire = serde_json::to_value(&valid).expect("serializes"); wire["schema"] = serde_json::json!(999); - assert_eq!( - serde_json::from_value::(wire) - .expect_err("unsupported schema fails") - .to_string() - .contains("unsupported snapshot schema"), - true + let error = serde_json::from_value::(wire).expect_err("schema 999 fails"); + assert!( + error.to_string().contains("unsupported snapshot schema"), + "unexpected error: {error}" ); } From 10b68c8e59d82634355f2aa6c05e33c753b010e0 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 17:58:59 +0700 Subject: [PATCH 05/17] U03: share one resolved layout between live and offline renderers Add ResolvedLayout in render/svg.rs: live screenshots and recordings resolve their Style to it, offline snapshots resolve SnapshotRenderOptions to it, and both draw through render_svg_with_layout and GridRenderer::with_layout. RenderState gains from_parts so snapshots can supply resolved colors without a live emulator; encode gains encode_png_bytes shared by file and memory PNG output. render_svg, GridRenderer, and encode_png keep their previous behavior. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/lib.rs | 6 + crates/tui-test/src/record/frames.rs | 2 +- crates/tui-test/src/render/encode.rs | 34 +- crates/tui-test/src/render/mod.rs | 1 + crates/tui-test/src/render/offline.rs | 994 +++++++++++++++++++++ crates/tui-test/src/render/raster.rs | 205 +++-- crates/tui-test/src/render/raster/tests.rs | 13 +- crates/tui-test/src/render/svg.rs | 384 ++++++-- 8 files changed, 1441 insertions(+), 198 deletions(-) create mode 100644 crates/tui-test/src/render/offline.rs diff --git a/crates/tui-test/src/lib.rs b/crates/tui-test/src/lib.rs index 35cb3bbb..e790a6e8 100644 --- a/crates/tui-test/src/lib.rs +++ b/crates/tui-test/src/lib.rs @@ -21,6 +21,12 @@ mod session; pub use api::*; pub use diagnostics::*; pub use engine::Engine; +#[cfg(feature = "recording-raster")] +pub use render::offline::{render_snapshot_png, write_snapshot_png, RenderedPng}; +pub use render::offline::{ + render_snapshot_svg, write_snapshot_svg, RenderError, RenderEvidence, RenderedSvg, + SnapshotRenderOptions, +}; pub use runtime::{ global_registry, Locator, LocatorClickOptions, LocatorExpectOptions, LocatorFilterOptions, Session, SessionHandle, SessionRegistry, diff --git a/crates/tui-test/src/record/frames.rs b/crates/tui-test/src/record/frames.rs index 575914ad..6c4b2761 100644 --- a/crates/tui-test/src/record/frames.rs +++ b/crates/tui-test/src/record/frames.rs @@ -527,7 +527,7 @@ mod tests { // Derived from the style rather than pinned, so a deliberate change to // a default gap does not read as a broken encoder test. let style = crate::render::style::Style::default(); - let (panel_width, panel_height) = crate::render::svg::pixel_size(2, 1, &style); + let (panel_width, panel_height) = crate::render::svg::test_pixel_size(2, 1, &style); assert_eq!( renderer.pixel_size(), // The renderer is built at scale 2. diff --git a/crates/tui-test/src/render/encode.rs b/crates/tui-test/src/render/encode.rs index 3ef14251..621c3191 100644 --- a/crates/tui-test/src/render/encode.rs +++ b/crates/tui-test/src/render/encode.rs @@ -45,16 +45,9 @@ pub(crate) fn encode_png( ) -> anyhow::Result<()> { let (staged_path, file) = stage_png(path)?; let result = (|| -> anyhow::Result<()> { - let (width, height) = renderer.pixel_size(); + let bytes = encode_png_bytes(frame, renderer)?; let mut output = BufWriter::new(file); - let mut encoder = png::Encoder::new(&mut output, width, height); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - encoder.set_adaptive_filter(png::AdaptiveFilterType::Adaptive); - let mut writer = encoder.write_header()?; - let image = renderer.render(frame)?; - writer.write_image_data(image.as_raw())?; - writer.finish()?; + output.write_all(&bytes)?; output.flush()?; drop(output); std::fs::rename(&staged_path, path)?; @@ -66,6 +59,29 @@ pub(crate) fn encode_png( result } +/// Encode one rendered frame to PNG bytes, without touching the filesystem. +/// +/// The file writer above shares this so live screenshots and offline snapshot +/// renders produce identical bytes from the same frame and renderer. +pub(crate) fn encode_png_bytes( + frame: &Frame, + renderer: &mut dyn FrameRenderer, +) -> anyhow::Result> { + let (width, height) = renderer.pixel_size(); + let mut encoded = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut encoded, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_adaptive_filter(png::AdaptiveFilterType::Adaptive); + let mut writer = encoder.write_header()?; + let image = renderer.render(frame)?; + writer.write_image_data(image.as_raw())?; + writer.finish()?; + } + Ok(encoded) +} + fn stage_png(path: &Path) -> std::io::Result<(PathBuf, File)> { use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/crates/tui-test/src/render/mod.rs b/crates/tui-test/src/render/mod.rs index acdb2b2a..41b780f0 100644 --- a/crates/tui-test/src/render/mod.rs +++ b/crates/tui-test/src/render/mod.rs @@ -1,4 +1,5 @@ mod nerd_font; +pub mod offline; pub mod style; pub mod svg; diff --git a/crates/tui-test/src/render/offline.rs b/crates/tui-test/src/render/offline.rs new file mode 100644 index 00000000..252957ab --- /dev/null +++ b/crates/tui-test/src/render/offline.rs @@ -0,0 +1,994 @@ +//! Public offline rendering for owned snapshots. +//! +//! [`render_snapshot_svg`] draws a [`TerminalSnapshot`] as SVG text without a +//! live session, and [`render_snapshot_png`] (with the `recording-raster` +//! feature) draws it as decoded PNG pixels. Neither starts a child process, +//! PTY, session, or daemon: they convert the snapshot into the same grid, +//! colors, cursor, and title the live screenshot path uses, then draw through +//! the same renderer. +//! +//! SVG output keeps text as text, so it stays sharp at any viewer zoom but +//! never promises pixel identity across viewer fonts. Strict visual gates +//! must decode the PNG bytes and compare pixels. +//! +//! ``` +//! use tui_test::profile::Rgb; +//! use tui_test::terminal::cell::Attrs; +//! use tui_test::{ +//! render_snapshot_svg, SnapshotCell, SnapshotColor, SnapshotCursor, +//! SnapshotCursorShape, SnapshotMetadata, SnapshotParts, SnapshotRenderOptions, +//! SnapshotScope, SnapshotUnderline, TerminalSnapshot, +//! }; +//! +//! # fn main() -> Result<(), Box> { +//! let palette = [Rgb::new(0, 0, 0); 256]; +//! let cell = SnapshotCell::new( +//! "x".to_string(), +//! 1, +//! SnapshotColor::Default, +//! SnapshotColor::Default, +//! Attrs::empty(), +//! SnapshotUnderline::None, +//! SnapshotColor::Default, +//! None, +//! None, +//! )?; +//! let snapshot = TerminalSnapshot::try_new(SnapshotParts { +//! cols: 1, +//! rows: 1, +//! scope: SnapshotScope::Viewport, +//! viewport_origin: 0, +//! cells: vec![cell], +//! palette, +//! default_foreground: Rgb::new(192, 192, 192), +//! default_background: Rgb::new(0, 0, 0), +//! default_cursor: Rgb::new(192, 192, 192), +//! cursor: SnapshotCursor::new( +//! 0, +//! 0, +//! true, +//! false, +//! SnapshotCursorShape::Block, +//! Rgb::new(192, 192, 192), +//! None, +//! ), +//! title: None, +//! metadata: SnapshotMetadata::default(), +//! })?; +//! let rendered = render_snapshot_svg(&snapshot, &SnapshotRenderOptions::default())?; +//! assert!(rendered.svg().contains(">x<")); +//! assert!(!rendered.evidence().cursor_drawn()); +//! # Ok(()) +//! # } +//! ``` + +use std::path::Path; +use std::sync::Arc; + +use compact_str::CompactString; + +#[cfg(feature = "recording-raster")] +use super::raster::FrameRenderer; +use super::style::{Padding, PaddingSides, Style}; +use super::svg::{RenderState, ResolvedLayout}; +use crate::api::CaptureBackground; +use crate::snapshot::{SnapshotColor, SnapshotUnderline, TerminalSnapshot}; +use crate::terminal::cell::{Attrs, Color, EmuCell, Hyperlink, UnderlineStyle}; +use crate::terminal::emu::CursorShape; + +/// Largest accepted cell edge, border width, or corner radius in pixels. +/// +/// Mirrors the length bound the live [`Style`] validation uses, so offline +/// options cannot size what live styles must reject. +const MAX_METRIC_PX: f64 = 10_000.0; + +/// Largest accepted grid font size in pixels, mirroring live validation. +const MAX_FONT_SIZE_PX: f64 = 1_000.0; + +/// Largest accepted canvas or content padding per side, mirroring live validation. +const MAX_PADDING_PX: u32 = 10_000; + +/// Largest accepted render scale, mirroring the live zoom bound. +const MAX_SCALE: f64 = f32::MAX as f64 / 2.0; + +/// Largest accepted decoded image: 100 megapixels, the raster limit. +const MAX_RENDER_PIXELS: u64 = 100_000_000; + +/// A typed offline-render failure. +#[derive(Debug)] +pub enum RenderError { + /// A metric that must be finite was not (`cell_width`, `scale`, ...). + NonFinite { + /// The option field that failed validation. + field: &'static str, + }, + /// A metric that must be positive was zero or negative. + NonPositive { + /// The option field that failed validation. + field: &'static str, + /// The rejected value. + value: f64, + }, + /// A metric that must not be negative was negative. + Negative { + /// The option field that failed validation. + field: &'static str, + /// The rejected value. + value: f64, + }, + /// A metric above its accepted limit. + TooLarge { + /// The option field that failed validation. + field: &'static str, + /// The rejected value. + value: f64, + /// The accepted limit. + limit: f64, + }, + /// The text baseline falls outside the cell it must sit in. + BaselineOutsideCell { + /// The rejected baseline, measured from the top of the cell. + baseline: f32, + /// The cell height it must fit in. + cell_height: f32, + }, + /// Checked image-size arithmetic overflowed, left `u32`, or passed the + /// pixel cap, so no image was allocated. + ImageTooLarge { + /// Snapshot columns the size was computed for. + cols: u32, + /// Snapshot rows the size was computed for. + rows: u32, + }, + /// The raster backend failed: missing glyphs, allocation, or encoding. + RasterFailed { + /// What the raster backend reported. + message: String, + }, + /// A snapshot file could not be written. + Io(std::io::Error), +} + +impl std::fmt::Display for RenderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RenderError::NonFinite { field } => { + write!(f, "{field} must be finite") + } + RenderError::NonPositive { field, value } => { + write!(f, "{field} must be greater than zero (got {value})") + } + RenderError::Negative { field, value } => { + write!(f, "{field} must not be negative (got {value})") + } + RenderError::TooLarge { + field, + value, + limit, + } => { + write!(f, "{field} must not exceed {limit} (got {value})") + } + RenderError::BaselineOutsideCell { + baseline, + cell_height, + } => { + write!( + f, + "text baseline {baseline} falls outside a {cell_height}-pixel cell" + ) + } + RenderError::ImageTooLarge { cols, rows } => { + write!( + f, + "rendered image for a {cols}x{rows} grid does not fit the pixel budget" + ) + } + RenderError::RasterFailed { message } => { + write!(f, "raster render failed: {message}") + } + RenderError::Io(error) => { + write!(f, "snapshot render write failed: {error}") + } + } + } +} + +impl std::error::Error for RenderError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + RenderError::Io(error) => Some(error), + _ => None, + } + } +} + +impl From for crate::api::TuiTestError { + fn from(error: RenderError) -> Self { + crate::api::TuiTestError::internal(error.to_string()) + } +} + +/// Render settings for one offline snapshot. +/// +/// Every field is private; the constructor, accessors, and builder methods +/// below are the whole surface. Defaults mirror the live [`Style`] geometry +/// (10-by-21 cells, title bar with controls, shadow, default padding), so a +/// default offline render matches a default live screenshot of the same +/// terminal state. U04 extends this type with pinned fonts; it stays +/// raster-independent either way. +#[derive(Debug, Clone)] +pub struct SnapshotRenderOptions { + cell_width: f32, + cell_height: f32, + font_size: f32, + baseline: f32, + scale: f64, + canvas_padding: Padding, + content_padding: Padding, + title_bar: bool, + title_controls: bool, + shadow: bool, + border_width: f32, + border_radius: f32, + background: Option, +} + +impl Default for SnapshotRenderOptions { + fn default() -> Self { + let cell_width = 10.0f32; + let cell_height = 21.0f32; + let font_size = 17.0f32; + Self { + cell_width, + cell_height, + font_size, + // Same formula the live style uses: half the leading plus the + // distance from the font top to its baseline. + baseline: (cell_height - font_size) / 2.0 + font_size * 0.78, + scale: 1.0, + canvas_padding: Padding::Uniform(24), + content_padding: Padding::Sides(PaddingSides { + top: Some(8), + right: Some(15), + bottom: Some(14), + left: Some(15), + }), + title_bar: true, + title_controls: true, + shadow: true, + border_width: 0.0, + border_radius: 8.0, + background: None, + } + } +} + +impl SnapshotRenderOptions { + /// Default options: the live style geometry with no overrides. + pub fn new() -> Self { + Self::default() + } + + /// Terminal columns are this many pixels wide; must be finite and positive. + pub fn cell_width(&self) -> f32 { + self.cell_width + } + + /// Set the cell width in pixels. + pub fn with_cell_width(mut self, cell_width: f32) -> Self { + self.cell_width = cell_width; + self + } + + /// Terminal rows are this many pixels tall; must be finite and positive. + pub fn cell_height(&self) -> f32 { + self.cell_height + } + + /// Set the cell height in pixels. + pub fn with_cell_height(mut self, cell_height: f32) -> Self { + self.cell_height = cell_height; + self + } + + /// Grid font size in pixels; must be finite and positive. + pub fn font_size(&self) -> f32 { + self.font_size + } + + /// Set the grid font size in pixels. + pub fn with_font_size(mut self, font_size: f32) -> Self { + self.font_size = font_size; + self + } + + /// Distance from the top of a cell to its text baseline; must sit in the + /// cell (`0.0` through `cell_height`). + pub fn baseline(&self) -> f32 { + self.baseline + } + + /// Set the text baseline offset in pixels from the top of the cell. + pub fn with_baseline(mut self, baseline: f32) -> Self { + self.baseline = baseline; + self + } + + /// Output scale factor; must be finite and positive. + pub fn scale(&self) -> f64 { + self.scale + } + + /// Set the output scale factor. + pub fn with_scale(mut self, scale: f64) -> Self { + self.scale = scale; + self + } + + /// Gap around the window. + pub fn canvas_padding(&self) -> Padding { + self.canvas_padding + } + + /// Set the gap around the window. + pub fn with_canvas_padding(mut self, canvas_padding: Padding) -> Self { + self.canvas_padding = canvas_padding; + self + } + + /// Gap between the window and the grid drawn inside it. + pub fn content_padding(&self) -> Padding { + self.content_padding + } + + /// Set the gap between the window and the grid. + pub fn with_content_padding(mut self, content_padding: Padding) -> Self { + self.content_padding = content_padding; + self + } + + /// Whether the title bar, divider, and title are drawn. + pub fn title_bar(&self) -> bool { + self.title_bar + } + + /// Show or hide the title bar. + pub fn with_title_bar(mut self, title_bar: bool) -> Self { + self.title_bar = title_bar; + self + } + + /// Whether the three window controls are drawn when the bar is visible. + pub fn title_controls(&self) -> bool { + self.title_controls + } + + /// Show or hide the window controls. + pub fn with_title_controls(mut self, title_controls: bool) -> Self { + self.title_controls = title_controls; + self + } + + /// Whether the drop shadow under the panel is drawn. + pub fn shadow(&self) -> bool { + self.shadow + } + + /// Turn the drop shadow on or off. + pub fn with_shadow(mut self, shadow: bool) -> Self { + self.shadow = shadow; + self + } + + /// Panel border width in pixels; zero disables it. + pub fn border_width(&self) -> f32 { + self.border_width + } + + /// Set the panel border width in pixels. + pub fn with_border_width(mut self, border_width: f32) -> Self { + self.border_width = border_width; + self + } + + /// Panel corner radius in pixels. + pub fn border_radius(&self) -> f32 { + self.border_radius + } + + /// Set the panel corner radius in pixels. + pub fn with_border_radius(mut self, border_radius: f32) -> Self { + self.border_radius = border_radius; + self + } + + /// Canvas background override: a color, transparent, or the default fill. + pub fn background(&self) -> Option { + self.background + } + + /// Set the canvas background override. + pub fn with_background(mut self, background: Option) -> Self { + self.background = background; + self + } + + /// Reject nonfinite values, bad dimensions, and a baseline outside the + /// cell, before any image-size arithmetic or allocation. + pub fn validate(&self) -> Result<(), RenderError> { + for (field, value) in [ + ("cell_width", f64::from(self.cell_width)), + ("cell_height", f64::from(self.cell_height)), + ("font_size", f64::from(self.font_size)), + ("scale", self.scale), + ] { + if !value.is_finite() { + return Err(RenderError::NonFinite { field }); + } + if value <= 0.0 { + return Err(RenderError::NonPositive { field, value }); + } + } + if f64::from(self.font_size) > MAX_FONT_SIZE_PX { + return Err(RenderError::TooLarge { + field: "font_size", + value: f64::from(self.font_size), + limit: MAX_FONT_SIZE_PX, + }); + } + for (field, value) in [ + ("cell_width", f64::from(self.cell_width)), + ("cell_height", f64::from(self.cell_height)), + ] { + if value > MAX_METRIC_PX { + return Err(RenderError::TooLarge { + field, + value, + limit: MAX_METRIC_PX, + }); + } + } + if !self.baseline.is_finite() { + return Err(RenderError::NonFinite { field: "baseline" }); + } + if self.baseline < 0.0 || self.baseline > self.cell_height { + return Err(RenderError::BaselineOutsideCell { + baseline: self.baseline, + cell_height: self.cell_height, + }); + } + if self.scale > MAX_SCALE { + return Err(RenderError::TooLarge { + field: "scale", + value: self.scale, + limit: MAX_SCALE, + }); + } + for (field, value) in [ + ("border_width", f64::from(self.border_width)), + ("border_radius", f64::from(self.border_radius)), + ] { + if !value.is_finite() { + return Err(RenderError::NonFinite { field }); + } + if value < 0.0 { + return Err(RenderError::Negative { field, value }); + } + if value > MAX_METRIC_PX { + return Err(RenderError::TooLarge { + field, + value, + limit: MAX_METRIC_PX, + }); + } + } + check_padding("canvas_padding", &self.canvas_padding)?; + check_padding("content_padding", &self.content_padding)?; + Ok(()) + } +} + +/// One padding side must fit its pixel budget. +fn check_padding(field: &'static str, padding: &Padding) -> Result<(), RenderError> { + let sides = match padding { + Padding::Uniform(value) => [*value; 4], + Padding::Sides(sides) => { + [sides.top, sides.right, sides.bottom, sides.left].map(|side| side.unwrap_or(0)) + } + }; + for value in sides { + if u64::from(value) > u64::from(MAX_PADDING_PX) { + return Err(RenderError::TooLarge { + field, + value: f64::from(value), + limit: f64::from(MAX_PADDING_PX), + }); + } + } + Ok(()) +} + +/// Structured evidence from one offline render. +/// +/// Dimensions plus the behaviors the spec calls out: whether the cursor was +/// drawn (hidden and clipped cursors stay hidden), whether the canvas is +/// transparent, and which title the bar showed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderEvidence { + width: u32, + height: u32, + cursor_drawn: bool, + transparent_background: bool, + title: Option, +} + +impl RenderEvidence { + /// Output image width in pixels. + pub fn width(&self) -> u32 { + self.width + } + + /// Output image height in pixels. + pub fn height(&self) -> u32 { + self.height + } + + /// Whether the cursor was drawn: false when it is hidden or clipped out + /// of the capture, exactly like a live screenshot. + pub fn cursor_drawn(&self) -> bool { + self.cursor_drawn + } + + /// Whether the canvas was left transparent instead of filled. + pub fn transparent_background(&self) -> bool { + self.transparent_background + } + + /// The title the bar showed, or `None` with the bar off or nothing fitting. + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } +} + +/// One rendered SVG image with its evidence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedSvg { + svg: String, + evidence: RenderEvidence, +} + +impl RenderedSvg { + /// The SVG document. Text stays text: sharp at any viewer zoom, with no + /// pixel-identity promise across viewer fonts. + pub fn svg(&self) -> &str { + &self.svg + } + + /// Output width in pixels. + pub fn width(&self) -> u32 { + self.evidence.width + } + + /// Output height in pixels. + pub fn height(&self) -> u32 { + self.evidence.height + } + + /// Dimensions and recorded cursor, background, and title behavior. + pub fn evidence(&self) -> &RenderEvidence { + &self.evidence + } + + /// The SVG document as bytes. + pub fn into_bytes(self) -> Vec { + self.svg.into_bytes() + } +} + +/// Draw a snapshot as SVG text, without a live session. +/// +/// The input snapshot is never changed. A hidden cursor, or one clipped out +/// of the capture, is not drawn, and the evidence records that. +pub fn render_snapshot_svg( + snapshot: &TerminalSnapshot, + options: &SnapshotRenderOptions, +) -> Result { + let layout = layout_from_options(options)?; + let (cols, rows) = snapshot_grid_size(snapshot)?; + let (width, height) = checked_output_size(&layout, snapshot.cols(), snapshot.rows())?; + let grid = snapshot_rows(snapshot); + let colors = snapshot_colors(snapshot); + let cursor = snapshot_cursor_position(snapshot); + let svg = super::svg::render_svg_with_layout( + &grid, + cols, + &colors, + cursor, + snapshot.title(), + &layout, + options.background, + ); + Ok(RenderedSvg { + svg, + evidence: RenderEvidence { + width, + height, + cursor_drawn: cursor.is_some(), + transparent_background: matches!( + options.background, + Some(CaptureBackground::Transparent) + ), + title: shown_title(snapshot.title(), cols, rows, &layout), + }, + }) +} + +/// Draw a snapshot as SVG and save it to `path`, without a live session. +/// +/// Returns the same evidence [`render_snapshot_svg`] returns for the same +/// inputs. +pub fn write_snapshot_svg( + path: &Path, + snapshot: &TerminalSnapshot, + options: &SnapshotRenderOptions, +) -> Result { + let rendered = render_snapshot_svg(snapshot, options)?; + std::fs::write(path, rendered.svg()).map_err(RenderError::Io)?; + Ok(rendered.evidence) +} + +/// One rendered PNG image with its evidence. +#[cfg(feature = "recording-raster")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderedPng { + png: Vec, + evidence: RenderEvidence, +} + +#[cfg(feature = "recording-raster")] +impl RenderedPng { + /// The encoded PNG bytes; decode them for strict pixel gates. + pub fn png(&self) -> &[u8] { + &self.png + } + + /// Output width in pixels. + pub fn width(&self) -> u32 { + self.evidence.width + } + + /// Output height in pixels. + pub fn height(&self) -> u32 { + self.evidence.height + } + + /// Dimensions and recorded cursor, background, and title behavior. + pub fn evidence(&self) -> &RenderEvidence { + &self.evidence + } + + /// The encoded PNG bytes, owned. + pub fn into_bytes(self) -> Vec { + self.png + } +} + +/// Draw a snapshot as PNG bytes, without a live session. +/// +/// Uses the same [`GridRenderer`](super::raster::GridRenderer) the live +/// screenshots use, at screenshot (exact, unpadded) size. The input snapshot +/// is never changed. A hidden cursor, or one clipped out of the capture, is +/// not drawn, and the evidence records that. +#[cfg(feature = "recording-raster")] +pub fn render_snapshot_png( + snapshot: &TerminalSnapshot, + options: &SnapshotRenderOptions, +) -> Result { + let layout = layout_from_options(options)?; + // Checked before the renderer allocates its pixmap. + let _ = checked_output_size(&layout, snapshot.cols(), snapshot.rows())?; + let (cols, rows) = snapshot_grid_size(snapshot)?; + let mut renderer = super::raster::GridRenderer::with_layout( + cols, + rows, + layout.clone(), + options.background, + true, + ) + .map_err(|error| RenderError::RasterFailed { + message: error.to_string(), + })?; + let frame = snapshot_frame(snapshot); + let png = super::encode::encode_png_bytes(&frame, &mut renderer).map_err(|error| { + RenderError::RasterFailed { + message: error.to_string(), + } + })?; + let (width, height) = renderer.pixel_size(); + Ok(RenderedPng { + png, + evidence: RenderEvidence { + width, + height, + cursor_drawn: frame.cursor.is_some(), + transparent_background: matches!( + options.background, + Some(CaptureBackground::Transparent) + ), + title: shown_title(snapshot.title(), cols, rows, &layout), + }, + }) +} + +/// Draw a snapshot as PNG and save it to `path`, without a live session. +/// +/// Shares the staged file writer the live screenshots use, so bytes and path +/// wrappers decode to equal pixels. Returns the same evidence +/// [`render_snapshot_png`] returns for the same inputs. +#[cfg(feature = "recording-raster")] +pub fn write_snapshot_png( + path: &Path, + snapshot: &TerminalSnapshot, + options: &SnapshotRenderOptions, +) -> Result { + let layout = layout_from_options(options)?; + // Checked before the renderer allocates its pixmap. + let _ = checked_output_size(&layout, snapshot.cols(), snapshot.rows())?; + let (cols, rows) = snapshot_grid_size(snapshot)?; + let mut renderer = super::raster::GridRenderer::with_layout( + cols, + rows, + layout.clone(), + options.background, + true, + ) + .map_err(|error| RenderError::RasterFailed { + message: error.to_string(), + })?; + let frame = snapshot_frame(snapshot); + super::encode::encode_png(path, &frame, &mut renderer).map_err(|error| { + RenderError::RasterFailed { + message: error.to_string(), + } + })?; + let (width, height) = renderer.pixel_size(); + Ok(RenderEvidence { + width, + height, + cursor_drawn: frame.cursor.is_some(), + transparent_background: matches!(options.background, Some(CaptureBackground::Transparent)), + title: shown_title(snapshot.title(), cols, rows, &layout), + }) +} + +/// Resolve validated options to the shared layout. +/// +/// Starts from the live style defaults so preserved geometry is inherited, +/// not re-typed, then applies the explicit cell metrics and chrome switches. +fn layout_from_options(options: &SnapshotRenderOptions) -> Result { + options.validate()?; + let mut layout = ResolvedLayout::from_style(&Style::default(), options.scale); + layout.cell_w = options.cell_width; + layout.cell_h = options.cell_height; + layout.font_size = options.font_size; + layout.baseline = options.baseline; + let (canvas_top, canvas_right, canvas_bottom, canvas_left) = + padding_values(&options.canvas_padding, (24, 24, 24, 24)); + layout.canvas_top = canvas_top; + layout.canvas_right = canvas_right; + layout.canvas_bottom = canvas_bottom; + layout.canvas_left = canvas_left; + let (content_top, content_right, content_bottom, content_left) = + padding_values(&options.content_padding, (8, 15, 14, 15)); + // Content gaps are f32 in the layout, but validated as u32 above. + layout.content_top = content_top as f32; + layout.content_right = content_right as f32; + layout.content_bottom = content_bottom as f32; + layout.content_left = content_left as f32; + layout.title_bar = options.title_bar; + layout.title_controls = options.title_controls; + layout.shadow_enabled = options.shadow; + layout.border_width = options.border_width; + layout.border_radius = options.border_radius; + Ok(layout) +} + +/// Resolve one padding to `(top, right, bottom, left)`, filling omitted sides +/// from the given defaults. +fn padding_values(padding: &Padding, defaults: (u32, u32, u32, u32)) -> (u32, u32, u32, u32) { + match padding { + Padding::Uniform(value) => (*value, *value, *value, *value), + Padding::Sides(sides) => ( + sides.top.unwrap_or(defaults.0), + sides.right.unwrap_or(defaults.1), + sides.bottom.unwrap_or(defaults.2), + sides.left.unwrap_or(defaults.3), + ), + } +} + +/// Checked grid size for the renderers: snapshot dimensions always fit the +/// `u16` columns and `usize` rows the renderer indexes with, but the +/// conversion stays checked rather than assumed. +fn snapshot_grid_size(snapshot: &TerminalSnapshot) -> Result<(u16, usize), RenderError> { + let cols = u16::try_from(snapshot.cols()).map_err(|_| RenderError::ImageTooLarge { + cols: snapshot.cols(), + rows: snapshot.rows(), + })?; + let rows = usize::try_from(snapshot.rows()).map_err(|_| RenderError::ImageTooLarge { + cols: snapshot.cols(), + rows: snapshot.rows(), + })?; + Ok((cols, rows)) +} + +/// Checked canvas-inclusive output size at the layout zoom, before allocation. +/// +/// Mirrors the renderers' size math (ceiled panel plus canvas, scaled) with +/// finite and range checks on every step and the shared pixel cap at the end. +fn checked_output_size( + layout: &ResolvedLayout, + cols: u32, + rows: u32, +) -> Result<(u32, u32), RenderError> { + let too_large = || RenderError::ImageTooLarge { cols, rows }; + let panel_w = f64::from(layout.content_left) + + f64::from(layout.content_right) + + f64::from(cols) * f64::from(layout.cell_w); + let panel_h = f64::from(layout.header_h) + + f64::from(layout.content_top) + + f64::from(layout.content_bottom) + + f64::from(rows.max(1)) * f64::from(layout.cell_h); + let width = + (panel_w + f64::from(layout.canvas_left) + f64::from(layout.canvas_right)) * layout.zoom; + let height = + (panel_h + f64::from(layout.canvas_top) + f64::from(layout.canvas_bottom)) * layout.zoom; + let width = ceil_u32(width).ok_or_else(too_large)?; + let height = ceil_u32(height).ok_or_else(too_large)?; + let pixels = u64::from(width) * u64::from(height); + if pixels > MAX_RENDER_PIXELS { + return Err(too_large()); + } + Ok((width, height)) +} + +/// Ceil a size to `u32`, or `None` past zero, nonfinite, or `u32::MAX`. +fn ceil_u32(value: f64) -> Option { + if !value.is_finite() || value <= 0.0 || value > f64::from(u32::MAX) { + return None; + } + let ceiled = value.ceil(); + if ceiled > f64::from(u32::MAX) { + return None; + } + // In range by the checks above; `as` cannot saturate here. + Some(ceiled as u32) +} + +/// Convert snapshot cells to the renderer grid. +/// +/// Widths are recorded on the snapshot and never recomputed from the text; +/// continuation cells (empty text off column zero) convert to the empty +/// marker the renderer already treats as a wide-character trailer. +fn snapshot_rows(snapshot: &TerminalSnapshot) -> Vec> { + let cols = snapshot.cols() as usize; + snapshot + .cells() + .chunks(cols.max(1)) + .map(|row| row.iter().map(snapshot_cell_to_emu).collect()) + .collect() +} + +/// Convert one snapshot cell to a renderer cell. +fn snapshot_cell_to_emu(cell: &crate::snapshot::SnapshotCell) -> EmuCell { + let mut attrs = Attrs::empty(); + for flag in [ + Attrs::BOLD, + Attrs::DIM, + Attrs::ITALIC, + Attrs::INVERSE, + Attrs::INVISIBLE, + Attrs::STRIKE, + Attrs::BLINK, + ] { + if cell.has(flag) { + attrs |= flag; + } + } + EmuCell { + ch: CompactString::from(cell.text()), + fg: snapshot_color_to_emu(cell.fg()), + bg: snapshot_color_to_emu(cell.bg()), + underline: snapshot_underline_to_emu(cell.underline()), + underline_color: snapshot_color_to_emu(cell.underline_color()), + attrs, + hyperlink: cell.link_uri().map(|uri| { + Arc::new(Hyperlink { + id: cell.link_id().map(CompactString::from), + uri: CompactString::from(uri), + }) + }), + } +} + +/// Snapshot color references become the cell slots the renderer resolves: +/// defaults stay defaults, indices keep their normalization, RGB stays RGB. +fn snapshot_color_to_emu(color: SnapshotColor) -> Option { + match color { + SnapshotColor::Default => None, + SnapshotColor::Indexed(index) => Some(Color::from_index(index)), + SnapshotColor::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)), + } +} + +/// Snapshot underlines use the same shapes the renderer draws. +fn snapshot_underline_to_emu(underline: SnapshotUnderline) -> UnderlineStyle { + match underline { + SnapshotUnderline::None => UnderlineStyle::None, + SnapshotUnderline::Single => UnderlineStyle::Single, + SnapshotUnderline::Double => UnderlineStyle::Double, + SnapshotUnderline::Curly => UnderlineStyle::Curly, + SnapshotUnderline::Dotted => UnderlineStyle::Dotted, + SnapshotUnderline::Dashed => UnderlineStyle::Dashed, + } +} + +/// Resolve snapshot colors to the render state the renderer reads. +fn snapshot_colors(snapshot: &TerminalSnapshot) -> RenderState { + RenderState::from_parts( + *snapshot.palette(), + snapshot.default_foreground(), + snapshot.default_background(), + snapshot.default_cursor(), + match snapshot.cursor().shape() { + crate::snapshot::SnapshotCursorShape::Block => CursorShape::Block, + crate::snapshot::SnapshotCursorShape::Underline => CursorShape::Underline, + crate::snapshot::SnapshotCursorShape::Bar => CursorShape::Bar, + }, + ) +} + +/// Where to draw the cursor in the captured rows, or `None`. +/// +/// A hidden cursor is never drawn, and one clipped out of the capture keeps +/// its real position on the snapshot but stays off the image, exactly like a +/// live screenshot. +fn snapshot_cursor_position(snapshot: &TerminalSnapshot) -> Option<(u16, usize)> { + if !snapshot.cursor().visible() { + return None; + } + let (col, row) = snapshot.cursor_local()?; + let col = u16::try_from(col).ok()?; + let row = usize::try_from(row).ok()?; + Some((col, row)) +} + +/// One internal recording frame from a snapshot, for the raster renderer. +/// +/// The frame type blocks external access by design; this conversion runs +/// inside the crate so offline rendering needs no public worker or sampler +/// types. +#[cfg(feature = "recording-raster")] +fn snapshot_frame(snapshot: &TerminalSnapshot) -> crate::record::frames::Frame { + crate::record::frames::Frame { + grid: snapshot_rows(snapshot), + title: snapshot.title().map(str::to_string), + duration: std::time::Duration::ZERO, + render_state: snapshot_colors(snapshot), + cursor: snapshot_cursor_position(snapshot), + } +} + +/// The title the bar shows, or `None` with the bar off or nothing fitting. +fn shown_title( + title: Option<&str>, + cols: u16, + rows: usize, + layout: &ResolvedLayout, +) -> Option { + if !layout.title_bar { + return None; + } + let (panel_width, _) = layout.panel_size(cols as usize, rows); + super::svg::visible_title(title, cols, rows, panel_width, layout) +} diff --git a/crates/tui-test/src/render/raster.rs b/crates/tui-test/src/render/raster.rs index 3fc2419d..c6ce05d1 100644 --- a/crates/tui-test/src/render/raster.rs +++ b/crates/tui-test/src/render/raster.rs @@ -9,7 +9,7 @@ use crate::terminal::cell::{EmuCell, CONTINUATION}; use crate::terminal::emu::CursorShape; use super::svg; -use super::svg::RenderColors; +use super::svg::{RenderColors, ResolvedLayout}; mod draw; mod font; @@ -61,8 +61,8 @@ pub struct GridRenderer { height: u32, pixmap: Pixmap, fonts: FontSystem, - style: Style, - /// Overrides the style's canvas background for one capture. + layout: ResolvedLayout, + /// Overrides the layout's canvas background for one capture. background: Option, } @@ -117,15 +117,50 @@ impl GridRenderer { anyhow::bail!("recording zoom must be finite and greater than zero"); } style.validate().map_err(|error| anyhow::anyhow!(error))?; + Self::with_layout_and_size( + cols, + rows, + ResolvedLayout::from_style(&style, zoom), + background, + exact_size, + ) + } + + /// Build from an already-resolved layout, for offline snapshot rendering. + /// + /// The caller validates the layout's metrics; this checks the zoom and + /// the checked image-size arithmetic again before allocating the pixmap. + pub(crate) fn with_layout( + cols: u16, + rows: usize, + layout: ResolvedLayout, + background: Option, + exact_size: bool, + ) -> anyhow::Result { + if !layout.zoom.is_finite() || layout.zoom <= 0.0 || layout.zoom > f64::from(f32::MAX) { + anyhow::bail!("recording zoom must be finite and greater than zero"); + } + Self::with_layout_and_size(cols, rows, layout, background, exact_size) + } + + fn with_layout_and_size( + cols: u16, + rows: usize, + layout: ResolvedLayout, + background: Option, + exact_size: bool, + ) -> anyhow::Result { + let zoom = layout.zoom; let (base_width, base_height) = if exact_size { - svg::exact_pixel_size(cols, rows, &style) + layout.grid_pixel_size(cols, rows) } else { - svg::pixel_size(cols, rows, &style) + let (width, height) = layout.grid_pixel_size(cols, rows); + (width + width % 2, height + height % 2) }; - let horizontal = style + let horizontal = layout .canvas_horizontal() .ok_or_else(|| anyhow::anyhow!("recording canvas padding must fit in u32"))?; - let vertical = style + let vertical = layout .canvas_vertical() .ok_or_else(|| anyhow::anyhow!("recording canvas padding must fit in u32"))?; let width = base_width @@ -152,8 +187,8 @@ impl GridRenderer { pixmap: Pixmap::new(width, height).ok_or_else(|| { anyhow::anyhow!("terminal recording dimensions must fit a pixmap") })?, - fonts: FontSystem::new(&style.font)?, - style, + fonts: FontSystem::new(&layout.font)?, + layout, background, }) } @@ -176,19 +211,19 @@ impl FrameRenderer for GridRenderer { let scale = self.scale; let colors = &frame.render_state; - let style = &self.style; + let layout = &self.layout; let (base_width, base_height) = if self.exact_size { - svg::exact_pixel_size(cols, rows, style) + layout.grid_pixel_size(cols, rows) } else { - svg::pixel_size(cols, rows, style) + layout.even_grid_pixel_size(cols, rows) }; let panel_width = scaled_dimension(base_width, f64::from(self.scale), "frame width")?; let panel_height = scaled_dimension(base_height, f64::from(self.scale), "frame height")?; // Center smaller frames inside the configured padding after a resize. - let pad_left = style.canvas_left() as f32 * scale; - let pad_top = style.canvas_top() as f32 * scale; - let pad_right = style.canvas_right() as f32 * scale; - let pad_bottom = style.canvas_bottom() as f32 * scale; + let pad_left = layout.canvas_left as f32 * scale; + let pad_top = layout.canvas_top as f32 * scale; + let pad_right = layout.canvas_right as f32 * scale; + let pad_bottom = layout.canvas_bottom as f32 * scale; let content_width = (self.width as f32 - pad_left - pad_right).max(0.0); let content_height = (self.height as f32 - pad_top - pad_bottom).max(0.0); let origin_x = pad_left + (content_width - panel_width as f32).max(0.0) / 2.0; @@ -203,9 +238,9 @@ impl FrameRenderer for GridRenderer { } None => { self.pixmap.fill(tiny_skia::Color::from_rgba8( - style.canvas_background.r, - style.canvas_background.g, - style.canvas_background.b, + layout.canvas_background.r, + layout.canvas_background.g, + layout.canvas_background.b, 255, )); } @@ -217,7 +252,7 @@ impl FrameRenderer for GridRenderer { panel_width as f32, panel_height as f32, scale, - style, + layout, ); fill_rounded_rect( &mut self.pixmap, @@ -225,39 +260,39 @@ impl FrameRenderer for GridRenderer { origin_y, panel_width as f32, panel_height as f32, - style.border.radius * scale, + layout.border_radius * scale, colors.resolve(None, false), ); let mut missing = BTreeSet::new(); - if style.window.title_bar { + if layout.title_bar { fill_top_rounded_rect( &mut self.pixmap, origin_x, origin_y, panel_width as f32, - (style.header_height() - style.divider_height()) * scale, - style.border.radius * scale, - style.window.background, + (layout.header_h - layout.divider_h) * scale, + layout.border_radius * scale, + layout.window_background, ); fill_antialiased_rect( &mut self.pixmap, origin_x, - origin_y + (style.header_height() - style.divider_height()) * scale, + origin_y + (layout.header_h - layout.divider_h) * scale, panel_width as f32, - style.divider_height() * scale, - style.window.divider, + layout.divider_h * scale, + layout.window_divider, ); - let lights = style.window.traffic_lights(); + let lights = layout.traffic_lights(); for (index, color) in lights.iter().copied().enumerate() { - let cx = origin_x + (style.content_left() + 5.0 + index as f32 * 20.0) * scale; - let cy = origin_y + style.header_height() / 2.0 * scale; + let cx = origin_x + (layout.content_left + 5.0 + index as f32 * 20.0) * scale; + let cy = origin_y + layout.header_h / 2.0 * scale; fill_circle(&mut self.pixmap, cx, cy, svg::DOT_R * scale, color); } if !lights.is_empty() { fill_circle( &mut self.pixmap, - origin_x + (style.content_left() + 5.0) * scale, - origin_y + style.header_height() / 2.0 * scale, + origin_x + (layout.content_left + 5.0) * scale, + origin_y + layout.header_h / 2.0 * scale, svg::RED_DOT_R * scale, svg::RED_DOT_COLOR, ); @@ -273,7 +308,7 @@ impl FrameRenderer for GridRenderer { origin_y, scale, &mut missing, - style, + layout, ); } @@ -290,10 +325,10 @@ impl FrameRenderer for GridRenderer { run += 1; } if background != colors.resolve(None, false) { - let left = grid_x(origin_x, x, scale, style); - let right = grid_x(origin_x, x + run, scale, style); - let top = grid_y(origin_y, y, scale, style); - let bottom = grid_y(origin_y, y + 1, scale, style); + let left = grid_x(origin_x, x, scale, layout); + let right = grid_x(origin_x, x + run, scale, layout); + let top = grid_y(origin_y, y, scale, layout); + let bottom = grid_y(origin_y, y + 1, scale, layout); fill_pixel_rect(&mut self.pixmap, left, top, right, bottom, background); } x += run; @@ -320,19 +355,16 @@ impl FrameRenderer for GridRenderer { 1 }; let cell_origin_x = - origin_x + (style.content_left() + x as f32 * style.cell_width()) * scale; + origin_x + (layout.content_left + x as f32 * layout.cell_w) * scale; let cell_origin_y = origin_y - + (style.header_height() - + style.content_top() - + y as f32 * style.cell_height()) - * scale; - let cell_width = style.cell_width() * span as f32 * scale; - let cell_height = style.cell_height() * scale; + + (layout.header_h + layout.content_top + y as f32 * layout.cell_h) * scale; + let cell_width = layout.cell_w * span as f32 * scale; + let cell_height = layout.cell_h * scale; let baseline = origin_y - + (style.header_height() - + style.content_top() - + y as f32 * style.cell_height() - + style.baseline()) + + (layout.header_h + + layout.content_top + + y as f32 * layout.cell_h + + layout.baseline) * scale; if unsupported_grapheme(cell.ch.as_str()) { @@ -358,7 +390,7 @@ impl FrameRenderer for GridRenderer { cell_height, baseline, paint.fg, - style.font_size, + layout.font_size, scale, ), None => { @@ -381,7 +413,7 @@ impl FrameRenderer for GridRenderer { fill_antialiased_rect( pixmap, cell_origin_x, - baseline - style.font_size * 0.32 * scale, + baseline - layout.font_size * 0.32 * scale, cell_width, scale.max(1.0), paint.fg, @@ -401,21 +433,21 @@ impl FrameRenderer for GridRenderer { origin_y, scale, &mut missing, - style, + layout, ); } // Last, so the content it frames cannot paint over it. - if style.border.width > 0.0 { + if layout.border_width > 0.0 { stroke_rounded_rect( &mut self.pixmap, origin_x, origin_y, panel_width as f32, panel_height as f32, - style.border.radius * scale, - style.border.color, - style.border.width * scale, + layout.border_radius * scale, + layout.border_color, + layout.border_width * scale, ); } @@ -443,14 +475,13 @@ impl FrameRenderer for GridRenderer { } } -fn grid_x(origin_x: f32, column: usize, scale: f32, style: &Style) -> u32 { - (origin_x + (style.content_left() + column as f32 * style.cell_width()) * scale).round() as u32 +fn grid_x(origin_x: f32, column: usize, scale: f32, layout: &ResolvedLayout) -> u32 { + (origin_x + (layout.content_left + column as f32 * layout.cell_w) * scale).round() as u32 } -fn grid_y(origin_y: f32, row: usize, scale: f32, style: &Style) -> u32 { - (origin_y - + (style.header_height() + style.content_top() + row as f32 * style.cell_height()) * scale) - .round() as u32 +fn grid_y(origin_y: f32, row: usize, scale: f32, layout: &ResolvedLayout) -> u32 { + (origin_y + (layout.header_h + layout.content_top + row as f32 * layout.cell_h) * scale).round() + as u32 } #[allow(clippy::too_many_arguments)] @@ -464,7 +495,7 @@ fn draw_cursor( panel_origin_y: f32, scale: f32, missing: &mut BTreeSet, - style: &Style, + layout: &ResolvedLayout, ) { let Some(row) = grid.get(cy) else { return; @@ -481,16 +512,15 @@ fn draw_cursor( 1 }; let column = usize::from(cx); - let origin_x = - panel_origin_x + (style.content_left() + f32::from(cx) * style.cell_width()) * scale; - let origin_y = panel_origin_y - + (style.header_height() + style.content_top() + cy as f32 * style.cell_height()) * scale; - let cell_width = style.cell_width() * span as f32 * scale; - let cell_height = style.cell_height() * scale; - let left = grid_x(panel_origin_x, column, scale, style); - let right = grid_x(panel_origin_x, column + span, scale, style); - let top = grid_y(panel_origin_y, cy, scale, style); - let bottom = grid_y(panel_origin_y, cy + 1, scale, style); + let origin_x = panel_origin_x + (layout.content_left + f32::from(cx) * layout.cell_w) * scale; + let origin_y = + panel_origin_y + (layout.header_h + layout.content_top + cy as f32 * layout.cell_h) * scale; + let cell_width = layout.cell_w * span as f32 * scale; + let cell_height = layout.cell_h * scale; + let left = grid_x(panel_origin_x, column, scale, layout); + let right = grid_x(panel_origin_x, column + span, scale, layout); + let top = grid_y(panel_origin_y, cy, scale, layout); + let bottom = grid_y(panel_origin_y, cy + 1, scale, layout); let thickness = (2.0 * scale).round().max(1.0) as u32; let color = colors.color(ColorSlot::Cursor); match colors.cursor_shape() { @@ -533,10 +563,7 @@ fn draw_cursor( return; } let baseline = panel_origin_y - + (style.header_height() - + style.content_top() - + cy as f32 * style.cell_height() - + style.baseline()) + + (layout.header_h + layout.content_top + cy as f32 * layout.cell_h + layout.baseline) * scale; for character in cell.ch.chars() { if is_default_ignorable(character) { @@ -557,7 +584,7 @@ fn draw_cursor( cell_height, baseline, svg::bg_of(cell, colors), - style.font_size, + layout.font_size, scale, ), None => { @@ -574,9 +601,9 @@ fn draw_shadow( width: f32, height: f32, scale: f32, - style: &Style, + layout: &ResolvedLayout, ) { - for (spread, offset_y, alpha) in style.shadow_layers() { + for (spread, offset_y, alpha) in layout.shadow_layers() { let spread = spread * scale; fill_rounded_rect_alpha( pixmap, @@ -584,8 +611,8 @@ fn draw_shadow( y - spread + offset_y * scale, width + spread * 2.0, height + spread * 2.0, - style.border.radius * scale + spread, - style.shadow.color, + layout.border_radius * scale + spread, + layout.shadow_color, alpha, ); } @@ -603,15 +630,15 @@ fn draw_title( origin_y: f32, scale: f32, missing: &mut BTreeSet, - style: &Style, + layout: &ResolvedLayout, ) { - let Some(title) = svg::visible_title(title, cols, rows, panel_width, style) else { + let Some(title) = svg::visible_title(title, cols, rows, panel_width, layout) else { return; }; - let advance = svg::title_advance(style); + let advance = svg::title_advance(layout); let title_width = crate::terminal::cell::display_width(&title) as f32 * advance * scale; let mut x = origin_x + (panel_width * scale - title_width) / 2.0; - let baseline = origin_y + (style.header_height() / 2.0 + style.title_font_size * 0.35) * scale; + let baseline = origin_y + (layout.header_h / 2.0 + layout.title_font_size * 0.35) * scale; for character in title.chars() { let columns = crate::terminal::cell::display_width(&character.to_string()).max(1); @@ -629,10 +656,10 @@ fn draw_title( x, origin_y, width, - style.header_height() * scale, + layout.header_h * scale, baseline, - style.window.foreground, - style.title_font_size, + layout.window_foreground, + layout.title_font_size, scale, ), None => { diff --git a/crates/tui-test/src/render/raster/tests.rs b/crates/tui-test/src/render/raster/tests.rs index 8cf459cf..3f43e59c 100644 --- a/crates/tui-test/src/render/raster/tests.rs +++ b/crates/tui-test/src/render/raster/tests.rs @@ -153,7 +153,7 @@ fn adjacent_background_cells_are_seamless_at_fractional_zoom() { .unwrap(); let image = renderer.render(&frame(grid.clone())).unwrap(); let (panel_width, panel_height) = - crate::render::svg::pixel_size(backgrounds.len() as u16, rows, &Style::default()); + crate::render::svg::test_pixel_size(backgrounds.len() as u16, rows, &Style::default()); let panel_width = super::scaled_dimension(panel_width, zoom, "test width").unwrap(); let panel_height = super::scaled_dimension(panel_height, zoom, "test height").unwrap(); let origin_x = (image.dimensions().0 - panel_width) as f32 / 2.0; @@ -200,7 +200,8 @@ fn block_cursor_is_aligned_with_background_cells_at_fractional_zoom() { .color(crate::profile::ColorSlot::Cursor); let mut renderer = GridRenderer::with_zoom(4, 1, zoom, Style::default()).unwrap(); let image = renderer.render(&content).unwrap(); - let (panel_width, panel_height) = crate::render::svg::pixel_size(4, 1, &Style::default()); + let (panel_width, panel_height) = + crate::render::svg::test_pixel_size(4, 1, &Style::default()); let panel_width = super::scaled_dimension(panel_width, zoom, "test width").unwrap(); let panel_height = super::scaled_dimension(panel_height, zoom, "test height").unwrap(); let origin_x = (image.dimensions().0 - panel_width) as f32 / 2.0; @@ -247,7 +248,7 @@ fn smaller_terminal_is_centered_on_the_recording_canvas() { let expected_background = content.render_state.resolve(None, false); let image = renderer.render(&content).unwrap(); let (width, height) = image.dimensions(); - let (panel_width, panel_height) = crate::render::svg::pixel_size(2, 1, &Style::default()); + let (panel_width, panel_height) = crate::render::svg::test_pixel_size(2, 1, &Style::default()); let origin_x = (width - panel_width) / 2; let origin_y = (height - panel_height) / 2; @@ -599,7 +600,7 @@ fn the_raster_canvas_is_drawn_from_its_style() { let mut renderer = GridRenderer::with_zoom(4, 2, 1.0, style.clone()).unwrap(); let plain = GridRenderer::new(4, 2); - let (panel_width, panel_height) = crate::render::svg::pixel_size(4, 2, &style); + let (panel_width, panel_height) = crate::render::svg::test_pixel_size(4, 2, &style); assert_eq!( renderer.dimensions(), ( @@ -644,7 +645,7 @@ fn a_border_is_stroked_onto_the_raster_canvas() { .render(&frame(vec![vec![EmuCell::blank(); 6]; 2])) .unwrap(); - let (panel_width, _) = crate::render::svg::pixel_size(6, 2, &style); + let (panel_width, _) = crate::render::svg::test_pixel_size(6, 2, &style); // The panel is centered, so its left edge sits one padding in. Two pixels // further is the middle of a four-wide stroke. let middle_of_stroke = (image.dimensions().0 - panel_width) / 2 + 2; @@ -696,7 +697,7 @@ fn the_raster_window_sits_at_its_own_gaps() { ..Style::default() }; let mut renderer = GridRenderer::with_zoom(6, 2, 1.0, style.clone()).unwrap(); - let (panel_width, panel_height) = crate::render::svg::pixel_size(6, 2, &style); + let (panel_width, panel_height) = crate::render::svg::test_pixel_size(6, 2, &style); assert_eq!( renderer.dimensions(), diff --git a/crates/tui-test/src/render/svg.rs b/crates/tui-test/src/render/svg.rs index f4b5cf1c..fc3a6f42 100644 --- a/crates/tui-test/src/render/svg.rs +++ b/crates/tui-test/src/render/svg.rs @@ -13,15 +13,15 @@ use std::fmt::Write; use super::nerd_font::NerdFont; use crate::api::CaptureBackground; use crate::profile::{ColorSlot, Profile, Rgb}; -use crate::render::style::Style; +use crate::render::style::{FontFamilies, Style}; use crate::terminal::cell::{truncate_to_columns, Attrs, Color, EmuCell, CONTINUATION}; use crate::terminal::emu::{CursorShape, Emulator}; pub(crate) const DOT_R: f32 = 7.0; pub(crate) const RED_DOT_R: f32 = 2.5; pub(crate) const RED_DOT_COLOR: Rgb = Rgb::new(105, 17, 10); -fn dots_right(style: &Style) -> f32 { - style.content_left() + 5.0 + 2.0 * 20.0 + DOT_R +fn dots_right(layout: &ResolvedLayout) -> f32 { + layout.content_left + 5.0 + 2.0 * 20.0 + DOT_R } fn hex(c: Rgb) -> String { @@ -130,6 +130,191 @@ impl RenderColors for RenderState { } } +impl RenderState { + /// Build from resolved snapshot data, without a live emulator. + /// + /// The offline renderer converts a [`crate::snapshot::TerminalSnapshot`] + /// into these fields so snapshots render through the same code as live + /// screenshots; the recording [`crate::record::frames::Frame`] stays + /// internal to the crate. + pub(crate) fn from_parts( + indexed: [Rgb; 256], + foreground: Rgb, + background: Rgb, + cursor: Rgb, + cursor_shape: CursorShape, + ) -> Self { + Self { + indexed, + foreground, + background, + cursor, + cursor_shape, + } + } +} + +/// One resolved geometry and chrome bundle for both render paths. +/// +/// Live screenshots and recordings build it from a [`Style`]; offline +/// snapshot rendering builds it from +/// [`crate::render::offline::SnapshotRenderOptions`]. The SVG and raster +/// renderers both draw from it, so there is one renderer, not one per input +/// kind. Layout defaults are preserved by construction: [`from_style`] copies +/// the style's resolved values unchanged. +#[derive(Debug, Clone)] +pub(crate) struct ResolvedLayout { + pub cell_w: f32, + pub cell_h: f32, + pub font_size: f32, + pub title_font_size: f32, + pub baseline: f32, + pub header_h: f32, + pub divider_h: f32, + pub content_left: f32, + pub content_top: f32, + pub content_right: f32, + pub content_bottom: f32, + pub canvas_left: u32, + pub canvas_right: u32, + pub canvas_top: u32, + pub canvas_bottom: u32, + pub canvas_background: Rgb, + pub font: FontFamilies, + pub title_bar: bool, + pub title_controls: bool, + pub window_background: Rgb, + pub window_foreground: Rgb, + pub window_divider: Rgb, + pub border_width: f32, + pub border_color: Rgb, + pub border_radius: f32, + pub shadow_enabled: bool, + pub shadow_color: Rgb, + pub shadow_offset: f32, + pub shadow_spread: f32, + pub zoom: f64, +} + +impl ResolvedLayout { + /// Resolve a live style at one zoom, copying every value unchanged. + pub(crate) fn from_style(style: &Style, zoom: f64) -> Self { + Self { + cell_w: style.cell_width(), + cell_h: style.cell_height(), + font_size: style.font_size, + title_font_size: style.title_font_size, + baseline: style.baseline(), + header_h: style.header_height(), + divider_h: style.divider_height(), + content_left: style.content_left(), + content_top: style.content_top(), + content_right: style.content_right(), + content_bottom: style.content_bottom(), + canvas_left: style.canvas_left(), + canvas_right: style.canvas_right(), + canvas_top: style.canvas_top(), + canvas_bottom: style.canvas_bottom(), + canvas_background: style.canvas_background, + font: style.font.clone(), + title_bar: style.window.title_bar, + title_controls: style.window.traffic_lights, + window_background: style.window.background, + window_foreground: style.window.foreground, + window_divider: style.window.divider, + border_width: style.border.width, + border_color: style.border.color, + border_radius: style.border.radius, + shadow_enabled: style.shadow.enabled, + shadow_color: style.shadow.color, + shadow_offset: style.shadow.offset, + shadow_spread: style.shadow.spread, + zoom, + } + } + + /// The shadow's stacked rectangles, empty when it is turned off. + /// + /// Kept in sync with the shadow math the style used before the layout + /// type existed: `(spread, offset, alpha)` layers, largest first. + pub(crate) fn shadow_layers(&self) -> Vec<(f32, f32, u8)> { + if !self.shadow_enabled { + return Vec::new(); + } + let spread_step = self.shadow_spread * 2.0 / 7.0; + let offset_step = self.shadow_offset / 5.0; + let layers: [(f32, f32, u8); 4] = std::array::from_fn(|index| { + let step = index as f32; + ( + self.shadow_spread - step * spread_step, + self.shadow_offset - step * offset_step, + 18 + index as u8 * 2, + ) + }); + layers.to_vec() + } + + /// The three controls, drawn only with the title bar and its switch on. + pub(crate) fn traffic_lights(&self) -> &[Rgb] { + const LIGHTS: [Rgb; 3] = [ + Rgb::new(236, 106, 94), + Rgb::new(244, 191, 79), + Rgb::new(97, 197, 84), + ]; + if self.title_bar && self.title_controls { + &LIGHTS + } else { + &[] + } + } + + #[cfg(feature = "recording-raster")] + /// What the canvas grows by to hold the window, or `None` past `u32`. + pub(crate) fn canvas_horizontal(&self) -> Option { + self.canvas_left.checked_add(self.canvas_right) + } + + #[cfg(feature = "recording-raster")] + /// What the canvas grows by to hold the window, or `None` past `u32`. + pub(crate) fn canvas_vertical(&self) -> Option { + self.canvas_top.checked_add(self.canvas_bottom) + } + + /// F32 panel size for the grid: content gaps, header, and cells, no canvas. + pub(crate) fn panel_size(&self, cols: usize, rows: usize) -> (f32, f32) { + let width = self.content_left + self.content_right + cols as f32 * self.cell_w; + let height = self.header_h + + self.content_top + + self.content_bottom + + rows.max(1) as f32 * self.cell_h; + (width, height) + } + + #[cfg(feature = "recording-raster")] + /// Ceiled grid pixel size for the raster renderer, before canvas and zoom. + /// + /// Screenshots keep the exact size; recordings round up to even + /// dimensions for the video encoders through + /// [`even_grid_pixel_size`]. + pub(crate) fn grid_pixel_size(&self, cols: u16, rows: usize) -> (u32, u32) { + let width = + (self.content_left + self.content_right + f32::from(cols) * self.cell_w).ceil() as u32; + let height = (self.header_h + + self.content_top + + self.content_bottom + + rows.max(1) as f32 * self.cell_h) + .ceil() as u32; + (width, height) + } + + #[cfg(feature = "recording-raster")] + /// Ceiled grid pixel size rounded up to even dimensions, for recordings. + pub(crate) fn even_grid_pixel_size(&self, cols: u16, rows: usize) -> (u32, u32) { + let (width, height) = self.grid_pixel_size(cols, rows); + (width + width % 2, height + height % 2) + } +} + /// Resolved background color for a cell (honoring inverse). pub(crate) fn bg_of(cell: &EmuCell, colors: &dyn RenderColors) -> Rgb { let bg = colors.resolve(cell.bg, false); @@ -214,19 +399,19 @@ fn write_text_run( y: usize, paint: CellPaint, nerd_font: &NerdFont, - style: &Style, + layout: &ResolvedLayout, ) { if paint.invisible { return; } let (start, end) = (run.start, run.end); - let cell_w = style.cell_width(); - let cell_h = style.cell_height(); - let header_h = style.header_height(); - let font_baseline = style.baseline(); + let cell_w = layout.cell_w; + let cell_h = layout.cell_h; + let header_h = layout.header_h; + let font_baseline = layout.baseline; let fg = hex(paint.fg); - let tx = style.content_left() + start as f32 * cell_w; - let baseline = header_h + style.content_top() + y as f32 * cell_h + font_baseline; + let tx = layout.content_left + start as f32 * cell_w; + let baseline = header_h + layout.content_top + y as f32 * cell_h + font_baseline; let width = (end - start) as f32 * cell_w; let original_text = run_text(row, start, end); let (text, run_x_adjust) = nerd_font.prepare_run(&original_text, width, cell_w); @@ -249,8 +434,8 @@ fn write_text_run( (false, true) => r#" text-decoration="line-through""#, (false, false) => "", }; - let resolved = style.font.resolve(paint.bold, paint.italic); - let family = if resolved == style.font.family { + let resolved = layout.font.resolve(paint.bold, paint.italic); + let family = if resolved == layout.font.family { String::new() } else { format!(r#" font-family="{}""#, escape_attribute(resolved)) @@ -267,8 +452,8 @@ fn write_text_run( out, c, ( - style.content_left() + i as f32 * cell_w, - header_h + style.content_top() + y as f32 * cell_h, + layout.content_left + i as f32 * cell_w, + header_h + layout.content_top + y as f32 * cell_h, ), (cell_w, cell_h), run_x_adjust, @@ -278,8 +463,8 @@ fn write_text_run( } } -pub(crate) fn title_advance(style: &Style) -> f32 { - style.title_font_size * (style.cell_width() / style.font_size) +pub(crate) fn title_advance(layout: &ResolvedLayout) -> f32 { + layout.title_font_size * (layout.cell_w / layout.font_size) } pub(crate) fn media_title(title: Option<&str>, cols: u16, rows: usize, fits: usize) -> String { @@ -305,16 +490,16 @@ pub(crate) fn visible_title( cols: u16, rows: usize, width: f32, - style: &Style, + layout: &ResolvedLayout, ) -> Option { const GAP: f32 = 8.0; - let margin = if style.window.traffic_lights { - dots_right(style) + GAP + let margin = if layout.title_controls { + dots_right(layout) + GAP } else { - style.content_left().max(style.content_right()).max(GAP) + layout.content_left.max(layout.content_right).max(GAP) }; let available = width - 2.0 * margin; - let fits = (available / title_advance(style)).floor().max(0.0) as usize; + let fits = (available / title_advance(layout)).floor().max(0.0) as usize; if fits == 0 { return None; } @@ -327,16 +512,16 @@ fn write_title( cols: u16, rows: usize, width: f32, - style: &Style, + layout: &ResolvedLayout, ) { - let Some(shown) = visible_title(title, cols, rows, width, style) else { + let Some(shown) = visible_title(title, cols, rows, width, layout) else { return; }; - let header_h = style.header_height(); - let title_font_size = style.title_font_size; - let title_fg = style.window.foreground; - let resolved = style.font.resolve(true, false); - let family = if resolved == style.font.family { + let header_h = layout.header_h; + let title_font_size = layout.title_font_size; + let title_fg = layout.window_foreground; + let resolved = layout.font.resolve(true, false); + let family = if resolved == layout.font.family { String::new() } else { format!(r#" font-family="{}""#, escape_attribute(resolved)) @@ -366,14 +551,14 @@ fn write_cursor( (cx, cy): (u16, usize), colors: &dyn RenderColors, nerd_font: &NerdFont, - style: &Style, + layout: &ResolvedLayout, ) { let Some(row) = rows.get(cy) else { return; }; - let cell_w = style.cell_width(); - let cell_h = style.cell_height(); - let header_h = style.header_height(); + let cell_w = layout.cell_w; + let cell_h = layout.cell_h; + let header_h = layout.header_h; let cell = cell_at(row, cx as usize); // A double-width character stores its second half as a continuation cell, // so the cursor has to cover both or it clips the glyph down the middle. @@ -386,8 +571,8 @@ fn write_cursor( 1.0 }; let w = span * cell_w; - let x = style.content_left() + cx as f32 * cell_w; - let y = header_h + style.content_top() + cy as f32 * cell_h; + let x = layout.content_left + cx as f32 * cell_w; + let y = header_h + layout.content_top + cy as f32 * cell_h; let fill = hex(colors.color(ColorSlot::Cursor)); let (rx, ry, rw, rh) = match colors.cursor_shape() { @@ -412,7 +597,7 @@ fn write_cursor( cy, paint, nerd_font, - style, + layout, ); } @@ -423,6 +608,9 @@ fn write_cursor( /// /// Its row is a `usize` because it indexes `rows`, which for a full-history /// render is as long as the scrollback and so is not bounded by the screen. +/// +/// The style entry point keeps the recorded layout: it resolves the style to +/// a [`ResolvedLayout`] and draws from that, exactly like offline rendering. #[allow(clippy::too_many_arguments)] pub(crate) fn render_svg( rows: &[Vec], @@ -434,29 +622,46 @@ pub(crate) fn render_svg( zoom: f64, background: Option, ) -> String { - let cell_w = style.cell_width(); - let cell_h = style.cell_height(); - let font_size = style.font_size; - let header_h = style.header_height(); - let divider_h = style.divider_height(); - let radius = style.border.radius; - let shadow_color = style.shadow.color; - let title_bg = style.window.background; - let title_divider = style.window.divider; - let font_family = escape_attribute(&style.font.family); + let layout = ResolvedLayout::from_style(style, zoom); + render_svg_with_layout(rows, cols, colors, cursor, title, &layout, background) +} + +/// Draw the grid from an already-resolved layout. +/// +/// Live screenshots resolve their [`Style`] first; offline snapshots resolve +/// [`crate::render::offline::SnapshotRenderOptions`]. Both draw here, so +/// there is one renderer. +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_svg_with_layout( + rows: &[Vec], + cols: u16, + colors: &dyn RenderColors, + cursor: Option<(u16, usize)>, + title: Option<&str>, + layout: &ResolvedLayout, + background: Option, +) -> String { + let cell_w = layout.cell_w; + let cell_h = layout.cell_h; + let font_size = layout.font_size; + let header_h = layout.header_h; + let divider_h = layout.divider_h; + let radius = layout.border_radius; + let shadow_color = layout.shadow_color; + let title_bg = layout.window_background; + let title_divider = layout.window_divider; + let font_family = escape_attribute(&layout.font.family); let nerd_font = NerdFont::new(rows, font_size); let cols = cols as usize; - let x0 = style.content_left(); - let y0 = header_h + style.content_top(); - let panel_width = style.content_left() + style.content_right() + cols as f32 * cell_w; - let panel_height = - header_h + style.content_top() + style.content_bottom() + rows.len().max(1) as f32 * cell_h; - let pad_left = style.canvas_left() as f32; - let pad_top = style.canvas_top() as f32; - let width = panel_width + pad_left + style.canvas_right() as f32; - let height = panel_height + pad_top + style.canvas_bottom() as f32; - let output_width = svg_dimension(f64::from(width) * zoom); - let output_height = svg_dimension(f64::from(height) * zoom); + let x0 = layout.content_left; + let y0 = header_h + layout.content_top; + let (panel_width, panel_height) = layout.panel_size(cols, rows.len()); + let pad_left = layout.canvas_left as f32; + let pad_top = layout.canvas_top as f32; + let width = panel_width + pad_left + layout.canvas_right as f32; + let height = panel_height + pad_top + layout.canvas_bottom as f32; + let output_width = svg_dimension(f64::from(width) * layout.zoom); + let output_height = svg_dimension(f64::from(height) * layout.zoom); let mut out = String::new(); let _ = write!( @@ -477,11 +682,11 @@ pub(crate) fn render_svg( let _ = write!( out, r#""#, - hex(style.canvas_background) + hex(layout.canvas_background) ); } } - for (spread, offset_y, alpha) in style.shadow_layers() { + for (spread, offset_y, alpha) in layout.shadow_layers() { let shadow_x = pad_left - spread; let shadow_y = pad_top - spread + offset_y; let shadow_width = panel_width + spread * 2.0; @@ -499,7 +704,7 @@ pub(crate) fn render_svg( r#""#, hex(colors.resolve(None, false)) ); - if style.window.title_bar { + if layout.title_bar { let title_bottom = header_h - divider_h; let radius = radius.min(panel_width / 2.0).min(title_bottom); let right_curve = panel_width - radius; @@ -513,9 +718,9 @@ pub(crate) fn render_svg( r#""#, hex(title_divider) ); - let lights = style.window.traffic_lights(); + let lights = layout.traffic_lights(); for (i, dot) in lights.iter().copied().enumerate() { - let cx = style.content_left() + 5.0 + i as f32 * 20.0; + let cx = layout.content_left + 5.0 + i as f32 * 20.0; let _ = write!( out, r#""#, @@ -528,7 +733,7 @@ pub(crate) fn render_svg( out, r#""#, hex(RED_DOT_COLOR), - cx = style.content_left() + 5.0, + cx = layout.content_left + 5.0, cy = header_h / 2.0, ); } @@ -538,7 +743,7 @@ pub(crate) fn render_svg( cols as u16, rows.len().max(1), panel_width, - style, + layout, ); } @@ -572,28 +777,28 @@ pub(crate) fn render_svg( while x + run < cols && cell_paint(cell_at(row, x + run), colors) == paint { run += 1; } - write_text_run(&mut out, row, x..x + run, y, paint, &nerd_font, style); + write_text_run(&mut out, row, x..x + run, y, paint, &nerd_font, layout); x += run; } } if let Some(at) = cursor { - write_cursor(&mut out, rows, at, colors, &nerd_font, style); + write_cursor(&mut out, rows, at, colors, &nerd_font, layout); } // Last, so the content it frames cannot paint over it. The rect is inset // by half the stroke because SVG centers a stroke on its path, and a // border that straddled the panel edge would bleed into the padding. - if style.border.width > 0.0 { - let inset = style.border.width / 2.0; + if layout.border_width > 0.0 { + let inset = layout.border_width / 2.0; let _ = write!( out, r#""#, - (panel_width - style.border.width).max(0.0), - (panel_height - style.border.width).max(0.0), + (panel_width - layout.border_width).max(0.0), + (panel_height - layout.border_width).max(0.0), (radius - inset).max(0.0), - hex(style.border.color), - style.border.width, + hex(layout.border_color), + layout.border_width, ); } @@ -601,23 +806,13 @@ pub(crate) fn render_svg( out } -#[cfg(feature = "recording-raster")] -pub(crate) fn pixel_size(cols: u16, rows: usize, style: &Style) -> (u32, u32) { - let (width, height) = exact_pixel_size(cols, rows, style); - (width + width % 2, height + height % 2) -} - -#[cfg(feature = "recording-raster")] -pub(crate) fn exact_pixel_size(cols: u16, rows: usize, style: &Style) -> (u32, u32) { - let cell_w = style.cell_width(); - let cell_h = style.cell_height(); - let header_h = style.header_height(); - let width = - (style.content_left() + style.content_right() + f32::from(cols) * cell_w).ceil() as u32; - let height = - (header_h + style.content_top() + style.content_bottom() + rows.max(1) as f32 * cell_h) - .ceil() as u32; - (width, height) +/// Grid pixel size for tests that derive expectations from the style. +/// +/// The renderers read the same method through the layout, so these stay in +/// sync by construction rather than by duplicated math. +#[cfg(all(test, feature = "recording-raster"))] +pub(crate) fn test_pixel_size(cols: u16, rows: usize, style: &Style) -> (u32, u32) { + ResolvedLayout::from_style(style, 1.0).even_grid_pixel_size(cols, rows) } #[cfg(test)] @@ -1359,10 +1554,12 @@ mod tests { #[test] fn title_uses_the_space_released_by_disabled_traffic_lights() { let mut style = Style::default(); - assert_eq!(visible_title(Some("title"), 12, 1, 150.0, &style), None); + let layout = ResolvedLayout::from_style(&style, 1.0); + assert_eq!(visible_title(Some("title"), 12, 1, 150.0, &layout), None); style.window.traffic_lights = false; + let layout = ResolvedLayout::from_style(&style, 1.0); assert_eq!( - visible_title(Some("title"), 12, 1, 150.0, &style).as_deref(), + visible_title(Some("title"), 12, 1, 150.0, &layout).as_deref(), Some("title - 12x1") ); let svg = render_svg(&[], 12, &colors(), None, Some("title"), &style, 1.0, None); @@ -1446,12 +1643,13 @@ mod tests { fn assert_fits_clear_of_the_controls(drawn: &str, cols: f32) { let panel = Style::default().content_left() + Style::default().content_right() + cols * cell_w(); + let layout = ResolvedLayout::from_style(&geometry(), 1.0); let drawn_width = - crate::terminal::cell::display_width(drawn) as f32 * title_advance(&geometry()); + crate::terminal::cell::display_width(drawn) as f32 * title_advance(&layout); assert!( - drawn_width <= panel - 2.0 * dots_right(&Style::default()), + drawn_width <= panel - 2.0 * dots_right(&layout), "title {drawn:?} is {drawn_width} wide, past the {} available", - panel - 2.0 * dots_right(&Style::default()) + panel - 2.0 * dots_right(&layout) ); } From 6a3c88f1e362edd8cfe564391939e48a01a04105 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 18:01:32 +0700 Subject: [PATCH 06/17] U03: add public offline SVG/PNG snapshot rendering with tests Public render_snapshot_svg/render_snapshot_png (+ path wrappers) draw a TerminalSnapshot through the same renderer as live screenshots, with SnapshotRenderOptions carrying explicit cell metrics, scale, padding, and chrome switches. Hidden and clipped cursors stay hidden and are recorded in RenderEvidence with dimensions and title. Tests cover U03-A through U03-E and U03-G through U03-I. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/render/offline.rs | 5 + crates/tui-test/tests/offline_render.rs | 488 ++++++++++++++++++++++++ 2 files changed, 493 insertions(+) create mode 100644 crates/tui-test/tests/offline_render.rs diff --git a/crates/tui-test/src/render/offline.rs b/crates/tui-test/src/render/offline.rs index 252957ab..c2aa658c 100644 --- a/crates/tui-test/src/render/offline.rs +++ b/crates/tui-test/src/render/offline.rs @@ -787,6 +787,11 @@ fn layout_from_options(options: &SnapshotRenderOptions) -> Result [Rgb; 256] { + let profile = Profile::default(); + std::array::from_fn(|index| profile.colors.rgb(index as u8)) +} + +fn cell(text: &str) -> SnapshotCell { + SnapshotCell::new( + text.to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("test cell is valid") +} + +fn hidden_cursor() -> SnapshotCursor { + SnapshotCursor::new( + 0, + 0, + true, + false, + SnapshotCursorShape::Block, + Rgb::new(9, 9, 9), + None, + ) +} + +fn snapshot_parts(cols: u32, rows: u32, texts: &[&str]) -> SnapshotParts { + assert_eq!( + texts.len() as u64, + u64::from(cols) * u64::from(rows), + "test grid must fill the snapshot" + ); + SnapshotParts { + cols, + rows, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells: texts.iter().map(|text| cell(text)).collect(), + palette: resolved_palette(), + default_foreground: Rgb::new(192, 192, 192), + default_background: Rgb::new(0, 0, 0), + default_cursor: Rgb::new(9, 9, 9), + cursor: hidden_cursor(), + title: None, + metadata: SnapshotMetadata::default(), + } +} + +fn snapshot_of(cols: u32, rows: u32, texts: &[&str]) -> TerminalSnapshot { + TerminalSnapshot::try_new(snapshot_parts(cols, rows, texts)).expect("test snapshot is valid") +} + +fn undecorated() -> SnapshotRenderOptions { + use tui_test::render::style::Padding; + SnapshotRenderOptions::default() + .with_canvas_padding(Padding::Uniform(0)) + .with_content_padding(Padding::Uniform(0)) + .with_title_bar(false) + .with_title_controls(false) + .with_shadow(false) + .with_border_width(0.0) +} + +/// U03-B: offline rendering starts no process, PTY, session, or daemon. +/// +/// This test builds its snapshot from validated parts and renders it with no +/// session object anywhere in scope; success means no live machinery ran. +#[test] +fn offline_svg_renders_without_a_session() { + let snapshot = snapshot_of(2, 1, &["a", " "]); + let rendered = tui_test::render_snapshot_svg(&snapshot, &SnapshotRenderOptions::default()) + .expect("offline SVG renders"); + assert!(rendered.svg().contains(">a "), "{}", rendered.svg()); + assert_eq!((rendered.width(), rendered.height()), (98, 125)); + assert!(!rendered.evidence().cursor_drawn()); +} + +/// U03-D: hidden and clipped cursors stay hidden in SVG output. +/// +/// The cursor color is distinctive, so any drawn cursor would leave its fill +/// in the document; the evidence records the same condition. +#[test] +fn hidden_and_clipped_cursors_stay_hidden() { + for (name, cursor) in [ + ( + "hidden", + SnapshotCursor::new( + 0, + 0, + true, + false, + SnapshotCursorShape::Block, + Rgb::new(1, 2, 3), + None, + ), + ), + ( + "clipped", + SnapshotCursor::new( + 9, + 9, + false, + true, + SnapshotCursorShape::Block, + Rgb::new(1, 2, 3), + None, + ), + ), + ] { + let mut parts = snapshot_parts(2, 1, &["a", " "]); + parts.cursor = cursor; + let snapshot = TerminalSnapshot::try_new(parts).expect("test snapshot is valid"); + let rendered = tui_test::render_snapshot_svg(&snapshot, &SnapshotRenderOptions::default()) + .expect("offline SVG renders"); + assert!( + !rendered.svg().contains("#010203"), + "{name} cursor must not be drawn: {}", + rendered.svg() + ); + assert!( + !rendered.evidence().cursor_drawn(), + "{name} cursor must be recorded as not drawn" + ); + } +} + +/// U03-G (SVG half): a 3-column, 2-row grid with 10-by-20 cells and no +/// decoration reports a 30-by-40 image. +#[test] +fn undecorated_grid_reports_exact_svg_size() { + let snapshot = snapshot_of(3, 2, &[" "; 6]); + let options = undecorated().with_cell_width(10.0).with_cell_height(20.0); + let rendered = tui_test::render_snapshot_svg(&snapshot, &options).expect("offline SVG renders"); + assert_eq!((rendered.width(), rendered.height()), (30, 40)); + assert_eq!( + (rendered.evidence().width(), rendered.evidence().height()), + (30, 40) + ); +} + +/// U03-H: invalid metrics and scale values fail without a panic. +#[test] +fn invalid_metrics_and_scale_fail_without_panic() { + use tui_test::render::style::Padding; + let bad = [ + SnapshotRenderOptions::default().with_cell_width(0.0), + SnapshotRenderOptions::default().with_cell_width(-10.0), + SnapshotRenderOptions::default().with_cell_width(f32::NAN), + SnapshotRenderOptions::default().with_cell_width(f32::INFINITY), + SnapshotRenderOptions::default().with_cell_height(0.0), + SnapshotRenderOptions::default().with_font_size(0.0), + SnapshotRenderOptions::default().with_font_size(f32::NAN), + SnapshotRenderOptions::default().with_baseline(-1.0), + SnapshotRenderOptions::default().with_baseline(22.0), + SnapshotRenderOptions::default().with_baseline(f32::NAN), + SnapshotRenderOptions::default().with_scale(0.0), + SnapshotRenderOptions::default().with_scale(-2.0), + SnapshotRenderOptions::default().with_scale(f64::NAN), + SnapshotRenderOptions::default().with_scale(f64::INFINITY), + SnapshotRenderOptions::default().with_scale(f64::MAX), + SnapshotRenderOptions::default().with_border_width(-1.0), + SnapshotRenderOptions::default().with_border_width(f32::NAN), + SnapshotRenderOptions::default().with_border_radius(-4.0), + SnapshotRenderOptions::default().with_canvas_padding(Padding::Uniform(10_001)), + ]; + let snapshot = snapshot_of(1, 1, &[" "]); + for options in bad { + let error = tui_test::render_snapshot_svg(&snapshot, &options) + .expect_err("invalid metrics must fail"); + assert!( + !error.to_string().is_empty(), + "typed errors carry a message" + ); + } +} + +/// U03-I (SVG half): default `Style` values keep their previous layout. +/// +/// A default offline render of a 1-by-1 grid reports the recorded live +/// screenshot dimensions: an 88-by-125 panel with decoration. +#[test] +fn default_style_values_keep_previous_layout() { + let snapshot = snapshot_of(1, 1, &[" "]); + let rendered = tui_test::render_snapshot_svg(&snapshot, &SnapshotRenderOptions::default()) + .expect("offline SVG renders"); + assert_eq!((rendered.width(), rendered.height()), (88, 125)); + assert!( + rendered.svg().contains(r#"viewBox="0 0 88 125""#), + "default geometry is unchanged" + ); +} + +/// U03-C (SVG half): bytes and path wrappers save the same document. +#[test] +fn svg_bytes_and_path_wrappers_agree() { + let snapshot = snapshot_of(2, 1, &["a", " "]); + let options = SnapshotRenderOptions::default(); + let rendered = tui_test::render_snapshot_svg(&snapshot, &options).expect("offline SVG renders"); + let dir = std::env::temp_dir(); + let path = dir.join(format!("u03-svg-{}.svg", std::process::id())); + let evidence = + tui_test::write_snapshot_svg(&path, &snapshot, &options).expect("SVG path writes"); + let saved = std::fs::read_to_string(&path).expect("SVG file reads back"); + std::fs::remove_file(&path).ok(); + assert_eq!(saved, rendered.svg()); + assert_eq!(evidence, *rendered.evidence()); +} + +/// U03-A (SVG half): a live capture renders through the same functions. +/// +/// The offline render of a live Viewport capture must equal the live +/// screenshot SVG byte for byte: same grid conversion, same resolved +/// colors, same cursor projection, same title, same layout. +#[cfg(unix)] +#[test] +fn live_capture_and_screenshot_share_the_svg_path() { + use tui_test::{OpenOptions, Operation, RunOptions, Session, SnapshotScope}; + + let session = Session::new(format!("u03-svg-{}", std::process::id())); + let defaults = OpenOptions::default(); + session + .run(RunOptions { + backend: defaults.backend, + program: "sh".to_string(), + args: vec!["-c".to_string(), "printf 'hello-U03'; sleep 30".to_string()], + profile: defaults.profile, + style: defaults.style, + cols: 16, + rows: 2, + cwd: defaults.cwd, + env: defaults.env, + wait_ready: Some(false), + restart: defaults.restart, + timeouts: defaults.timeouts, + recording: defaults.recording, + }) + .expect("session starts"); + let deadline = Instant::now() + Duration::from_secs(15); + let marker = loop { + let text = match session.execute(Operation::Text { full: false }) { + Ok(tui_test::OperationResult::Text(text)) => text, + other => panic!("text operation gave {other:?}"), + }; + if text.contains("hello-U03") { + break text; + } + if Instant::now() >= deadline { + panic!("timed out waiting for marker; got {text:?}"); + } + std::thread::sleep(Duration::from_millis(20)); + }; + assert!(marker.contains("hello-U03")); + + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("live capture works"); + let rendered = tui_test::render_snapshot_svg(&snapshot, &SnapshotRenderOptions::default()) + .expect("offline SVG renders"); + + let path = std::env::temp_dir().join(format!("u03-live-{}.svg", std::process::id())); + session + .execute(Operation::Screenshot { + full: false, + path: Some(path.to_string_lossy().into_owned()), + zoom: None, + background: None, + }) + .expect("live screenshot works"); + session.close().expect("session closes"); + let live = std::fs::read_to_string(&path).expect("live SVG reads back"); + std::fs::remove_file(&path).ok(); + assert_eq!(live, rendered.svg()); +} + +#[cfg(feature = "recording-raster")] +mod raster_tests { + use super::*; + use tui_test::{render_snapshot_png, write_snapshot_png}; + + fn decode_png(bytes: &[u8]) -> (u32, u32, Vec) { + let decoder = png::Decoder::new(bytes); + let mut reader = decoder.read_info().expect("PNG header decodes"); + let mut pixels = vec![0; reader.output_buffer_size()]; + let info = reader.next_frame(&mut pixels).expect("PNG frame decodes"); + assert_eq!(info.color_type, png::ColorType::Rgba); + assert_eq!(info.bit_depth, png::BitDepth::Eight); + (info.width, info.height, pixels) + } + + /// U03-B: offline PNG rendering starts no session machinery. + #[test] + fn offline_png_renders_without_a_session() { + let snapshot = snapshot_of(2, 1, &["x", " "]); + let rendered = render_snapshot_png(&snapshot, &SnapshotRenderOptions::default()) + .expect("offline PNG renders"); + let (width, height, _) = decode_png(rendered.png()); + assert_eq!((width, height), (98, 125)); + assert_eq!((rendered.width(), rendered.height()), (98, 125)); + } + + /// U03-C: bytes and path wrappers decode to equal pixels. + #[test] + fn png_bytes_and_path_wrappers_decode_equal() { + let snapshot = snapshot_of(3, 1, &["a", "x", " "]); + let options = SnapshotRenderOptions::default(); + let rendered = render_snapshot_png(&snapshot, &options).expect("offline PNG renders"); + let dir = std::env::temp_dir(); + let path = dir.join(format!("u03-png-{}.png", std::process::id())); + let evidence = write_snapshot_png(&path, &snapshot, &options).expect("PNG path writes"); + let saved = std::fs::read(&path).expect("PNG file reads back"); + std::fs::remove_file(&path).ok(); + assert_eq!(decode_png(&saved), decode_png(rendered.png())); + assert_eq!(evidence, *rendered.evidence()); + } + + /// U03-D: hidden and clipped cursors stay hidden in PNG output. + #[test] + fn hidden_and_clipped_cursors_stay_hidden_in_png() { + let options = SnapshotRenderOptions::default(); + let hidden = { + let mut parts = snapshot_parts(2, 1, &[" ", " "]); + parts.cursor = SnapshotCursor::new( + 0, + 0, + true, + false, + SnapshotCursorShape::Block, + Rgb::new(200, 30, 30), + None, + ); + TerminalSnapshot::try_new(parts).expect("test snapshot is valid") + }; + let shown = { + let mut parts = snapshot_parts(2, 1, &[" ", " "]); + parts.cursor = SnapshotCursor::new( + 0, + 0, + true, + true, + SnapshotCursorShape::Block, + Rgb::new(200, 30, 30), + None, + ); + TerminalSnapshot::try_new(parts).expect("test snapshot is valid") + }; + let clipped = { + let mut parts = snapshot_parts(2, 1, &[" ", " "]); + parts.cursor = SnapshotCursor::new( + 7, + 7, + false, + true, + SnapshotCursorShape::Block, + Rgb::new(200, 30, 30), + None, + ); + TerminalSnapshot::try_new(parts).expect("test snapshot is valid") + }; + let hidden_render = render_snapshot_png(&hidden, &options).expect("hidden renders"); + let shown_render = render_snapshot_png(&shown, &options).expect("shown renders"); + let clipped_render = render_snapshot_png(&clipped, &options).expect("clipped renders"); + assert!(!hidden_render.evidence().cursor_drawn()); + assert!(shown_render.evidence().cursor_drawn()); + assert!(!clipped_render.evidence().cursor_drawn()); + assert_ne!( + decode_png(hidden_render.png()), + decode_png(shown_render.png()), + "a drawn cursor changes pixels" + ); + assert_eq!( + decode_png(hidden_render.png()), + decode_png(clipped_render.png()), + "hidden and clipped cursors both draw nothing" + ); + } + + /// U03-G: a 3-column, 2-row grid with 10-by-20 cells gives a 30-by-40 + /// image with no decoration. Blank cells need no font lookup, so the + /// geometry gate stays independent of host fonts. + #[test] + fn undecorated_grid_gives_exact_decoded_pixels() { + let snapshot = snapshot_of(3, 2, &[" "; 6]); + let options = undecorated().with_cell_width(10.0).with_cell_height(20.0); + let rendered = render_snapshot_png(&snapshot, &options).expect("offline PNG renders"); + assert_eq!((rendered.width(), rendered.height()), (30, 40)); + let (width, height, _) = decode_png(rendered.png()); + assert_eq!((width, height), (30, 40)); + } + + /// U03-I (PNG half): default options keep the recorded screenshot size. + #[test] + fn default_options_keep_recorded_png_size() { + let snapshot = snapshot_of(1, 1, &[" "]); + let rendered = render_snapshot_png(&snapshot, &SnapshotRenderOptions::default()) + .expect("offline PNG renders"); + assert_eq!((rendered.width(), rendered.height()), (88, 125)); + let (width, height, _) = decode_png(rendered.png()); + assert_eq!((width, height), (88, 125)); + } + + /// U03-A (PNG half): a live capture renders through the same renderer. + /// + /// The offline PNG of a live Viewport capture must decode to the same + /// pixels as the live screenshot PNG: same frame conversion, same + /// `GridRenderer` path at the same size. + #[cfg(unix)] + #[test] + fn live_capture_and_screenshot_share_the_png_path() { + use tui_test::{OpenOptions, Operation, RunOptions, Session, SnapshotScope}; + + let session = Session::new(format!("u03-png-{}", std::process::id())); + let defaults = OpenOptions::default(); + session + .run(RunOptions { + backend: defaults.backend, + program: "sh".to_string(), + args: vec!["-c".to_string(), "printf 'hello-U03'; sleep 30".to_string()], + profile: defaults.profile, + style: defaults.style, + cols: 16, + rows: 2, + cwd: defaults.cwd, + env: defaults.env, + wait_ready: Some(false), + restart: defaults.restart, + timeouts: defaults.timeouts, + recording: defaults.recording, + }) + .expect("session starts"); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let text = match session.execute(Operation::Text { full: false }) { + Ok(tui_test::OperationResult::Text(text)) => text, + other => panic!("text operation gave {other:?}"), + }; + if text.contains("hello-U03") { + break; + } + if Instant::now() >= deadline { + panic!("timed out waiting for marker; got {text:?}"); + } + std::thread::sleep(Duration::from_millis(20)); + } + + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("live capture works"); + let rendered = render_snapshot_png(&snapshot, &SnapshotRenderOptions::default()) + .expect("offline PNG renders"); + + let path = std::env::temp_dir().join(format!("u03-live-{}.png", std::process::id())); + session + .execute(Operation::Screenshot { + full: false, + path: Some(path.to_string_lossy().into_owned()), + zoom: None, + background: None, + }) + .expect("live screenshot works"); + session.close().expect("session closes"); + let live = std::fs::read(&path).expect("live PNG reads back"); + std::fs::remove_file(&path).ok(); + assert_eq!(decode_png(&live), decode_png(rendered.png())); + } +} From f7774c747df2bbd6d3a35797a86a77f19778ca97 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 18:35:28 +0700 Subject: [PATCH 07/17] U04: add pinned-font policy to offline raster rendering Add explicit PinnedFontSet (font bytes, face indices, fallback order, style selection) extending SnapshotRenderOptions. Under the pinned policy the rasterizer loads only the specified bytes: no system fonts and no ambient font-selection variables. Missing font, style face, or glyph returns a typed PinnedFontError; no host fallback, replacement glyph, or unselected synthetic style. Each strict PNG carries a versioned render contract (renderer and raster policy versions, font digests, geometry, scale, palette digest, cursor phase with blink intent). Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/lib.rs | 5 + crates/tui-test/src/render/mod.rs | 1 + crates/tui-test/src/render/offline.rs | 150 ++++- crates/tui-test/src/render/pinned.rs | 716 ++++++++++++++++++++++ crates/tui-test/src/render/raster.rs | 105 +++- crates/tui-test/src/render/raster/font.rs | 176 +++++- 6 files changed, 1103 insertions(+), 50 deletions(-) create mode 100644 crates/tui-test/src/render/pinned.rs diff --git a/crates/tui-test/src/lib.rs b/crates/tui-test/src/lib.rs index e790a6e8..dbb11239 100644 --- a/crates/tui-test/src/lib.rs +++ b/crates/tui-test/src/lib.rs @@ -27,6 +27,11 @@ pub use render::offline::{ render_snapshot_svg, write_snapshot_svg, RenderError, RenderEvidence, RenderedSvg, SnapshotRenderOptions, }; +pub use render::pinned::{ + ContractFont, CursorPhase, PinnedCacheKey, PinnedCachedFace, PinnedFace, PinnedFontError, + PinnedFontSet, RenderContract, RASTER_POLICY_PINNED_V1, RASTER_POLICY_SYSTEM_V1, + RENDER_CONTRACT_VERSION, +}; pub use runtime::{ global_registry, Locator, LocatorClickOptions, LocatorExpectOptions, LocatorFilterOptions, Session, SessionHandle, SessionRegistry, diff --git a/crates/tui-test/src/render/mod.rs b/crates/tui-test/src/render/mod.rs index 41b780f0..cf523fd1 100644 --- a/crates/tui-test/src/render/mod.rs +++ b/crates/tui-test/src/render/mod.rs @@ -1,5 +1,6 @@ mod nerd_font; pub mod offline; +pub mod pinned; pub mod style; pub mod svg; diff --git a/crates/tui-test/src/render/offline.rs b/crates/tui-test/src/render/offline.rs index c2aa658c..62495ee2 100644 --- a/crates/tui-test/src/render/offline.rs +++ b/crates/tui-test/src/render/offline.rs @@ -67,6 +67,9 @@ use std::sync::Arc; use compact_str::CompactString; +#[cfg(feature = "recording-raster")] +use super::pinned::RenderContract; +use super::pinned::{PinnedFontError, PinnedFontSet}; #[cfg(feature = "recording-raster")] use super::raster::FrameRenderer; use super::style::{Padding, PaddingSides, Style}; @@ -145,6 +148,10 @@ pub enum RenderError { /// What the raster backend reported. message: String, }, + /// A pinned-font render failed: empty set, invalid bytes, missing + /// style face, missing glyph, or an unshaped sequence. The renderer + /// never substitutes a host font or replacement glyph. + PinnedFont(PinnedFontError), /// A snapshot file could not be written. Io(std::io::Error), } @@ -186,6 +193,9 @@ impl std::fmt::Display for RenderError { RenderError::RasterFailed { message } => { write!(f, "raster render failed: {message}") } + RenderError::PinnedFont(error) => { + write!(f, "pinned font render failed: {error}") + } RenderError::Io(error) => { write!(f, "snapshot render write failed: {error}") } @@ -197,6 +207,7 @@ impl std::error::Error for RenderError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { RenderError::Io(error) => Some(error), + RenderError::PinnedFont(error) => Some(error), _ => None, } } @@ -214,8 +225,8 @@ impl From for crate::api::TuiTestError { /// below are the whole surface. Defaults mirror the live [`Style`] geometry /// (10-by-21 cells, title bar with controls, shadow, default padding), so a /// default offline render matches a default live screenshot of the same -/// terminal state. U04 extends this type with pinned fonts; it stays -/// raster-independent either way. +/// terminal state. The optional pinned fonts select explicit font bytes for +/// strict raster renders; the type stays raster-independent either way. #[derive(Debug, Clone)] pub struct SnapshotRenderOptions { cell_width: f32, @@ -231,6 +242,7 @@ pub struct SnapshotRenderOptions { border_width: f32, border_radius: f32, background: Option, + pinned_fonts: Option, } impl Default for SnapshotRenderOptions { @@ -259,6 +271,7 @@ impl Default for SnapshotRenderOptions { border_width: 0.0, border_radius: 8.0, background: None, + pinned_fonts: None, } } } @@ -413,6 +426,24 @@ impl SnapshotRenderOptions { self } + /// The pinned fonts for strict raster renders, or `None` for the + /// recorded system-font behavior. + /// + /// Without pinned fonts the rasterizer keeps its recorded behavior: + /// bundled and system fonts ranked with the ambient configuration. + /// With pinned fonts it loads only the specified bytes — no system + /// fonts, no ambient font-selection variables — and a missing font, + /// style face, or glyph is a [`RenderError::PinnedFont`] error. + pub fn pinned_fonts(&self) -> Option<&PinnedFontSet> { + self.pinned_fonts.as_ref() + } + + /// Set the pinned fonts for strict raster renders. + pub fn with_pinned_fonts(mut self, fonts: PinnedFontSet) -> Self { + self.pinned_fonts = Some(fonts); + self + } + /// Reject nonfinite values, bad dimensions, and a baseline outside the /// cell, before any image-size arithmetic or allocation. pub fn validate(&self) -> Result<(), RenderError> { @@ -637,12 +668,13 @@ pub fn write_snapshot_svg( Ok(rendered.evidence) } -/// One rendered PNG image with its evidence. +/// One rendered PNG image with its evidence and render contract. #[cfg(feature = "recording-raster")] #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenderedPng { png: Vec, evidence: RenderEvidence, + contract: RenderContract, } #[cfg(feature = "recording-raster")] @@ -652,6 +684,13 @@ impl RenderedPng { &self.png } + /// The versioned render contract: renderer and raster policy versions, + /// fonts, geometry, scale, palette digest, and cursor phase with the + /// blink intent kept from the snapshot state. + pub fn contract(&self) -> &RenderContract { + &self.contract + } + /// Output width in pixels. pub fn width(&self) -> u32 { self.evidence.width @@ -679,6 +718,11 @@ impl RenderedPng { /// screenshots use, at screenshot (exact, unpadded) size. The input snapshot /// is never changed. A hidden cursor, or one clipped out of the capture, is /// not drawn, and the evidence records that. +/// +/// Without pinned fonts the rasterizer keeps its recorded system-font +/// behavior. With pinned fonts it loads only the specified bytes and +/// reports them in the render contract; a missing style face or glyph is a +/// [`RenderError::PinnedFont`] error with no host fallback. #[cfg(feature = "recording-raster")] pub fn render_snapshot_png( snapshot: &TerminalSnapshot, @@ -688,36 +732,84 @@ pub fn render_snapshot_png( // Checked before the renderer allocates its pixmap. let _ = checked_output_size(&layout, snapshot.cols(), snapshot.rows())?; let (cols, rows) = snapshot_grid_size(snapshot)?; - let mut renderer = super::raster::GridRenderer::with_layout( - cols, - rows, - layout.clone(), - options.background, - true, - ) - .map_err(|error| RenderError::RasterFailed { - message: error.to_string(), - })?; let frame = snapshot_frame(snapshot); - let png = super::encode::encode_png_bytes(&frame, &mut renderer).map_err(|error| { - RenderError::RasterFailed { - message: error.to_string(), + let cursor_drawn = frame.cursor.is_some(); + let evidence = || RenderEvidence { + width: 0, + height: 0, + cursor_drawn, + transparent_background: matches!(options.background, Some(CaptureBackground::Transparent)), + title: shown_title(snapshot.title(), cols, rows, &layout), + }; + match options.pinned_fonts.as_ref() { + None => { + let mut renderer = super::raster::GridRenderer::with_layout( + cols, + rows, + layout.clone(), + options.background, + true, + ) + .map_err(|error| RenderError::RasterFailed { + message: error.to_string(), + })?; + let png = super::encode::encode_png_bytes(&frame, &mut renderer).map_err(|error| { + RenderError::RasterFailed { + message: error.to_string(), + } + })?; + let (width, height) = renderer.pixel_size(); + let mut rendered_evidence = evidence(); + rendered_evidence.width = width; + rendered_evidence.height = height; + Ok(RenderedPng { + png, + evidence: rendered_evidence, + contract: RenderContract::system(&layout, options.scale, snapshot, cursor_drawn), + }) } - })?; - let (width, height) = renderer.pixel_size(); - Ok(RenderedPng { - png, - evidence: RenderEvidence { - width, - height, - cursor_drawn: frame.cursor.is_some(), - transparent_background: matches!( + Some(set) => { + let mut renderer = super::raster::GridRenderer::with_pinned_layout( + cols, + rows, + layout.clone(), + set, options.background, - Some(CaptureBackground::Transparent) - ), - title: shown_title(snapshot.title(), cols, rows, &layout), + true, + ) + .map_err(map_pinned_error)?; + let key = super::raster::GridRenderer::pinned_cache_key(&layout, set); + let png = + super::encode::encode_png_bytes(&frame, &mut renderer).map_err(map_pinned_error)?; + let (width, height) = renderer.pixel_size(); + let mut rendered_evidence = evidence(); + rendered_evidence.width = width; + rendered_evidence.height = height; + Ok(RenderedPng { + png, + evidence: rendered_evidence, + contract: RenderContract::pinned( + &key, + &layout, + options.scale, + snapshot, + cursor_drawn, + ), + }) + } + } +} + +/// Keep a typed [`PinnedFontError`] typed: downcast the renderer failure +/// back to it, and only stringify what was never pinned. +#[cfg(feature = "recording-raster")] +fn map_pinned_error(error: anyhow::Error) -> RenderError { + match error.downcast::() { + Ok(pinned) => RenderError::PinnedFont(pinned), + Err(other) => RenderError::RasterFailed { + message: other.to_string(), }, - }) + } } /// Draw a snapshot as PNG and save it to `path`, without a live session. diff --git a/crates/tui-test/src/render/pinned.rs b/crates/tui-test/src/render/pinned.rs new file mode 100644 index 00000000..a05537a0 --- /dev/null +++ b/crates/tui-test/src/render/pinned.rs @@ -0,0 +1,716 @@ +//! Explicit pinned-font policy for strict offline rendering. +//! +//! A [`PinnedFontSet`] names the exact font bytes a render may use: each +//! face carries its bytes, its index inside those bytes, and the style slot +//! it serves. The order of the faces is the fallback order. Under the +//! pinned policy the rasterizer loads only these bytes: it never scans +//! system fonts and never reads ambient font-selection variables such as +//! `TUI_TEST_RECORDING_FONT_FAMILIES`. +//! +//! The renderer hashes the bytes it loaded and reports them, with face +//! indices, styles, and fallback order, in the versioned [`RenderContract`] +//! attached to each strict image. A requested style slot with no face, or a +//! grapheme no specified face contains, is a typed [`PinnedFontError`]: +//! the renderer never substitutes a host font, a replacement glyph, or an +//! unselected synthetic style. Full multi-scalar shaping arrives in a later +//! task; here the renderer stops with an error for sequences it cannot +//! shape. +//! +//! ``` +//! use tui_test::{PinnedFace, PinnedFontSet}; +//! +//! # fn main() -> Result<(), Box> { +//! let regular = PinnedFace::new( +//! include_bytes!("../../assets/jetbrains-mono/JetBrainsMono-Regular.ttf").to_vec(), +//! 0, +//! false, +//! false, +//! )?; +//! let set = PinnedFontSet::new(vec![regular])?; +//! assert_eq!(set.faces().len(), 1); +//! assert!(!set.allow_synthetic_style()); +//! # Ok(()) +//! # } +//! ``` + +use sha2::{Digest, Sha256}; + +/// Schema version of [`RenderContract`]. +pub const RENDER_CONTRACT_VERSION: u32 = 1; + +/// Raster policy name recorded when no pinned fonts are selected. +pub const RASTER_POLICY_SYSTEM_V1: &str = "system-v1"; + +/// Raster policy name recorded for pinned-font renders. +pub const RASTER_POLICY_PINNED_V1: &str = "pinned-v1"; + +/// Largest accepted pinned face: the total custom-font budget the live +/// rasterizer already enforces, applied per face at the public boundary. +const MAX_PINNED_FACE_BYTES: usize = 64 * 1024 * 1024; + +/// One explicitly selected font face: bytes plus the style slot it serves. +/// +/// The `index` selects the face inside the bytes (zero for a plain TTF, +/// the collection index for a TTC). `bold` and `italic` name the requested +/// style slot the face serves; the bytes are authoritative for the outlines +/// and are never restyled unless the set explicitly allows synthetic +/// styles. +#[derive(Debug, Clone)] +pub struct PinnedFace { + bytes: Vec, + index: u32, + bold: bool, + italic: bool, +} + +impl PinnedFace { + /// Select one face. Rejects empty or oversized bytes and bytes whose + /// selected index does not parse, before any render reads them. + pub fn new( + bytes: Vec, + index: u32, + bold: bool, + italic: bool, + ) -> Result { + if bytes.len() > MAX_PINNED_FACE_BYTES { + return Err(PinnedFontError::FaceTooLarge { + bytes: bytes.len(), + limit: MAX_PINNED_FACE_BYTES as u64, + }); + } + if bytes.is_empty() { + return Err(PinnedFontError::InvalidFace { + detail: "face bytes are empty".to_string(), + }); + } + if let Err(error) = ttf_parser::Face::parse(&bytes, index) { + return Err(PinnedFontError::InvalidFace { + detail: format!("face index {index} does not parse: {error}"), + }); + } + Ok(Self { + bytes, + index, + bold, + italic, + }) + } + + /// The selected font bytes. + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// The face index inside the bytes. + pub fn index(&self) -> u32 { + self.index + } + + /// Whether this face serves the bold style slot. + pub fn bold(&self) -> bool { + self.bold + } + + /// Whether this face serves the italic style slot. + pub fn italic(&self) -> bool { + self.italic + } + + /// SHA-256 over the style slot, face index, and bytes of this face. + pub fn digest(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"pinned-face-v1"); + hasher.update([u8::from(self.bold), u8::from(self.italic)]); + hasher.update(self.index.to_le_bytes()); + hasher.update(self.bytes.len().to_le_bytes()); + hasher.update(&self.bytes); + format!("{:x}", hasher.finalize()) + } +} + +/// Explicit pinned fonts for one offline render. +/// +/// The faces are tried in order for a grapheme whose style slot has more +/// than one face; the order is the fallback order. Synthetic bold or +/// italic is off unless [`PinnedFontSet::with_synthetic_style`] selects it. +#[derive(Debug, Clone)] +pub struct PinnedFontSet { + faces: Vec, + allow_synthetic_style: bool, +} + +impl PinnedFontSet { + /// Select the pinned faces. An empty set is an error: pinned rendering + /// never falls back to host fonts. + pub fn new(faces: Vec) -> Result { + if faces.is_empty() { + return Err(PinnedFontError::EmptySet); + } + Ok(Self { + faces, + allow_synthetic_style: false, + }) + } + + /// Allow emboldened or slanted rendering when the bytes lack the + /// requested style. Off by default: without this selection a requested + /// style slot with no face is a missing-face error. + pub fn with_synthetic_style(mut self, allow: bool) -> Self { + self.allow_synthetic_style = allow; + self + } + + /// The pinned faces in fallback order. + pub fn faces(&self) -> &[PinnedFace] { + &self.faces + } + + /// Whether synthetic bold or italic is selected. + pub fn allow_synthetic_style(&self) -> bool { + self.allow_synthetic_style + } + + /// SHA-256 over the policy tag, the synthetic-style selection, and each + /// face digest in fallback order. System fonts never contribute. + pub fn digest(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"pinned-set-v1"); + hasher.update([u8::from(self.allow_synthetic_style)]); + for face in &self.faces { + hasher.update(face.digest().as_bytes()); + } + format!("{:x}", hasher.finalize()) + } +} + +/// A pinned-font failure. Every variant names what is missing and states +/// that no host font, replacement glyph, or unselected synthetic style is +/// substituted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PinnedFontError { + /// The set names no faces, so no pinned render can start. + EmptySet, + /// One face exceeds the per-face byte budget. + FaceTooLarge { + /// The rejected byte count. + bytes: usize, + /// The accepted limit. + limit: u64, + }, + /// The selected bytes do not parse as a font face. + InvalidFace { + /// What the parser rejected. + detail: String, + }, + /// No specified face serves the requested style slot. + MissingFace { + /// The requested bold selection. + bold: bool, + /// The requested italic selection. + italic: bool, + }, + /// No specified face in the requested style slot contains the glyph. + MissingGlyph { + /// The glyph no face contains. + character: char, + /// The requested bold selection. + bold: bool, + /// The requested italic selection. + italic: bool, + }, + /// A multi-scalar sequence needs the shaping task; pinned rendering + /// stops instead of splitting it across faces. + UnsupportedSequence { + /// The sequence the renderer cannot shape yet. + sequence: String, + }, +} + +impl std::fmt::Display for PinnedFontError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PinnedFontError::EmptySet => write!( + f, + "pinned font set is empty: specify at least one font face; \ + pinned rendering never falls back to host fonts" + ), + PinnedFontError::FaceTooLarge { bytes, limit } => write!( + f, + "pinned font face is {bytes} bytes, above the {limit} byte limit" + ), + PinnedFontError::InvalidFace { detail } => { + write!(f, "pinned font face is not a valid font: {detail}") + } + PinnedFontError::MissingFace { bold, italic } => write!( + f, + "pinned fonts have no {} face; add one or select synthetic styles", + style_name(*bold, *italic) + ), + PinnedFontError::MissingGlyph { + character, + bold, + italic, + } => write!( + f, + "pinned {} faces contain no glyph for {character:?} (U+{:04X}); \ + no host font or replacement glyph is substituted", + style_name(*bold, *italic), + *character as u32, + ), + PinnedFontError::UnsupportedSequence { sequence } => write!( + f, + "pinned rendering cannot shape the multi-scalar sequence \ + {sequence:?} yet; no face is split across it" + ), + } + } +} + +impl std::error::Error for PinnedFontError {} + +fn style_name(bold: bool, italic: bool) -> &'static str { + match (bold, italic) { + (false, false) => "regular", + (true, false) => "bold", + (false, true) => "italic", + (true, true) => "bold-italic", + } +} + +/// The identity a pinned-font cache entry is stored under. +/// +/// The policy tag, every face digest with its face index and style slot in +/// fallback order, the synthetic-style selection, the output scale, and the +/// raster geometry (font size, cell metrics, baseline) all join the key, so +/// a system-font entry — keyed by family and path — can never serve a +/// pinned request and one raster configuration can never serve another. +/// Each pinned render builds its fonts from the specified bytes only; the +/// key is recorded in the render contract as the evidence of what was used. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PinnedCacheKey { + policy: &'static str, + faces: Vec, + synthetic: bool, + scale_bits: u64, + font_size_bits: u32, + cell_width_bits: u32, + cell_height_bits: u32, + baseline_bits: u32, +} + +/// One face inside a [`PinnedCacheKey`], in fallback order. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PinnedCachedFace { + digest: String, + index: u32, + bold: bool, + italic: bool, +} + +impl PinnedCachedFace { + /// The face digest. + pub fn digest(&self) -> &str { + &self.digest + } + + /// The face index inside its bytes. + pub fn index(&self) -> u32 { + self.index + } + + /// Whether the face serves the bold style slot. + pub fn bold(&self) -> bool { + self.bold + } + + /// Whether the face serves the italic style slot. + pub fn italic(&self) -> bool { + self.italic + } +} + +impl PinnedCacheKey { + /// Build the cache identity for one render from the pinned set and the + /// raster settings the render actually uses. + pub fn new( + set: &PinnedFontSet, + scale: f64, + font_size: f32, + cell_width: f32, + cell_height: f32, + baseline: f32, + ) -> Self { + Self { + policy: RASTER_POLICY_PINNED_V1, + faces: set + .faces() + .iter() + .map(|face| PinnedCachedFace { + digest: face.digest(), + index: face.index(), + bold: face.bold(), + italic: face.italic(), + }) + .collect(), + synthetic: set.allow_synthetic_style(), + scale_bits: scale.to_bits(), + font_size_bits: font_size.to_bits(), + cell_width_bits: cell_width.to_bits(), + cell_height_bits: cell_height.to_bits(), + baseline_bits: baseline.to_bits(), + } + } + + /// The raster policy this key pins. + pub fn policy(&self) -> &'static str { + self.policy + } + + /// The face identities in fallback order. + pub fn faces(&self) -> &[PinnedCachedFace] { + &self.faces + } + + /// Whether synthetic styles join the key. + pub fn synthetic(&self) -> bool { + self.synthetic + } + + /// The output scale the key pins. + pub fn scale(&self) -> f64 { + f64::from_bits(self.scale_bits) + } + + /// The grid font size the key pins. + pub fn font_size(&self) -> f32 { + f32::from_bits(self.font_size_bits) + } + + /// The cell width the key pins. + pub fn cell_width(&self) -> f32 { + f32::from_bits(self.cell_width_bits) + } + + /// The cell height the key pins. + pub fn cell_height(&self) -> f32 { + f32::from_bits(self.cell_height_bits) + } + + /// The text baseline the key pins. + pub fn baseline(&self) -> f32 { + f32::from_bits(self.baseline_bits) + } + + /// SHA-256 over every key component, for contracts and comparisons. + pub fn digest(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"pinned-cache-key-v1"); + hasher.update(self.policy.as_bytes()); + hasher.update([u8::from(self.synthetic)]); + for face in &self.faces { + hasher.update(face.digest.as_bytes()); + hasher.update(face.index.to_le_bytes()); + hasher.update([u8::from(face.bold), u8::from(face.italic)]); + } + hasher.update(self.scale_bits.to_le_bytes()); + hasher.update(self.font_size_bits.to_le_bytes()); + hasher.update(self.cell_width_bits.to_le_bytes()); + hasher.update(self.cell_height_bits.to_le_bytes()); + hasher.update(self.baseline_bits.to_le_bytes()); + format!("{:x}", hasher.finalize()) + } +} + +/// Which cursor phase a strict image shows. +/// +/// Strict (pixel-compared) images always use the shown phase: a visible, +/// in-capture cursor is drawn. Blink intent is not a phase; it stays in the +/// snapshot state and is recorded on the contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CursorPhase { + /// The cursor was drawn. + Shown, + /// The cursor was hidden or clipped out of the capture. + Hidden, +} + +/// One font entry in a [`RenderContract`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractFont { + digest: String, + face_index: u32, + bold: bool, + italic: bool, + fallback_position: usize, +} + +impl ContractFont { + /// The SHA-256 of the face bytes, slot, and index. + pub fn digest(&self) -> &str { + &self.digest + } + + /// The face index inside its bytes. + pub fn face_index(&self) -> u32 { + self.face_index + } + + /// Whether the face serves the bold style slot. + pub fn bold(&self) -> bool { + self.bold + } + + /// Whether the face serves the italic style slot. + pub fn italic(&self) -> bool { + self.italic + } + + /// The position of the face in the fallback order. + pub fn fallback_position(&self) -> usize { + self.fallback_position + } +} + +/// Versioned evidence of what one strict render used. +/// +/// The contract names the renderer version, the raster policy version, the +/// pinned fonts (digests, face indices, styles, fallback order), the +/// geometry, the output scale, the palette digest, and the cursor phase +/// with the blink intent kept from the snapshot state. Strict image gates +/// compare decoded pixels; the contract tells reviewers and future runs +/// which font and raster configuration produced them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderContract { + contract_version: u32, + renderer_version: String, + raster_policy: String, + fonts: Vec, + cell_width_bits: u32, + cell_height_bits: u32, + font_size_bits: u32, + baseline_bits: u32, + scale_bits: u64, + title_bar: bool, + palette_digest: String, + cursor_phase: CursorPhase, + blink: Option, +} + +impl RenderContract { + /// Build the contract for a pinned render from the cache key that + /// identifies the render and the snapshot it drew. + #[cfg(feature = "recording-raster")] + pub(crate) fn pinned( + key: &PinnedCacheKey, + layout: &crate::render::svg::ResolvedLayout, + scale: f64, + snapshot: &crate::snapshot::TerminalSnapshot, + cursor_drawn: bool, + ) -> Self { + Self::for_render( + RASTER_POLICY_PINNED_V1, + key.faces() + .iter() + .enumerate() + .map(|(position, face)| ContractFont { + digest: face.digest().to_string(), + face_index: face.index(), + bold: face.bold(), + italic: face.italic(), + fallback_position: position, + }) + .collect(), + layout, + scale, + snapshot, + cursor_drawn, + ) + } + + /// Build the contract for a system-font render. No font bytes are + /// hashed: system faces come from host configuration, so the policy + /// name — not a digest — marks them as non-strict inputs. + #[cfg(feature = "recording-raster")] + pub(crate) fn system( + layout: &crate::render::svg::ResolvedLayout, + scale: f64, + snapshot: &crate::snapshot::TerminalSnapshot, + cursor_drawn: bool, + ) -> Self { + Self::for_render( + RASTER_POLICY_SYSTEM_V1, + Vec::new(), + layout, + scale, + snapshot, + cursor_drawn, + ) + } + + #[cfg(feature = "recording-raster")] + #[allow(clippy::too_many_arguments)] + fn for_render( + raster_policy: &str, + fonts: Vec, + layout: &crate::render::svg::ResolvedLayout, + scale: f64, + snapshot: &crate::snapshot::TerminalSnapshot, + cursor_drawn: bool, + ) -> Self { + let mut hasher = Sha256::new(); + hasher.update(b"render-palette-v1"); + for entry in snapshot.palette().iter() { + hasher.update([entry.r, entry.g, entry.b]); + } + for entry in [ + snapshot.default_foreground(), + snapshot.default_background(), + snapshot.default_cursor(), + ] { + hasher.update([entry.r, entry.g, entry.b]); + } + Self { + contract_version: RENDER_CONTRACT_VERSION, + renderer_version: env!("CARGO_PKG_VERSION").to_string(), + raster_policy: raster_policy.to_string(), + fonts, + cell_width_bits: layout.cell_w.to_bits(), + cell_height_bits: layout.cell_h.to_bits(), + font_size_bits: layout.font_size.to_bits(), + baseline_bits: layout.baseline.to_bits(), + scale_bits: scale.to_bits(), + title_bar: layout.title_bar, + palette_digest: format!("{:x}", hasher.finalize()), + cursor_phase: if cursor_drawn { + CursorPhase::Shown + } else { + CursorPhase::Hidden + }, + blink: snapshot.cursor().blink(), + } + } + + /// The contract schema version. + pub fn contract_version(&self) -> u32 { + self.contract_version + } + + /// The renderer crate version that produced the image. + pub fn renderer_version(&self) -> &str { + &self.renderer_version + } + + /// The raster policy version: `pinned-v1` or `system-v1`. + pub fn raster_policy(&self) -> &str { + &self.raster_policy + } + + /// The pinned fonts in fallback order; empty for system renders. + pub fn fonts(&self) -> &[ContractFont] { + &self.fonts + } + + /// The cell width in pixels. + pub fn cell_width(&self) -> f32 { + f32::from_bits(self.cell_width_bits) + } + + /// The cell height in pixels. + pub fn cell_height(&self) -> f32 { + f32::from_bits(self.cell_height_bits) + } + + /// The grid font size in pixels. + pub fn font_size(&self) -> f32 { + f32::from_bits(self.font_size_bits) + } + + /// The text baseline in pixels from the top of the cell. + pub fn baseline(&self) -> f32 { + f32::from_bits(self.baseline_bits) + } + + /// The output scale factor. + pub fn scale(&self) -> f64 { + f64::from_bits(self.scale_bits) + } + + /// Whether the title bar was drawn. + pub fn title_bar(&self) -> bool { + self.title_bar + } + + /// SHA-256 over the snapshot palette and default colors. + pub fn palette_digest(&self) -> &str { + &self.palette_digest + } + + /// The cursor phase the strict image shows. + pub fn cursor_phase(&self) -> CursorPhase { + self.cursor_phase + } + + /// Blink intent kept from the snapshot state, never normalized. + pub fn blink(&self) -> Option { + self.blink + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn regular_bytes() -> Vec { + include_bytes!("../../assets/jetbrains-mono/JetBrainsMono-Regular.ttf").to_vec() + } + + #[test] + fn an_empty_set_is_rejected_before_any_render() { + let error = PinnedFontSet::new(Vec::new()).expect_err("empty sets must fail"); + assert_eq!(error, PinnedFontError::EmptySet); + assert!(error.to_string().contains("never falls back to host fonts")); + } + + #[test] + fn oversized_and_empty_faces_are_rejected() { + let oversized = PinnedFace::new(vec![0u8; MAX_PINNED_FACE_BYTES + 1], 0, false, false) + .expect_err("oversized faces must fail"); + assert!(matches!(oversized, PinnedFontError::FaceTooLarge { .. })); + let empty = + PinnedFace::new(Vec::new(), 0, false, false).expect_err("empty faces must fail"); + assert!(matches!(empty, PinnedFontError::InvalidFace { .. })); + let garbage = PinnedFace::new(b"not a font".to_vec(), 0, false, false) + .expect_err("garbage must fail"); + assert!(matches!(garbage, PinnedFontError::InvalidFace { .. })); + } + + #[test] + fn digests_cover_bytes_order_and_style() { + let first = PinnedFace::new(regular_bytes(), 0, false, false).unwrap(); + let second = PinnedFace::new(regular_bytes(), 0, true, false).unwrap(); + assert_ne!( + first.digest(), + second.digest(), + "the style slot joins the digest" + ); + let regular = PinnedFontSet::new(vec![first.clone()]).unwrap(); + let reordered = PinnedFontSet::new(vec![first]).unwrap(); + assert_eq!(regular.digest(), reordered.digest()); + let bolded = PinnedFontSet::new(vec![second]).unwrap(); + assert_ne!(regular.digest(), bolded.digest()); + } + + #[test] + fn cache_keys_pin_policy_faces_and_raster_settings() { + let face = PinnedFace::new(regular_bytes(), 0, false, false).unwrap(); + let set = PinnedFontSet::new(vec![face]).unwrap(); + let key = PinnedCacheKey::new(&set, 1.0, 17.0, 10.0, 21.0, 14.0); + assert_eq!(key.policy(), RASTER_POLICY_PINNED_V1); + assert_eq!(key.faces().len(), 1); + assert_eq!(key.scale(), 1.0); + assert_eq!(key.cell_width(), 10.0); + let scaled = PinnedCacheKey::new(&set, 2.0, 17.0, 10.0, 21.0, 14.0); + assert_ne!(key, scaled, "scale joins the cache key"); + assert_ne!(key.digest(), scaled.digest()); + } +} diff --git a/crates/tui-test/src/render/raster.rs b/crates/tui-test/src/render/raster.rs index c6ce05d1..b44cfb3d 100644 --- a/crates/tui-test/src/render/raster.rs +++ b/crates/tui-test/src/render/raster.rs @@ -19,7 +19,9 @@ use draw::{ fill_rounded_rect_alpha, fill_top_rounded_rect, format_glyph_sequence, is_default_ignorable, stroke_rounded_rect, unpremultiply, unsupported_grapheme, }; -use font::{FontSystem, GlyphKey}; +use font::{FontBackend, GlyphKey}; + +use super::pinned::{PinnedCacheKey, PinnedFontError, PinnedFontSet}; use crate::render::style::Style; @@ -60,7 +62,7 @@ pub struct GridRenderer { width: u32, height: u32, pixmap: Pixmap, - fonts: FontSystem, + fonts: FontBackend, layout: ResolvedLayout, /// Overrides the layout's canvas background for one capture. background: Option, @@ -143,12 +145,58 @@ impl GridRenderer { Self::with_layout_and_size(cols, rows, layout, background, exact_size) } + /// Build from an already-resolved layout with only pinned bytes. + /// + /// The cache key is derived from the set and the layout the render + /// actually uses, so the key pins the scale and raster settings by + /// construction. A typed [`PinnedFontError`] passes through unchanged + /// for the offline layer to report. + pub(crate) fn with_pinned_layout( + cols: u16, + rows: usize, + layout: ResolvedLayout, + set: &PinnedFontSet, + background: Option, + exact_size: bool, + ) -> anyhow::Result { + if !layout.zoom.is_finite() || layout.zoom <= 0.0 || layout.zoom > f64::from(f32::MAX) { + anyhow::bail!("recording zoom must be finite and greater than zero"); + } + let fonts = FontBackend::pinned(set)?; + Self::with_layout_backend(cols, rows, layout, background, exact_size, fonts) + } + + /// The pinned cache identity for a layout and set, without allocating + /// the renderer. The offline layer records it in the render contract. + pub(crate) fn pinned_cache_key(layout: &ResolvedLayout, set: &PinnedFontSet) -> PinnedCacheKey { + PinnedCacheKey::new( + set, + layout.zoom, + layout.font_size, + layout.cell_w, + layout.cell_h, + layout.baseline, + ) + } + fn with_layout_and_size( cols: u16, rows: usize, layout: ResolvedLayout, background: Option, exact_size: bool, + ) -> anyhow::Result { + let fonts = FontBackend::system(&layout.font)?; + Self::with_layout_backend(cols, rows, layout, background, exact_size, fonts) + } + + fn with_layout_backend( + cols: u16, + rows: usize, + layout: ResolvedLayout, + background: Option, + exact_size: bool, + fonts: FontBackend, ) -> anyhow::Result { let zoom = layout.zoom; let (base_width, base_height) = if exact_size { @@ -187,7 +235,7 @@ impl GridRenderer { pixmap: Pixmap::new(width, height).ok_or_else(|| { anyhow::anyhow!("terminal recording dimensions must fit a pixmap") })?, - fonts: FontSystem::new(&layout.font)?, + fonts, layout, background, }) @@ -309,7 +357,7 @@ impl FrameRenderer for GridRenderer { scale, &mut missing, layout, - ); + )?; } let blank = EmuCell::blank(); @@ -368,6 +416,9 @@ impl FrameRenderer for GridRenderer { * scale; if unsupported_grapheme(cell.ch.as_str()) { + if fonts.is_pinned() { + return Err(reject_sequence(cell.ch.as_str()).into()); + } missing.insert(format_glyph_sequence(cell.ch.as_str())); continue; } @@ -380,7 +431,7 @@ impl FrameRenderer for GridRenderer { bold: paint.bold, italic: paint.italic, }; - match fonts.resolve(key) { + match fonts.resolve_strict(key)? { Some(glyph) => draw_glyph( pixmap, glyph, @@ -434,7 +485,7 @@ impl FrameRenderer for GridRenderer { scale, &mut missing, layout, - ); + )?; } // Last, so the content it frames cannot paint over it. @@ -484,10 +535,19 @@ fn grid_y(origin_y: f32, row: usize, scale: f32, layout: &ResolvedLayout) -> u32 as u32 } +#[allow(clippy::too_many_arguments)] +/// A multi-scalar sequence the pinned renderer stops on: shaping arrives +/// in a later task, so no face is split across the sequence. +fn reject_sequence(sequence: &str) -> PinnedFontError { + PinnedFontError::UnsupportedSequence { + sequence: sequence.to_string(), + } +} + #[allow(clippy::too_many_arguments)] fn draw_cursor( pixmap: &mut Pixmap, - fonts: &mut FontSystem, + fonts: &mut FontBackend, grid: &[Vec], (cx, cy): (u16, usize), colors: &dyn RenderColors, @@ -496,12 +556,12 @@ fn draw_cursor( scale: f32, missing: &mut BTreeSet, layout: &ResolvedLayout, -) { +) -> anyhow::Result<()> { let Some(row) = grid.get(cy) else { - return; + return Ok(()); }; let Some(cell) = row.get(usize::from(cx)) else { - return; + return Ok(()); }; let span = if row .get(usize::from(cx) + 1) @@ -536,7 +596,7 @@ fn draw_cursor( bottom, color, ); - return; + return Ok(()); } CursorShape::Bar => { fill_pixel_rect( @@ -547,20 +607,23 @@ fn draw_cursor( bottom, color, ); - return; + return Ok(()); } } if cell.ch.as_str() == CONTINUATION || cell.ch.chars().all(char::is_whitespace) { - return; + return Ok(()); } let paint = svg::cell_paint(cell, colors); if paint.invisible { - return; + return Ok(()); } if unsupported_grapheme(cell.ch.as_str()) { + if fonts.is_pinned() { + return Err(reject_sequence(cell.ch.as_str()).into()); + } missing.insert(format_glyph_sequence(cell.ch.as_str())); - return; + return Ok(()); } let baseline = panel_origin_y + (layout.header_h + layout.content_top + cy as f32 * layout.cell_h + layout.baseline) @@ -574,7 +637,7 @@ fn draw_cursor( bold: paint.bold, italic: paint.italic, }; - match fonts.resolve(key) { + match fonts.resolve_strict(key)? { Some(glyph) => draw_glyph( pixmap, glyph, @@ -592,6 +655,7 @@ fn draw_cursor( } } } + Ok(()) } fn draw_shadow( @@ -621,7 +685,7 @@ fn draw_shadow( #[allow(clippy::too_many_arguments)] fn draw_title( pixmap: &mut Pixmap, - fonts: &mut FontSystem, + fonts: &mut FontBackend, title: Option<&str>, cols: u16, rows: usize, @@ -631,9 +695,9 @@ fn draw_title( scale: f32, missing: &mut BTreeSet, layout: &ResolvedLayout, -) { +) -> anyhow::Result<()> { let Some(title) = svg::visible_title(title, cols, rows, panel_width, layout) else { - return; + return Ok(()); }; let advance = svg::title_advance(layout); let title_width = crate::terminal::cell::display_width(&title) as f32 * advance * scale; @@ -649,7 +713,7 @@ fn draw_title( bold: true, italic: false, }; - match fonts.resolve(key) { + match fonts.resolve_strict(key)? { Some(glyph) => draw_glyph( pixmap, glyph, @@ -669,6 +733,7 @@ fn draw_title( } x += width; } + Ok(()) } fn scaled_dimension(base: u32, zoom: f64, name: &str) -> anyhow::Result { diff --git a/crates/tui-test/src/render/raster/font.rs b/crates/tui-test/src/render/raster/font.rs index 3108709a..f3c0c928 100644 --- a/crates/tui-test/src/render/raster/font.rs +++ b/crates/tui-test/src/render/raster/font.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use tiny_skia::{Path, PathBuilder}; use ttf_parser::{Face, OutlineBuilder}; -use super::super::{font as catalog, nerd_font}; +use super::super::{font as catalog, nerd_font, pinned}; #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub(super) struct GlyphKey { @@ -12,6 +12,7 @@ pub(super) struct GlyphKey { pub italic: bool, } +#[derive(Clone)] pub(super) struct GlyphOutline { pub path: Path, pub bounds: ttf_parser::Rect, @@ -80,6 +81,179 @@ impl FontSystem { } } +/// Which font store backs one render: the recorded system-font catalog or +/// the explicitly pinned bytes. The pinned side never touches the catalog +/// cache, the system font database, or ambient font-selection variables. +pub(super) enum FontBackend { + System(FontSystem), + Pinned(PinnedFontSystem), +} + +impl FontBackend { + pub fn system(font: &crate::render::style::FontFamilies) -> anyhow::Result { + Ok(Self::System(FontSystem::new(font)?)) + } + + pub fn pinned(set: &pinned::PinnedFontSet) -> Result { + Ok(Self::Pinned(PinnedFontSystem::new(set)?)) + } + + pub fn is_pinned(&self) -> bool { + matches!(self, Self::Pinned(_)) + } + + /// Resolve one glyph. The system side keeps its recorded fallback: a + /// missing glyph is `None` for the caller to collect. The pinned side + /// returns a typed error instead: a missing style slot or a missing + /// glyph never falls back to a host font. + pub fn resolve_strict( + &mut self, + key: GlyphKey, + ) -> Result, pinned::PinnedFontError> { + match self { + Self::System(system) => Ok(system.resolve(key)), + Self::Pinned(pinned) => pinned.resolve(key).map(Some), + } + } +} + +/// Glyph outlines drawn only from explicitly pinned bytes. +/// +/// The faces hold caller-supplied bytes with their collection indices, in +/// fallback order. No `fontdb` database is built here on purpose: the +/// catalog's job — scanning system fonts, ranking families, reading +/// `TUI_TEST_RECORDING_FONT_FAMILIES` — is exactly what the pinned policy +/// must not do. Each render owns its instance, so no system-font cache +/// entry can serve a pinned request by construction. The recorded +/// [`pinned::PinnedCacheKey`] is the identity a shared cache would use. +pub(super) struct PinnedFontSystem { + faces: Vec, + synthetic: bool, + glyphs: HashMap, +} + +struct PinnedCatalogFace { + bytes: Vec, + index: u32, + bold: bool, + italic: bool, +} + +impl PinnedFontSystem { + pub fn new(set: &pinned::PinnedFontSet) -> Result { + if set.faces().is_empty() { + return Err(pinned::PinnedFontError::EmptySet); + } + let mut faces = Vec::with_capacity(set.faces().len()); + for face in set.faces() { + // The public constructor already parsed this index; re-check + // here so a render never trusts stale validation. + if ttf_parser::Face::parse(face.bytes(), face.index()).is_err() { + return Err(pinned::PinnedFontError::InvalidFace { + detail: format!("pinned face index {} does not parse", face.index()), + }); + } + faces.push(PinnedCatalogFace { + bytes: face.bytes().to_vec(), + index: face.index(), + bold: face.bold(), + italic: face.italic(), + }); + } + Ok(Self { + faces, + synthetic: set.allow_synthetic_style(), + glyphs: HashMap::new(), + }) + } + + /// Resolve one glyph from the specified faces in fallback order. + /// + /// Only faces serving the requested style slot are candidates: an + /// empty slot is a missing-face error, and a glyph no candidate holds + /// is a missing-glyph error. Synthetic bold or italic applies only + /// when the set selected it. + pub fn resolve(&mut self, key: GlyphKey) -> Result<&GlyphOutline, pinned::PinnedFontError> { + if !self.glyphs.contains_key(&key) { + let positions: Vec = self + .faces + .iter() + .enumerate() + .filter(|(_, face)| face.bold == key.bold && face.italic == key.italic) + .map(|(position, _)| position) + .collect(); + if positions.is_empty() { + return Err(pinned::PinnedFontError::MissingFace { + bold: key.bold, + italic: key.italic, + }); + } + let mut found = None; + for position in positions { + if let Some(outline) = self.load(position, key) { + found = Some(outline); + break; + } + } + match found { + Some(outline) => { + self.glyphs.insert(key, outline); + } + None => { + return Err(pinned::PinnedFontError::MissingGlyph { + character: key.character, + bold: key.bold, + italic: key.italic, + }); + } + } + } + // The outline was just cached above when it was absent; the error + // arm only guards the map lookup without panicking. + match self.glyphs.get(&key) { + Some(outline) => Ok(outline), + None => Err(pinned::PinnedFontError::MissingGlyph { + character: key.character, + bold: key.bold, + italic: key.italic, + }), + } + } + + fn load(&self, position: usize, key: GlyphKey) -> Option { + let face = self.faces.get(position)?; + let parsed = ttf_parser::Face::parse(&face.bytes, face.index).ok()?; + let glyph_id = parsed.glyph_index(key.character)?; + let mut builder = TinyPathBuilder::default(); + let bounds = parsed.outline_glyph(glyph_id, &mut builder)?; + let path = builder.finish()?; + // Style slots are caller-selected, so only an explicit synthetic + // selection emboldens or slants; otherwise the bytes draw verbatim. + let synthetic_bold = + self.synthetic && key.bold && !is_bold_weight(parsed.weight().to_number()); + let synthetic_italic = self.synthetic && key.italic && !is_italic_style(&parsed); + Some(GlyphOutline { + path, + bounds, + advance: parsed + .glyph_hor_advance(glyph_id) + .unwrap_or(parsed.units_per_em()), + units_per_em: parsed.units_per_em(), + synthetic_bold, + synthetic_italic, + powerline: nerd_font::is_powerline_separator(key.character), + }) + } +} + +fn is_bold_weight(weight: u16) -> bool { + weight >= fontdb::Weight::SEMIBOLD.0 +} + +fn is_italic_style(face: &ttf_parser::Face<'_>) -> bool { + face.is_italic() || face.is_oblique() +} + fn is_bold_face(face: &fontdb::FaceInfo) -> bool { face.weight.0 >= fontdb::Weight::SEMIBOLD.0 || face.post_script_name.to_ascii_lowercase().contains("bold") From 18dadb772881f5e6230ec3b419017528e9ffe727 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 18:35:32 +0700 Subject: [PATCH 08/17] U04: cover pinned-font policy with U04-A..U04-F tests U04-A re-renders C06 content under a rogue fontconfig path and family selection; U04-B checks digest sensitivity; U04-C checks typed missing-font, missing-face, and missing-glyph failures; U04-D checks system cache operations cannot move strict output; U04-E checks equal pinned inputs decode to equal pixels; U04-F checks the recorded system-font behavior is unchanged. Signed-off-by: Alexey Zhokhov --- crates/tui-test/tests/pinned_fonts.rs | 436 ++++++++++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 crates/tui-test/tests/pinned_fonts.rs diff --git a/crates/tui-test/tests/pinned_fonts.rs b/crates/tui-test/tests/pinned_fonts.rs new file mode 100644 index 00000000..6bc6a57e --- /dev/null +++ b/crates/tui-test/tests/pinned_fonts.rs @@ -0,0 +1,436 @@ +//! U04: pinned-font policy for strict offline rendering. +//! +//! The pinned policy loads only explicitly specified font bytes: no system +//! fonts and no ambient font-selection variables. These tests cover the +//! required U04 behaviors: host-font independence (U04-A, corpus C06), +//! digest sensitivity (U04-B), typed failures (U04-C), cache isolation +//! (U04-D), pixel determinism (U04-E), and unchanged system-font behavior +//! (U04-F). The font bytes come from the bundled JetBrains Mono assets read +//! as files, so system font configuration cannot leak into the inputs. + +#![cfg(feature = "recording-raster")] + +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use tui_test::profile::{Profile, Rgb}; +use tui_test::render::style::Padding; +use tui_test::terminal::cell::Attrs; +use tui_test::{ + render_snapshot_png, CursorPhase, PinnedFace, PinnedFontError, PinnedFontSet, RenderError, + SnapshotCell, SnapshotColor, SnapshotCursor, SnapshotCursorShape, SnapshotMetadata, + SnapshotParts, SnapshotRenderOptions, SnapshotScope, SnapshotUnderline, TerminalSnapshot, + RASTER_POLICY_PINNED_V1, RASTER_POLICY_SYSTEM_V1, RENDER_CONTRACT_VERSION, +}; + +fn regular_bytes() -> Vec { + include_bytes!("../assets/jetbrains-mono/JetBrainsMono-Regular.ttf").to_vec() +} + +fn bold_bytes() -> Vec { + include_bytes!("../assets/jetbrains-mono/JetBrainsMono-Bold.ttf").to_vec() +} + +fn regular_face() -> PinnedFace { + PinnedFace::new(regular_bytes(), 0, false, false).expect("bundled regular face parses") +} + +fn bold_face() -> PinnedFace { + PinnedFace::new(bold_bytes(), 0, true, false).expect("bundled bold face parses") +} + +/// Regular plus bold: the pair every titled snapshot below needs, because +/// the title bar always draws in the bold slot. +fn titled_pair() -> PinnedFontSet { + PinnedFontSet::new(vec![regular_face(), bold_face()]).expect("pinned pair builds") +} + +fn resolved_palette() -> [Rgb; 256] { + let profile = Profile::default(); + std::array::from_fn(|index| profile.colors.rgb(index as u8)) +} + +fn plain_cell(text: &str, width: u16) -> SnapshotCell { + SnapshotCell::new( + text.to_string(), + width, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("test cell is valid") +} + +fn bold_cell(text: &str) -> SnapshotCell { + SnapshotCell::new( + text.to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::BOLD, + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("test cell is valid") +} + +fn shown_cursor() -> SnapshotCursor { + SnapshotCursor::new( + 0, + 0, + true, + true, + SnapshotCursorShape::Block, + Rgb::new(9, 9, 9), + Some(true), + ) +} + +fn snapshot_with(cells: Vec, title: Option<&str>) -> TerminalSnapshot { + let cols = 3; + let rows = 2; + assert_eq!(cells.len(), (cols * rows) as usize); + TerminalSnapshot::try_new(SnapshotParts { + cols, + rows, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells, + palette: resolved_palette(), + default_foreground: Rgb::new(192, 192, 192), + default_background: Rgb::new(0, 0, 0), + default_cursor: Rgb::new(9, 9, 9), + cursor: shown_cursor(), + title: title.map(str::to_string), + metadata: SnapshotMetadata::default(), + }) + .expect("test snapshot is valid") +} + +fn ascii_snapshot(title: Option<&str>) -> TerminalSnapshot { + snapshot_with( + vec![ + plain_cell("a", 1), + plain_cell("b", 1), + plain_cell("c", 1), + plain_cell(" ", 1), + plain_cell("x", 1), + plain_cell("y", 1), + ], + title, + ) +} + +fn strict_options(set: &PinnedFontSet) -> SnapshotRenderOptions { + SnapshotRenderOptions::default() + .with_canvas_padding(Padding::Uniform(0)) + .with_content_padding(Padding::Uniform(0)) + .with_title_bar(false) + .with_title_controls(false) + .with_shadow(false) + .with_border_width(0.0) + .with_pinned_fonts(set.clone()) +} + +fn decode_png(bytes: &[u8]) -> (u32, u32, Vec) { + let decoder = png::Decoder::new(bytes); + let mut reader = decoder.read_info().expect("PNG header decodes"); + let mut pixels = vec![0; reader.output_buffer_size()]; + let info = reader.next_frame(&mut pixels).expect("PNG frame decodes"); + assert_eq!(info.color_type, png::ColorType::Rgba); + assert_eq!(info.bit_depth, png::BitDepth::Eight); + (info.width, info.height, pixels) +} + +fn is_hex_digest(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// Process-wide environment changes stay inside one test at a time: a +/// system-font render running concurrently must never observe a rogue +/// value planted by the host-availability test. +static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +struct EnvGuard { + key: &'static str, + old: Option, +} + +impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let old = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, old } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match self.old.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "tui-test-pinned-{name}-{}-{}", + std::process::id(), + TEMP_COUNTER.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&path).expect("temp dir builds"); + Self { path } + } + + fn path(&self) -> &PathBuf { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// U04-A (corpus C06): host font availability cannot change strict output. +/// +/// The pinned render runs once, then again with the system font path +/// hidden behind an empty fontconfig directory and the ambient family +/// selection pointed at a bogus family — sandboxed to temp-local +/// directories and restored process environment. Both strict images must +/// decode to equal pixels with equal contracts. +#[test] +fn u04_a_pinned_output_survives_changed_host_fonts() { + let _lock = ENV_LOCK.lock().expect("env lock is available"); + let snapshot = ascii_snapshot(Some("pinned")); + let set = titled_pair(); + let options = strict_options(&set).with_title_bar(true); + let baseline = render_snapshot_png(&snapshot, &options).expect("pinned baseline renders"); + + let empty_fonts = TempDir::new("fontconfig-empty"); + let _fontconfig = EnvGuard::set( + "FONTCONFIG_PATH", + empty_fonts.path().to_str().expect("temp path is UTF-8"), + ); + let _families = EnvGuard::set("TUI_TEST_RECORDING_FONT_FAMILIES", "NoSuchFamily-XYZ"); + + // The rogue environment is real enough to run through: a system render + // still succeeds while the pinned render must not move. + let _system = render_snapshot_png(&snapshot, &SnapshotRenderOptions::default()) + .expect("system render survives the rogue environment"); + let changed = render_snapshot_png(&snapshot, &options).expect("pinned render under rogue env"); + + assert_eq!( + decode_png(baseline.png()), + decode_png(changed.png()), + "host font availability must not change strict pinned pixels" + ); + assert_eq!(baseline.contract(), changed.contract()); + assert_eq!(baseline.contract().raster_policy(), RASTER_POLICY_PINNED_V1); +} + +/// U04-B: different font bytes change the reported digest. +/// +/// A second face in the fallback order changes the set digest, the +/// contract, and the contract's per-face entries, while the same bytes +/// twice give the same digest and the same decoded pixels. +#[test] +fn u04_b_different_bytes_change_the_digest() { + assert_ne!( + regular_face().digest(), + bold_face().digest(), + "different bytes must digest differently" + ); + let single = PinnedFontSet::new(vec![regular_face()]).expect("single-face set builds"); + let pair = titled_pair(); + assert_ne!(single.digest(), pair.digest()); + assert!(is_hex_digest(&single.digest())); + assert!(is_hex_digest(&pair.digest())); + + let snapshot = ascii_snapshot(None); + let first = render_snapshot_png(&snapshot, &strict_options(&single)) + .expect("single-face pinned render works without a title"); + let second = render_snapshot_png(&snapshot, &strict_options(&pair)) + .expect("two-face pinned render works"); + assert_ne!( + first.contract().fonts()[0].digest(), + second.contract().fonts()[1].digest() + ); + assert_ne!(first.contract(), second.contract()); + + let repeat = render_snapshot_png(&snapshot, &strict_options(&single)) + .expect("repeated pinned render works"); + assert_eq!(first.contract(), repeat.contract()); + assert_eq!(decode_png(first.png()), decode_png(repeat.png())); +} + +/// U04-C: missing font, missing styled face, and missing glyph fail clearly. +/// +/// Each failure keeps its type through the public render error: no host +/// fallback, no replacement glyph, and no image alongside the error. +#[test] +fn u04_c_missing_font_face_and_glyph_fail_clearly() { + let empty = PinnedFontSet::new(Vec::new()).expect_err("an empty set must fail"); + assert_eq!(empty, PinnedFontError::EmptySet); + assert!( + empty.to_string().contains("never falls back to host fonts"), + "unexpected message: {empty}" + ); + + // A regular-only set cannot draw the bold slot. + let regular_only = PinnedFontSet::new(vec![regular_face()]).expect("regular set builds"); + let bold_snapshot = snapshot_with( + vec![ + bold_cell("b"), + plain_cell(" ", 1), + plain_cell(" ", 1), + plain_cell(" ", 1), + plain_cell(" ", 1), + plain_cell(" ", 1), + ], + None, + ); + let missing_face = render_snapshot_png(&bold_snapshot, &strict_options(®ular_only)) + .expect_err("a missing style face must fail"); + match missing_face { + RenderError::PinnedFont(PinnedFontError::MissingFace { bold, italic }) => { + assert!(bold); + assert!(!italic); + } + other => panic!("missing face must stay typed, got: {other}"), + } + assert!( + missing_face.to_string().contains("bold"), + "the message must name the style: {missing_face}" + ); + + // The pinned JetBrains pair holds no crab emoji. + let crab = snapshot_with( + vec![ + plain_cell("\u{1F980}", 2), + plain_cell("", 0), + plain_cell(" ", 1), + plain_cell(" ", 1), + plain_cell(" ", 1), + plain_cell(" ", 1), + ], + None, + ); + let missing_glyph = render_snapshot_png(&crab, &strict_options(&titled_pair())) + .expect_err("a missing glyph must fail"); + match &missing_glyph { + RenderError::PinnedFont(PinnedFontError::MissingGlyph { character, .. }) => { + assert_eq!(*character, '\u{1F980}'); + } + other => panic!("missing glyph must stay typed, got: {other}"), + } + let message = missing_glyph.to_string(); + assert!(message.contains("U+1F980"), "unexpected message: {message}"); + assert!( + message.contains("no host font or replacement glyph is substituted"), + "unexpected message: {message}" + ); +} + +/// U04-D: previous system-font cache operations cannot change strict output. +/// +/// Pinned renders surround system renders — including one at a different +/// scale that exercises the shared raster caches — and the strict pixels +/// and contracts on both sides must match. +#[test] +fn u04_d_system_cache_operations_cannot_change_strict_output() { + let _lock = ENV_LOCK.lock().expect("env lock is available"); + let snapshot = ascii_snapshot(Some("pinned")); + let set = titled_pair(); + let options = strict_options(&set).with_title_bar(true); + + let before = render_snapshot_png(&snapshot, &options).expect("first pinned render works"); + let system_before = render_snapshot_png(&snapshot, &SnapshotRenderOptions::default()) + .expect("first system render works"); + let _scaled = render_snapshot_png(&snapshot, &SnapshotRenderOptions::default().with_scale(2.0)) + .expect("scaled system render works"); + let after = render_snapshot_png(&snapshot, &options).expect("second pinned render works"); + let system_after = render_snapshot_png(&snapshot, &SnapshotRenderOptions::default()) + .expect("second system render works"); + + assert_eq!( + decode_png(before.png()), + decode_png(after.png()), + "system cache operations must not change strict pinned pixels" + ); + assert_eq!(before.contract(), after.contract()); + assert_eq!( + decode_png(system_before.png()), + decode_png(system_after.png()), + "pinned renders must not change system output either" + ); +} + +/// U04-E: equal pinned inputs give equal decoded pixels on each platform. +/// +/// Two independent builds from the same bytes render the same snapshot to +/// equal decoded RGBA with equal contracts. +#[test] +fn u04_e_equal_pinned_inputs_give_equal_decoded_pixels() { + let snapshot = ascii_snapshot(Some("pinned")); + let first = render_snapshot_png(&snapshot, &strict_options(&titled_pair())) + .expect("first pinned render works"); + let second = render_snapshot_png(&snapshot, &strict_options(&titled_pair())) + .expect("second pinned render works"); + let (first_width, first_height, first_pixels) = decode_png(first.png()); + let (second_width, second_height, second_pixels) = decode_png(second.png()); + assert_eq!((first_width, first_height), (second_width, second_height)); + assert!(!first_pixels.is_empty()); + assert_eq!(first_pixels, second_pixels); + assert_eq!(first.contract(), second.contract()); + assert_eq!( + first.contract().cursor_phase(), + CursorPhase::Shown, + "a visible in-capture cursor uses the shown phase" + ); + assert!(first.evidence().cursor_drawn()); + assert_eq!(first.contract().blink(), Some(true)); +} + +/// U04-F: existing font selection keeps its recorded behavior. +/// +/// Default options select no pinned fonts, so the rasterizer keeps the +/// system-font path with a `system-v1` contract; the remaining evidence +/// comes from the untouched suites running green. +#[test] +fn u04_f_recorded_font_selection_is_unchanged() { + let _lock = ENV_LOCK.lock().expect("env lock is available"); + assert!( + SnapshotRenderOptions::default().pinned_fonts().is_none(), + "pinned fonts stay opt-in" + ); + let snapshot = ascii_snapshot(Some("recorded")); + let rendered = render_snapshot_png(&snapshot, &SnapshotRenderOptions::default()) + .expect("system render keeps working"); + assert_eq!(rendered.contract().raster_policy(), RASTER_POLICY_SYSTEM_V1); + assert!( + rendered.contract().fonts().is_empty(), + "system faces are host inputs, never hashed evidence" + ); + assert_eq!( + rendered.contract().contract_version(), + RENDER_CONTRACT_VERSION + ); + assert!(!decode_png(rendered.png()).2.is_empty()); +} From 3c37c6a109dd2643053f96363fcc36b8989d9d2b Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 18:49:18 +0700 Subject: [PATCH 09/17] U04: pin title, background, and padding settings in PinnedCacheKey Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/api.rs | 2 +- crates/tui-test/src/profile.rs | 2 +- crates/tui-test/src/render/offline.rs | 11 +- crates/tui-test/src/render/pinned.rs | 329 +++++++++++++++++++++++++- crates/tui-test/src/render/raster.rs | 33 ++- 5 files changed, 362 insertions(+), 15 deletions(-) diff --git a/crates/tui-test/src/api.rs b/crates/tui-test/src/api.rs index 154917bd..c79b4d5e 100644 --- a/crates/tui-test/src/api.rs +++ b/crates/tui-test/src/api.rs @@ -1217,7 +1217,7 @@ pub enum RecordingFormat { Cast, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "kind", content = "color")] pub enum CaptureBackground { Color(crate::profile::Rgb), diff --git a/crates/tui-test/src/profile.rs b/crates/tui-test/src/profile.rs index 7805159f..9c5fcf72 100644 --- a/crates/tui-test/src/profile.rs +++ b/crates/tui-test/src/profile.rs @@ -36,7 +36,7 @@ pub const CONFIG_FILE: &str = "tui-test.toml"; /// The profile used when none is named. pub const DEFAULT_PROFILE: &str = "default"; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Rgb { pub r: u8, pub g: u8, diff --git a/crates/tui-test/src/render/offline.rs b/crates/tui-test/src/render/offline.rs index 62495ee2..84e7a9a6 100644 --- a/crates/tui-test/src/render/offline.rs +++ b/crates/tui-test/src/render/offline.rs @@ -778,7 +778,12 @@ pub fn render_snapshot_png( true, ) .map_err(map_pinned_error)?; - let key = super::raster::GridRenderer::pinned_cache_key(&layout, set); + let key = super::raster::GridRenderer::pinned_cache_key( + &layout, + set, + options.background, + true, + ); let png = super::encode::encode_png_bytes(&frame, &mut renderer).map_err(map_pinned_error)?; let (width, height) = renderer.pixel_size(); @@ -800,8 +805,8 @@ pub fn render_snapshot_png( } } -/// Keep a typed [`PinnedFontError`] typed: downcast the renderer failure -/// back to it, and only stringify what was never pinned. +/// Preserve the typed [`PinnedFontError`]: downcast a pinned failure back +/// to its type, and report anything else as a raster failure. #[cfg(feature = "recording-raster")] fn map_pinned_error(error: anyhow::Error) -> RenderError { match error.downcast::() { diff --git a/crates/tui-test/src/render/pinned.rs b/crates/tui-test/src/render/pinned.rs index a05537a0..3b036ad3 100644 --- a/crates/tui-test/src/render/pinned.rs +++ b/crates/tui-test/src/render/pinned.rs @@ -35,6 +35,9 @@ use sha2::{Digest, Sha256}; +use crate::api::CaptureBackground; +use crate::profile::Rgb; + /// Schema version of [`RenderContract`]. pub const RENDER_CONTRACT_VERSION: u32 = 1; @@ -280,12 +283,17 @@ fn style_name(bold: bool, italic: bool) -> &'static str { /// The identity a pinned-font cache entry is stored under. /// /// The policy tag, every face digest with its face index and style slot in -/// fallback order, the synthetic-style selection, the output scale, and the -/// raster geometry (font size, cell metrics, baseline) all join the key, so -/// a system-font entry — keyed by family and path — can never serve a -/// pinned request and one raster configuration can never serve another. -/// Each pinned render builds its fonts from the specified bytes only; the -/// key is recorded in the render contract as the evidence of what was used. +/// fallback order, the synthetic-style selection, the output scale, and +/// every raster setting that changes output pixels all join the key: grid +/// geometry (font size, cell metrics, baseline), title chrome (bar, controls, +/// title size, header and divider heights), content and canvas padding, +/// backgrounds (canvas fill and capture override), window and border +/// chrome, the shadow, and the exact-size switch. A system-font entry — +/// keyed by family and path — can never serve a pinned request and one +/// raster configuration can never serve another. Each pinned render builds +/// its fonts from the specified bytes only; the key is recorded in the +/// render contract as the evidence of what was used. The key is a pure +/// identity value: no shared cache is stored behind it. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PinnedCacheKey { policy: &'static str, @@ -296,6 +304,32 @@ pub struct PinnedCacheKey { cell_width_bits: u32, cell_height_bits: u32, baseline_bits: u32, + title_font_size_bits: u32, + header_height_bits: u32, + divider_height_bits: u32, + content_left_bits: u32, + content_top_bits: u32, + content_right_bits: u32, + content_bottom_bits: u32, + canvas_left: u32, + canvas_right: u32, + canvas_top: u32, + canvas_bottom: u32, + canvas_background: Rgb, + background: Option, + title_bar: bool, + title_controls: bool, + window_background: Rgb, + window_foreground: Rgb, + window_divider: Rgb, + border_width_bits: u32, + border_color: Rgb, + border_radius_bits: u32, + shadow_enabled: bool, + shadow_color: Rgb, + shadow_offset_bits: u32, + shadow_spread_bits: u32, + exact_size: bool, } /// One face inside a [`PinnedCacheKey`], in fallback order. @@ -332,6 +366,7 @@ impl PinnedCachedFace { impl PinnedCacheKey { /// Build the cache identity for one render from the pinned set and the /// raster settings the render actually uses. + #[allow(clippy::too_many_arguments)] pub fn new( set: &PinnedFontSet, scale: f64, @@ -339,6 +374,32 @@ impl PinnedCacheKey { cell_width: f32, cell_height: f32, baseline: f32, + title_font_size: f32, + header_height: f32, + divider_height: f32, + content_left: f32, + content_top: f32, + content_right: f32, + content_bottom: f32, + canvas_left: u32, + canvas_right: u32, + canvas_top: u32, + canvas_bottom: u32, + canvas_background: Rgb, + background: Option, + title_bar: bool, + title_controls: bool, + window_background: Rgb, + window_foreground: Rgb, + window_divider: Rgb, + border_width: f32, + border_color: Rgb, + border_radius: f32, + shadow_enabled: bool, + shadow_color: Rgb, + shadow_offset: f32, + shadow_spread: f32, + exact_size: bool, ) -> Self { Self { policy: RASTER_POLICY_PINNED_V1, @@ -358,6 +419,32 @@ impl PinnedCacheKey { cell_width_bits: cell_width.to_bits(), cell_height_bits: cell_height.to_bits(), baseline_bits: baseline.to_bits(), + title_font_size_bits: title_font_size.to_bits(), + header_height_bits: header_height.to_bits(), + divider_height_bits: divider_height.to_bits(), + content_left_bits: content_left.to_bits(), + content_top_bits: content_top.to_bits(), + content_right_bits: content_right.to_bits(), + content_bottom_bits: content_bottom.to_bits(), + canvas_left, + canvas_right, + canvas_top, + canvas_bottom, + canvas_background, + background, + title_bar, + title_controls, + window_background, + window_foreground, + window_divider, + border_width_bits: border_width.to_bits(), + border_color, + border_radius_bits: border_radius.to_bits(), + shadow_enabled, + shadow_color, + shadow_offset_bits: shadow_offset.to_bits(), + shadow_spread_bits: shadow_spread.to_bits(), + exact_size, } } @@ -401,10 +488,140 @@ impl PinnedCacheKey { f32::from_bits(self.baseline_bits) } + /// The title font size the key pins. + pub fn title_font_size(&self) -> f32 { + f32::from_bits(self.title_font_size_bits) + } + + /// The title header height the key pins. + pub fn header_height(&self) -> f32 { + f32::from_bits(self.header_height_bits) + } + + /// The title divider height the key pins. + pub fn divider_height(&self) -> f32 { + f32::from_bits(self.divider_height_bits) + } + + /// The left content gap the key pins. + pub fn content_left(&self) -> f32 { + f32::from_bits(self.content_left_bits) + } + + /// The top content gap the key pins. + pub fn content_top(&self) -> f32 { + f32::from_bits(self.content_top_bits) + } + + /// The right content gap the key pins. + pub fn content_right(&self) -> f32 { + f32::from_bits(self.content_right_bits) + } + + /// The bottom content gap the key pins. + pub fn content_bottom(&self) -> f32 { + f32::from_bits(self.content_bottom_bits) + } + + /// The left canvas padding the key pins. + pub fn canvas_left(&self) -> u32 { + self.canvas_left + } + + /// The right canvas padding the key pins. + pub fn canvas_right(&self) -> u32 { + self.canvas_right + } + + /// The top canvas padding the key pins. + pub fn canvas_top(&self) -> u32 { + self.canvas_top + } + + /// The bottom canvas padding the key pins. + pub fn canvas_bottom(&self) -> u32 { + self.canvas_bottom + } + + /// The canvas fill the key pins, used without a capture override. + pub fn canvas_background(&self) -> Rgb { + self.canvas_background + } + + /// The capture background override the key pins, if any. + pub fn background(&self) -> Option { + self.background + } + + /// Whether the title bar the key pins is drawn. + pub fn title_bar(&self) -> bool { + self.title_bar + } + + /// Whether the window controls the key pins are drawn. + pub fn title_controls(&self) -> bool { + self.title_controls + } + + /// The title bar fill the key pins. + pub fn window_background(&self) -> Rgb { + self.window_background + } + + /// The title text color the key pins. + pub fn window_foreground(&self) -> Rgb { + self.window_foreground + } + + /// The title divider color the key pins. + pub fn window_divider(&self) -> Rgb { + self.window_divider + } + + /// The panel border width the key pins. + pub fn border_width(&self) -> f32 { + f32::from_bits(self.border_width_bits) + } + + /// The panel border color the key pins. + pub fn border_color(&self) -> Rgb { + self.border_color + } + + /// The panel corner radius the key pins. + pub fn border_radius(&self) -> f32 { + f32::from_bits(self.border_radius_bits) + } + + /// Whether the drop shadow the key pins is drawn. + pub fn shadow_enabled(&self) -> bool { + self.shadow_enabled + } + + /// The drop shadow color the key pins. + pub fn shadow_color(&self) -> Rgb { + self.shadow_color + } + + /// The drop shadow offset the key pins. + pub fn shadow_offset(&self) -> f32 { + f32::from_bits(self.shadow_offset_bits) + } + + /// The drop shadow spread the key pins. + pub fn shadow_spread(&self) -> f32 { + f32::from_bits(self.shadow_spread_bits) + } + + /// Whether exact (unpadded) output size the key pins is used. + pub fn exact_size(&self) -> bool { + self.exact_size + } + /// SHA-256 over every key component, for contracts and comparisons. pub fn digest(&self) -> String { let mut hasher = Sha256::new(); - hasher.update(b"pinned-cache-key-v1"); + hasher.update(b"pinned-cache-key-v2"); hasher.update(self.policy.as_bytes()); hasher.update([u8::from(self.synthetic)]); for face in &self.faces { @@ -417,6 +634,44 @@ impl PinnedCacheKey { hasher.update(self.cell_width_bits.to_le_bytes()); hasher.update(self.cell_height_bits.to_le_bytes()); hasher.update(self.baseline_bits.to_le_bytes()); + hasher.update(self.title_font_size_bits.to_le_bytes()); + hasher.update(self.header_height_bits.to_le_bytes()); + hasher.update(self.divider_height_bits.to_le_bytes()); + hasher.update(self.content_left_bits.to_le_bytes()); + hasher.update(self.content_top_bits.to_le_bytes()); + hasher.update(self.content_right_bits.to_le_bytes()); + hasher.update(self.content_bottom_bits.to_le_bytes()); + hasher.update(self.canvas_left.to_le_bytes()); + hasher.update(self.canvas_right.to_le_bytes()); + hasher.update(self.canvas_top.to_le_bytes()); + hasher.update(self.canvas_bottom.to_le_bytes()); + for color in [ + self.canvas_background, + self.window_background, + self.window_foreground, + self.window_divider, + self.border_color, + self.shadow_color, + ] { + hasher.update([color.r, color.g, color.b]); + } + match self.background { + None => hasher.update([0]), + Some(CaptureBackground::Transparent) => hasher.update([1]), + Some(CaptureBackground::Color(color)) => { + hasher.update([2, color.r, color.g, color.b]); + } + } + hasher.update([ + u8::from(self.title_bar), + u8::from(self.title_controls), + u8::from(self.shadow_enabled), + u8::from(self.exact_size), + ]); + hasher.update(self.border_width_bits.to_le_bytes()); + hasher.update(self.border_radius_bits.to_le_bytes()); + hasher.update(self.shadow_offset_bits.to_le_bytes()); + hasher.update(self.shadow_spread_bits.to_le_bytes()); format!("{:x}", hasher.finalize()) } } @@ -700,17 +955,73 @@ mod tests { assert_ne!(regular.digest(), bolded.digest()); } + fn test_key( + set: &PinnedFontSet, + scale: f64, + title_bar: bool, + background: Option, + canvas_left: u32, + ) -> PinnedCacheKey { + PinnedCacheKey::new( + set, + scale, + 17.0, + 10.0, + 21.0, + 14.0, + 13.0, + 28.0, + 1.0, + 15.0, + 8.0, + 15.0, + 14.0, + canvas_left, + 24, + 24, + 24, + Rgb::new(30, 30, 30), + background, + title_bar, + false, + Rgb::new(40, 40, 40), + Rgb::new(200, 200, 200), + Rgb::new(60, 60, 60), + 0.0, + Rgb::new(70, 70, 70), + 8.0, + false, + Rgb::new(0, 0, 0), + 4.0, + 6.0, + true, + ) + } + #[test] fn cache_keys_pin_policy_faces_and_raster_settings() { let face = PinnedFace::new(regular_bytes(), 0, false, false).unwrap(); let set = PinnedFontSet::new(vec![face]).unwrap(); - let key = PinnedCacheKey::new(&set, 1.0, 17.0, 10.0, 21.0, 14.0); + let key = test_key(&set, 1.0, false, None, 24); assert_eq!(key.policy(), RASTER_POLICY_PINNED_V1); assert_eq!(key.faces().len(), 1); assert_eq!(key.scale(), 1.0); assert_eq!(key.cell_width(), 10.0); - let scaled = PinnedCacheKey::new(&set, 2.0, 17.0, 10.0, 21.0, 14.0); + assert!(!key.title_bar()); + assert_eq!(key.background(), None); + assert_eq!(key.canvas_left(), 24); + assert!(key.exact_size()); + let scaled = test_key(&set, 2.0, false, None, 24); assert_ne!(key, scaled, "scale joins the cache key"); assert_ne!(key.digest(), scaled.digest()); + let titled = test_key(&set, 1.0, true, None, 24); + assert_ne!(key, titled, "title bar joins the cache key"); + assert_ne!(key.digest(), titled.digest()); + let tinted = test_key(&set, 1.0, false, Some(CaptureBackground::Transparent), 24); + assert_ne!(key, tinted, "background joins the cache key"); + assert_ne!(key.digest(), tinted.digest()); + let padded = test_key(&set, 1.0, false, None, 32); + assert_ne!(key, padded, "canvas padding joins the cache key"); + assert_ne!(key.digest(), padded.digest()); } } diff --git a/crates/tui-test/src/render/raster.rs b/crates/tui-test/src/render/raster.rs index b44cfb3d..fe1e051d 100644 --- a/crates/tui-test/src/render/raster.rs +++ b/crates/tui-test/src/render/raster.rs @@ -168,7 +168,12 @@ impl GridRenderer { /// The pinned cache identity for a layout and set, without allocating /// the renderer. The offline layer records it in the render contract. - pub(crate) fn pinned_cache_key(layout: &ResolvedLayout, set: &PinnedFontSet) -> PinnedCacheKey { + pub(crate) fn pinned_cache_key( + layout: &ResolvedLayout, + set: &PinnedFontSet, + background: Option, + exact_size: bool, + ) -> PinnedCacheKey { PinnedCacheKey::new( set, layout.zoom, @@ -176,6 +181,32 @@ impl GridRenderer { layout.cell_w, layout.cell_h, layout.baseline, + layout.title_font_size, + layout.header_h, + layout.divider_h, + layout.content_left, + layout.content_top, + layout.content_right, + layout.content_bottom, + layout.canvas_left, + layout.canvas_right, + layout.canvas_top, + layout.canvas_bottom, + layout.canvas_background, + background, + layout.title_bar, + layout.title_controls, + layout.window_background, + layout.window_foreground, + layout.window_divider, + layout.border_width, + layout.border_color, + layout.border_radius, + layout.shadow_enabled, + layout.shadow_color, + layout.shadow_offset, + layout.shadow_spread, + exact_size, ) } From f6cb06fcb29418e51436441c96c20fcdc43f2fad Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 18:58:20 +0700 Subject: [PATCH 10/17] U05: shape graphemes with rustybuzz 0.20.1 MIT, edition 2021, newest crates.io release (2024-11-12, stable 1.82 era, no declared rust-version; pure Rust with pre-1.90 dependencies). Uses ttf-parser 0.25, shared with the workspace, so no second parser enters the tree. Optional under recording-raster; the no-default-features build is unchanged. Signed-off-by: Alexey Zhokhov --- Cargo.lock | 43 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + crates/tui-test/Cargo.toml | 3 ++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index e67b49ac..b6c97fb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1364,6 +1364,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1875,6 +1893,7 @@ dependencies = [ "regex", "rio-vt", "rquickjs", + "rustybuzz", "serde", "serde_json", "sha2", @@ -1897,12 +1916,36 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 200fee35..ba6bec55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ ghostty-vt = { package = "libghostty-vt", version = "0.2.1", git = "https://gith png = "0.17.16" portable-pty = "0.9.0" regex = "1.12.4" +rustybuzz = "0.20.1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.10.9" diff --git a/crates/tui-test/Cargo.toml b/crates/tui-test/Cargo.toml index 580c7180..d2d7a966 100644 --- a/crates/tui-test/Cargo.toml +++ b/crates/tui-test/Cargo.toml @@ -17,7 +17,7 @@ default = [] ghostty = ["dep:ghostty-vt"] rio = ["dep:rio-vt"] xtermjs = ["dep:rquickjs"] -recording-raster = ["dep:fontdb", "dep:gif", "dep:png", "dep:tiny-skia"] +recording-raster = ["dep:fontdb", "dep:gif", "dep:png", "dep:tiny-skia", "dep:rustybuzz"] recording-font-jetbrains-mono = ["recording-raster"] recording-font-jetbrains-mono-styles = ["recording-font-jetbrains-mono"] recording-font-jetbrains-mono-full = ["recording-font-jetbrains-mono-styles"] @@ -38,6 +38,7 @@ portable-pty.workspace = true regex.workspace = true rio-vt = { workspace = true, optional = true } rquickjs = { workspace = true, optional = true } +rustybuzz = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true sha2.workspace = true From 759f5e04191cc7a0a973dd9d859402ca5b7b946c Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 18:58:20 +0700 Subject: [PATCH 11/17] U05: vendor Noto Sans JP subset for the CJK corpus case One OFL-1.1 face with only U+6771 plus .notdef (3,884 bytes), subset at development time from google/fonts NotoSansJP[wght].ttf; SOURCE records the commit, digests, and subset command. Pinned tests read these bytes directly; nothing fetches fonts at test time. Signed-off-by: Alexey Zhokhov --- .../assets/noto-sans-jp/NotoSansJP-subset.ttf | Bin 0 -> 3884 bytes crates/tui-test/assets/noto-sans-jp/OFL.txt | 93 ++++++++++++++++++ crates/tui-test/assets/noto-sans-jp/SOURCE | 22 +++++ 3 files changed, 115 insertions(+) create mode 100644 crates/tui-test/assets/noto-sans-jp/NotoSansJP-subset.ttf create mode 100644 crates/tui-test/assets/noto-sans-jp/OFL.txt create mode 100644 crates/tui-test/assets/noto-sans-jp/SOURCE diff --git a/crates/tui-test/assets/noto-sans-jp/NotoSansJP-subset.ttf b/crates/tui-test/assets/noto-sans-jp/NotoSansJP-subset.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7a9a731ab85fcc349164cda8253c0605345fc34b GIT binary patch literal 3884 zcmbVPeQZ-{MGm?wv&KL zHCE`xCN}X$TXi3uHmR$oY17y`byGhkhB}%}j7ghTCY7~pVuf}fp^i5xf5NTlUiPnriZ+VvMDlI*fxLSchTnQUQCw?8)hkBD>`2z*&XhG?g%#lzGthR|AG zaK^(NC1d*s2S%wcIuo7IGmFQVi7?PDJ!osRKSMUi4rWu}#t#GOC|dVRh7uExoke3xG4uyxO7_M7V_XXXRCqfEbNJ{M}dwV>rc zIF6Fme)Pcu+8)$mMmy3G}W$A75&1Jb?T$DmTW7rSw@eD3ai~t^`PXAHPzV9SaJ<;-mqjhdF*dl zvIjWVsf9wAZv$(%4RhX!nP;gTvO)b=U1^%6Fyt1BQ69ex_0UHA=H<^%X3@qEq%`J_czw5OOG4n!|evE!$w3La#yh10g9rG-EZUX%EekJk z$&PbU!1K{Cv|e~+MjU4fu^7k_Tli6Lq5%4n;4uvpwu+!WfTD1^VAYMi+70`T|G}v= z4+hw9+$KsM>^R?H=_djW(sH3un(=dilAZEc}jM&;N3-^usfg z#_HktIX_XL>_E)-({?gm|L*C!Gs0r9qiD&ifoFtsBkg`ryKwVY*U`dzinF6uumS!F z6y6P-`JD8~Exhm4%2d(i;GZKfu{XJ&^EHTgIY&F?Xao6Se!N!LPX@hddIVYsSiG8e zm+>BAWNzaf8wJ`yIRgeT*{>lHA~MfxTi6+#J zUJpEz{n&xy*q^(xa*Sx@i7V5g<3B}Df$Qx!D((-jln>`@1ETu4=Tu%s8^#V$=LzqL zrIU&0Is&YDFy_@&*okUI@f>lE_%thIns-iN-vtrZ2=r0plWj|pN01#p;}imC{?3z! z&lN6(la86)?zh0k#4MP=TUrgJCokuE8*=>sYR-QT>Oy{U)=8cN7v3uw^z%OF^Dlv# zPlYIQGYW)};wW|cf4nh;llRW^TV*@-k*DB;$V(i4ndK19Pj%*T##Gyqv`c0`j_fCK z0`b0y;}qw;Jyooi5SDs4Qz5KFzAfQdJc5(`qaNJuaA&HN+GLj6WR}`w zmfG$Zy@bK{*V2b^6|)-2L9;rUr9-xOiEo9gNSlww@`CL>V9A--hBM}2%-@`}BYZ32 z+e68?S1lCf3UN1NUKJX?7fzshrPx;`ZWQHed6Q=kBx*`*+`3V;@#sgn-|`5Kr|6Vy zs3F%DFL@uWqq=_?wC+4ET~}q6m2C}t3&idu2gf0qf~`UmPT>Lwbnqr-F7U<2tVJaX z7>g)6;_e(f)RJ-_g?;I7TS2E+6I8^1k!{+)l^n3#C>SuU5n-b+U|1p?>Jtz3Ee za{a&G|NA=^FT7XXzGX|mzi!1Ee^38Fx994Gw=Z43di~Ar?l;dE#u=~o-FJ_$V-OWL YZXp_c(R9dftpYzui!J0=8@>wv3+y$4tpET3 literal 0 HcmV?d00001 diff --git a/crates/tui-test/assets/noto-sans-jp/OFL.txt b/crates/tui-test/assets/noto-sans-jp/OFL.txt new file mode 100644 index 00000000..1c9f4328 --- /dev/null +++ b/crates/tui-test/assets/noto-sans-jp/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/tui-test/assets/noto-sans-jp/SOURCE b/crates/tui-test/assets/noto-sans-jp/SOURCE new file mode 100644 index 00000000..3d3270a5 --- /dev/null +++ b/crates/tui-test/assets/noto-sans-jp/SOURCE @@ -0,0 +1,22 @@ +Noto Sans JP subset for U05 grapheme tests (one CJK face) +commit: 295d98a7a0c17c68f1341eaeea354e7960ea70d3 (google/fonts, file last changed 2022-11-03) +source: https://github.com/google/fonts +path: ofl/notosansjp/NotoSansJP[wght].ttf +original SHA-256: c2f3b4d463500a2ddcd3849cded1fceeb9fd6d1c32e6cbecd568453ba50fc68f +original bytes: 9589900 +subset SHA-256: 8eec9e6c0ef45c3f0c62cd9fcd164d9c394a662b7f66328d9fc005798f441496 +subset bytes: 3884 + +Subset keeps only U+6771 (東, CJK UNIFIED IDEOGRAPH-6771) plus .notdef, +made with fonttools 4.67.0 subset at development time (never at test time): + + python3 -m fontTools.subset NotoSansJP-wght.ttf --unicodes="U+6771" \ + --output-file=NotoSansJP-subset.ttf --layout-features='' \ + --no-hinting --desubroutinize --name-IDs='*' --glyph-names \ + --recalc-bounds --recalc-timestamp --canonical-order \ + --no-recalc-timestamp + +Face index 0, units per em 1000, advance of U+6771 is 1000 (full width). +License OFL-1.1 in OFL.txt (Copyright 2014-2021 Adobe, Reserved Font +Name 'Source'). Used only as pinned test bytes for the mandatory CJK +corpus case; no test fetches fonts from the network. From eb1a43f01310db98565319d3128746e76c8d691a Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 19:14:08 +0700 Subject: [PATCH 12/17] U05: shape pinned graphemes through one path for cells, cursor, and titles Replace scalar-count rejection with rustybuzz shaping: each cell grapheme shapes with one face selected per grapheme in fallback order, so a combining sequence never divides across faces. Cells and cursor repaint share draw_cell_text; titles shape per cluster from the shaper's own indices with ligatures off. Single scalars draw verbatim through the pinned cache; shaped runs map their advance onto the cell width and clip to the permitted region. Glyph evidence flows to RenderEvidence.shaped_graphemes. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/render/offline.rs | 16 +- crates/tui-test/src/render/raster.rs | 261 ++++++++---- crates/tui-test/src/render/raster/draw.rs | 140 ++++++- crates/tui-test/src/render/raster/font.rs | 98 ++++- crates/tui-test/src/render/raster/shaping.rs | 420 +++++++++++++++++++ 5 files changed, 840 insertions(+), 95 deletions(-) create mode 100644 crates/tui-test/src/render/raster/shaping.rs diff --git a/crates/tui-test/src/render/offline.rs b/crates/tui-test/src/render/offline.rs index 84e7a9a6..ff9a022c 100644 --- a/crates/tui-test/src/render/offline.rs +++ b/crates/tui-test/src/render/offline.rs @@ -543,7 +543,8 @@ fn check_padding(field: &'static str, padding: &Padding) -> Result<(), RenderErr /// /// Dimensions plus the behaviors the spec calls out: whether the cursor was /// drawn (hidden and clipped cursors stay hidden), whether the canvas is -/// transparent, and which title the bar showed. +/// transparent, which title the bar showed, and which multi-scalar +/// graphemes the pinned shaper placed. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenderEvidence { width: u32, @@ -551,6 +552,7 @@ pub struct RenderEvidence { cursor_drawn: bool, transparent_background: bool, title: Option, + shaped_graphemes: Vec, } impl RenderEvidence { @@ -579,6 +581,14 @@ impl RenderEvidence { pub fn title(&self) -> Option<&str> { self.title.as_deref() } + + /// The multi-scalar cell graphemes the pinned shaper placed, in + /// row-major cell order with one entry per shaped cell. Empty for SVG + /// renders, system-font renders, and renders with no shaped cells: + /// single-scalar cells draw verbatim and are not listed. + pub fn shaped_graphemes(&self) -> &[String] { + &self.shaped_graphemes + } } /// One rendered SVG image with its evidence. @@ -650,6 +660,7 @@ pub fn render_snapshot_svg( Some(CaptureBackground::Transparent) ), title: shown_title(snapshot.title(), cols, rows, &layout), + shaped_graphemes: Vec::new(), }, }) } @@ -740,6 +751,7 @@ pub fn render_snapshot_png( cursor_drawn, transparent_background: matches!(options.background, Some(CaptureBackground::Transparent)), title: shown_title(snapshot.title(), cols, rows, &layout), + shaped_graphemes: Vec::new(), }; match options.pinned_fonts.as_ref() { None => { @@ -790,6 +802,7 @@ pub fn render_snapshot_png( let mut rendered_evidence = evidence(); rendered_evidence.width = width; rendered_evidence.height = height; + rendered_evidence.shaped_graphemes = renderer.take_shaped_graphemes(); Ok(RenderedPng { png, evidence: rendered_evidence, @@ -855,6 +868,7 @@ pub fn write_snapshot_png( cursor_drawn: frame.cursor.is_some(), transparent_background: matches!(options.background, Some(CaptureBackground::Transparent)), title: shown_title(snapshot.title(), cols, rows, &layout), + shaped_graphemes: renderer.take_shaped_graphemes(), }) } diff --git a/crates/tui-test/src/render/raster.rs b/crates/tui-test/src/render/raster.rs index fe1e051d..bc171d40 100644 --- a/crates/tui-test/src/render/raster.rs +++ b/crates/tui-test/src/render/raster.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use tiny_skia::Pixmap; use crate::api::CaptureBackground; -use crate::profile::ColorSlot; +use crate::profile::{ColorSlot, Rgb}; use crate::record::frames::Frame; use crate::terminal::cell::{EmuCell, CONTINUATION}; use crate::terminal::emu::CursorShape; @@ -13,15 +13,16 @@ use super::svg::{RenderColors, ResolvedLayout}; mod draw; mod font; +mod shaping; use draw::{ - draw_glyph, fill_antialiased_rect, fill_circle, fill_pixel_rect, fill_rounded_rect, - fill_rounded_rect_alpha, fill_top_rounded_rect, format_glyph_sequence, is_default_ignorable, - stroke_rounded_rect, unpremultiply, unsupported_grapheme, + cell_clip, draw_glyph, draw_shaped_run, fill_antialiased_rect, fill_circle, fill_pixel_rect, + fill_rounded_rect, fill_rounded_rect_alpha, fill_top_rounded_rect, format_glyph_sequence, + is_default_ignorable, stroke_rounded_rect, unpremultiply, unsupported_grapheme, }; use font::{FontBackend, GlyphKey}; -use super::pinned::{PinnedCacheKey, PinnedFontError, PinnedFontSet}; +use super::pinned::{PinnedCacheKey, PinnedFontSet}; use crate::render::style::Style; @@ -66,6 +67,11 @@ pub struct GridRenderer { layout: ResolvedLayout, /// Overrides the layout's canvas background for one capture. background: Option, + /// Multi-scalar graphemes the pinned shaper placed, in row-major cell + /// order. Only the pinned backend records; the system backend and live + /// recording never push here. The offline layer drains this for glyph + /// evidence. + shaped: Vec, } impl GridRenderer { @@ -269,8 +275,16 @@ impl GridRenderer { fonts, layout, background, + shaped: Vec::new(), }) } + + /// Drain the shaped-grapheme evidence this renderer recorded. The + /// offline pinned layer calls this once per render for glyph evidence; + /// live recording never calls it and never grows the list. + pub(crate) fn take_shaped_graphemes(&mut self) -> Vec { + std::mem::take(&mut self.shaped) + } } impl FrameRenderer for GridRenderer { @@ -414,7 +428,7 @@ impl FrameRenderer for GridRenderer { } } - let (pixmap, fonts) = (&mut self.pixmap, &mut self.fonts); + let (pixmap, fonts, shaped) = (&mut self.pixmap, &mut self.fonts, &mut self.shaped); for (y, row) in grid.iter().enumerate() { for x in 0..usize::from(cols) { let cell = row.get(x).unwrap_or(&blank); @@ -446,40 +460,30 @@ impl FrameRenderer for GridRenderer { + layout.baseline) * scale; - if unsupported_grapheme(cell.ch.as_str()) { - if fonts.is_pinned() { - return Err(reject_sequence(cell.ch.as_str()).into()); - } - missing.insert(format_glyph_sequence(cell.ch.as_str())); - continue; - } - for character in cell.ch.chars() { - if character.is_whitespace() || is_default_ignorable(character) { - continue; - } - let key = GlyphKey { - character, - bold: paint.bold, - italic: paint.italic, - }; - match fonts.resolve_strict(key)? { - Some(glyph) => draw_glyph( - pixmap, - glyph, - cell_origin_x, - cell_origin_y, - cell_width, - cell_height, - baseline, - paint.fg, - layout.font_size, - scale, - ), - None => { - missing.insert(format!("{character:?} (U+{:04X})", character as u32)); - } - } - } + draw_cell_text( + pixmap, + fonts, + cell.ch.as_str(), + paint.bold, + paint.italic, + paint.fg, + cell_origin_x, + cell_origin_y, + cell_width, + cell_height, + baseline, + layout.font_size, + scale, + span, + ( + grid_x(origin_x, x, scale, layout), + grid_y(origin_y, y, scale, layout), + grid_x(origin_x, x + span, scale, layout), + grid_y(origin_y, y + 1, scale, layout), + ), + &mut missing, + Some(&mut *shaped), + )?; if paint.underline { fill_antialiased_rect( @@ -566,13 +570,97 @@ fn grid_y(origin_y: f32, row: usize, scale: f32, layout: &ResolvedLayout) -> u32 as u32 } +/// The one shaping path for cell rendering and cursor repaint. +/// +/// The system backend keeps its recorded behavior: one scalar-count check +/// and per-character fallback with missing-glyph collection. The pinned +/// backend shapes the grapheme with one face selected for the full +/// sequence and draws the run: verbatim for a single scalar (recorded +/// pixels), positioned and clipped to `clip_rect` for a shaped run or a +/// wide scalar at the grid edge. A shaped cell is pushed to `shaped` for +/// glyph evidence when the caller passes the sink; cursor repaint passes +/// `None` because the cell already recorded. #[allow(clippy::too_many_arguments)] -/// A multi-scalar sequence the pinned renderer stops on: shaping arrives -/// in a later task, so no face is split across the sequence. -fn reject_sequence(sequence: &str) -> PinnedFontError { - PinnedFontError::UnsupportedSequence { - sequence: sequence.to_string(), +fn draw_cell_text( + pixmap: &mut Pixmap, + fonts: &mut FontBackend, + text: &str, + bold: bool, + italic: bool, + color: Rgb, + origin_x: f32, + origin_y: f32, + cell_width: f32, + cell_height: f32, + baseline: f32, + font_size: f32, + scale: f32, + span: usize, + clip_rect: (u32, u32, u32, u32), + missing: &mut BTreeSet, + shaped: Option<&mut Vec>, +) -> anyhow::Result<()> { + if let Some(pinned) = fonts.pinned_mut() { + let run = shaping::shape_pinned_text(pinned, text, bold, italic)?; + let wide_edge = crate::terminal::cell::display_width(text) == 2 && span < 2; + if run.is_shaped() { + if let Some(shaped) = shaped { + shaped.push(text.to_string()); + } + } + let clip = if run.is_shaped() || wide_edge { + let (left, top, right, bottom) = clip_rect; + cell_clip(pixmap.width(), pixmap.height(), left, top, right, bottom) + } else { + None + }; + draw_shaped_run( + pixmap, + &run, + origin_x, + origin_y, + cell_width, + cell_height, + baseline, + color, + font_size, + scale, + clip.as_ref(), + ); + return Ok(()); + } + if unsupported_grapheme(text) { + missing.insert(format_glyph_sequence(text)); + return Ok(()); } + for character in text.chars() { + if character.is_whitespace() || is_default_ignorable(character) { + continue; + } + let key = GlyphKey { + character, + bold, + italic, + }; + match fonts.resolve_strict(key)? { + Some(glyph) => draw_glyph( + pixmap, + glyph, + origin_x, + origin_y, + cell_width, + cell_height, + baseline, + color, + font_size, + scale, + ), + None => { + missing.insert(format!("{character:?} (U+{:04X})", character as u32)); + } + } + } + Ok(()) } #[allow(clippy::too_many_arguments)] @@ -649,44 +737,31 @@ fn draw_cursor( if paint.invisible { return Ok(()); } - if unsupported_grapheme(cell.ch.as_str()) { - if fonts.is_pinned() { - return Err(reject_sequence(cell.ch.as_str()).into()); - } - missing.insert(format_glyph_sequence(cell.ch.as_str())); - return Ok(()); - } let baseline = panel_origin_y + (layout.header_h + layout.content_top + cy as f32 * layout.cell_h + layout.baseline) * scale; - for character in cell.ch.chars() { - if is_default_ignorable(character) { - continue; - } - let key = GlyphKey { - character, - bold: paint.bold, - italic: paint.italic, - }; - match fonts.resolve_strict(key)? { - Some(glyph) => draw_glyph( - pixmap, - glyph, - origin_x, - origin_y, - cell_width, - cell_height, - baseline, - svg::bg_of(cell, colors), - layout.font_size, - scale, - ), - None => { - missing.insert(format!("{character:?} (U+{:04X})", character as u32)); - } - } - } - Ok(()) + // The same shaping path as cell rendering: the repainted glyph keeps + // the shaper positions, only the colors change to the cursor scheme. + // Cursor repaint never records evidence; the cell already did. + draw_cell_text( + pixmap, + fonts, + cell.ch.as_str(), + paint.bold, + paint.italic, + svg::bg_of(cell, colors), + origin_x, + origin_y, + cell_width, + cell_height, + baseline, + layout.font_size, + scale, + span, + (left, top, right, bottom), + missing, + None, + ) } fn draw_shadow( @@ -735,6 +810,32 @@ fn draw_title( let mut x = origin_x + (panel_width * scale - title_width) / 2.0; let baseline = origin_y + (layout.header_h / 2.0 + layout.title_font_size * 0.35) * scale; + if let Some(pinned) = fonts.pinned_mut() { + // Titles draw through the same per-grapheme selection as cells: + // clusters from the shaper's own indices, each shaped with one + // face, keeping its columns so the title width never changes. + // Ligatures stay off so clusters keep their own columns. + let clusters = shaping::title_clusters(pinned, &title)?; + for cluster in &clusters { + let width = cluster.columns as f32 * advance * scale; + let run = shaping::shape_title_cluster(pinned, cluster.text)?; + draw_shaped_run( + pixmap, + &run, + x, + origin_y, + width, + layout.header_h * scale, + baseline, + layout.window_foreground, + layout.title_font_size, + scale, + None, + ); + x += width; + } + return Ok(()); + } for character in title.chars() { let columns = crate::terminal::cell::display_width(&character.to_string()).max(1); let width = columns as f32 * advance * scale; diff --git a/crates/tui-test/src/render/raster/draw.rs b/crates/tui-test/src/render/raster/draw.rs index f9de29da..4da38fca 100644 --- a/crates/tui-test/src/render/raster/draw.rs +++ b/crates/tui-test/src/render/raster/draw.rs @@ -1,6 +1,7 @@ -use tiny_skia::{FillRule, Paint, Path, PathBuilder, Pixmap, Rect, Stroke, Transform}; +use tiny_skia::{FillRule, Mask, Paint, Path, PathBuilder, Pixmap, Rect, Stroke, Transform}; use super::font::GlyphOutline; +use super::shaping::ShapedRun; use crate::profile::Rgb; #[allow(clippy::too_many_arguments)] @@ -275,6 +276,143 @@ pub(super) fn unsupported_grapheme(grapheme: &str) -> bool { grapheme.chars().count() > 1 } +/// Draw one shaped cell run. A verbatim run draws exactly like a legacy +/// single scalar. A shaped run maps its total advance onto the cell width +/// (mirroring the legacy advance stretch) and places each glyph at its +/// shaper offset, clipped to `clip` so ink stays in the permitted region. +/// A missing clip mask draws unclipped. +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_shaped_run( + pixmap: &mut Pixmap, + run: &ShapedRun, + origin_x: f32, + origin_y: f32, + cell_width: f32, + cell_height: f32, + baseline: f32, + color: Rgb, + font_size: f32, + output_scale: f32, + clip: Option<&Mask>, +) { + match run { + ShapedRun::Empty => {} + ShapedRun::Verbatim(outline) => { + // A clipped single (a wide scalar at the grid edge) draws + // through the shaped math with zero offsets: its transform + // matches the legacy advance branch exactly, and the mask + // keeps the ink in the permitted region. Zero-advance and + // powerline outlines keep the legacy branches verbatim. + if clip.is_some() && outline.advance != 0 && !outline.powerline { + let single = ShapedRun::Shaped { + glyphs: vec![super::shaping::ShapedGlyph { + outline: outline.clone(), + dx: 0.0, + dy: 0.0, + advance: f32::from(outline.advance), + }], + advance: f32::from(outline.advance), + }; + draw_shaped_run( + pixmap, + &single, + origin_x, + origin_y, + cell_width, + cell_height, + baseline, + color, + font_size, + output_scale, + clip, + ); + return; + } + draw_glyph( + pixmap, + outline, + origin_x, + origin_y, + cell_width, + cell_height, + baseline, + color, + font_size, + output_scale, + ); + } + ShapedRun::Shaped { glyphs, advance } => { + let Some(first) = glyphs.first() else { + return; + }; + let units = f32::from(first.outline.units_per_em).max(1.0); + let scale_y = font_size * output_scale / units; + // Mirror the legacy advance stretch: the whole run fills the + // cell width. A zero total advance (only zero-width glyphs) + // draws at natural size from the cell origin instead. + let scale_x = if *advance > 0.0 { + cell_width / *advance + } else { + scale_y + }; + let mut pen = 0.0f32; + for glyph in glyphs { + let outline = &glyph.outline; + let shear = if outline.synthetic_italic { + 0.2 * scale_y + } else { + 0.0 + }; + let transform = Transform::from_row( + scale_x, + 0.0, + shear, + -scale_y, + origin_x + pen + glyph.dx * scale_x, + baseline - glyph.dy * scale_y, + ); + let mut paint = Paint::default(); + paint.set_color_rgba8(color.r, color.g, color.b, 255); + pixmap.fill_path(&outline.path, &paint, FillRule::Winding, transform, clip); + if outline.synthetic_bold { + let mut bold = transform; + bold.tx += 0.65 * output_scale; + pixmap.fill_path(&outline.path, &paint, FillRule::Winding, bold, clip); + } + pen += glyph.advance * scale_x; + } + } + } +} + +/// A clip mask covering one pixel rectangle of a pixmap with the given +/// dimensions. Shaped runs paint through it so ink stays in the permitted +/// region (their cell, or the surviving column of a wide cell at the grid +/// edge). Returns `None` for an empty rectangle or when no mask can be +/// built; the caller then draws unclipped. +pub(super) fn cell_clip( + pixmap_width: u32, + pixmap_height: u32, + left: u32, + top: u32, + right: u32, + bottom: u32, +) -> Option { + if left >= right || top >= bottom { + return None; + } + let mut mask = Mask::new(pixmap_width, pixmap_height)?; + let mut builder = PathBuilder::new(); + builder.move_to(left as f32, top as f32); + builder.line_to(right as f32, top as f32); + builder.line_to(right as f32, bottom as f32); + builder.line_to(left as f32, bottom as f32); + builder.close(); + let path = builder.finish()?; + mask.fill_path(&path, FillRule::Winding, true, Transform::identity()); + Some(mask) +} + pub(super) fn format_glyph_sequence(grapheme: &str) -> String { let codepoints = grapheme .chars() diff --git a/crates/tui-test/src/render/raster/font.rs b/crates/tui-test/src/render/raster/font.rs index f3c0c928..067d0dee 100644 --- a/crates/tui-test/src/render/raster/font.rs +++ b/crates/tui-test/src/render/raster/font.rs @@ -98,8 +98,14 @@ impl FontBackend { Ok(Self::Pinned(PinnedFontSystem::new(set)?)) } - pub fn is_pinned(&self) -> bool { - matches!(self, Self::Pinned(_)) + /// The pinned backend for the shaping path, or `None` for system + /// fonts. Lets cell, cursor, and title rendering share one shaping + /// path without holding a match borrow across the draw calls. + pub fn pinned_mut(&mut self) -> Option<&mut PinnedFontSystem> { + match self { + Self::Pinned(pinned) => Some(pinned), + Self::System(_) => None, + } } /// Resolve one glyph. The system side keeps its recorded fallback: a @@ -175,13 +181,7 @@ impl PinnedFontSystem { /// when the set selected it. pub fn resolve(&mut self, key: GlyphKey) -> Result<&GlyphOutline, pinned::PinnedFontError> { if !self.glyphs.contains_key(&key) { - let positions: Vec = self - .faces - .iter() - .enumerate() - .filter(|(_, face)| face.bold == key.bold && face.italic == key.italic) - .map(|(position, _)| position) - .collect(); + let positions = self.style_positions(key.bold, key.italic); if positions.is_empty() { return Err(pinned::PinnedFontError::MissingFace { bold: key.bold, @@ -224,14 +224,86 @@ impl PinnedFontSystem { let face = self.faces.get(position)?; let parsed = ttf_parser::Face::parse(&face.bytes, face.index).ok()?; let glyph_id = parsed.glyph_index(key.character)?; + if glyph_id.0 == 0 { + // A `.notdef` mapping is a missing glyph, not a drawable one: + // the renderer falls back or fails instead of drawing tofu. + return None; + } + self.build_outline(&parsed, glyph_id, key.bold, key.italic, true, key.character) + } + + /// Face positions serving one style slot, in fallback order. The shaper + /// selects one of these per grapheme; an empty result is a missing-face + /// error at the call site. + pub fn style_positions(&self, bold: bool, italic: bool) -> Vec { + self.faces + .iter() + .enumerate() + .filter(|(_, face)| face.bold == bold && face.italic == italic) + .map(|(position, _)| position) + .collect() + } + + /// Whether the face at `position` maps `character` to a real glyph. A + /// missing mapping or a `.notdef` mapping counts as absent. + pub fn has_glyph(&self, position: usize, character: char) -> bool { + let Some(face) = self.faces.get(position) else { + return false; + }; + let Ok(parsed) = ttf_parser::Face::parse(&face.bytes, face.index) else { + return false; + }; + parsed + .glyph_index(character) + .is_some_and(|glyph_id| glyph_id.0 != 0) + } + + /// The raw bytes and collection index of the face at `position`, for the + /// shaper to parse. The bytes are the pinned caller-supplied bytes. + pub fn face_bytes(&self, position: usize) -> Option<(&[u8], u32)> { + self.faces + .get(position) + .map(|face| (face.bytes.as_slice(), face.index)) + } + + /// The outline for a glyph id the shaper already placed. The synthetic + /// style decisions match [`PinnedFontSystem::load`]; a `.notdef` id or + /// an empty outline is `None` so the caller falls back or fails. + pub fn outline_for_glyph( + &self, + position: usize, + glyph_id: ttf_parser::GlyphId, + bold: bool, + italic: bool, + ) -> Option { + if glyph_id.0 == 0 { + return None; + } + let face = self.faces.get(position)?; + let parsed = ttf_parser::Face::parse(&face.bytes, face.index).ok()?; + self.build_outline(&parsed, glyph_id, bold, italic, false, '\0') + } + + /// Build an outline with the style-slot decisions shared by the + /// single-scalar and shaped paths. `powerline` stretch applies only to + /// single-scalar lookups of a separator character; shaped runs keep + /// their shaper positions instead. + fn build_outline( + &self, + parsed: &ttf_parser::Face<'_>, + glyph_id: ttf_parser::GlyphId, + bold: bool, + italic: bool, + powerline: bool, + character: char, + ) -> Option { let mut builder = TinyPathBuilder::default(); let bounds = parsed.outline_glyph(glyph_id, &mut builder)?; let path = builder.finish()?; // Style slots are caller-selected, so only an explicit synthetic // selection emboldens or slants; otherwise the bytes draw verbatim. - let synthetic_bold = - self.synthetic && key.bold && !is_bold_weight(parsed.weight().to_number()); - let synthetic_italic = self.synthetic && key.italic && !is_italic_style(&parsed); + let synthetic_bold = self.synthetic && bold && !is_bold_weight(parsed.weight().to_number()); + let synthetic_italic = self.synthetic && italic && !is_italic_style(parsed); Some(GlyphOutline { path, bounds, @@ -241,7 +313,7 @@ impl PinnedFontSystem { units_per_em: parsed.units_per_em(), synthetic_bold, synthetic_italic, - powerline: nerd_font::is_powerline_separator(key.character), + powerline: powerline && nerd_font::is_powerline_separator(character), }) } } diff --git a/crates/tui-test/src/render/raster/shaping.rs b/crates/tui-test/src/render/raster/shaping.rs new file mode 100644 index 00000000..53914b74 --- /dev/null +++ b/crates/tui-test/src/render/raster/shaping.rs @@ -0,0 +1,420 @@ +//! One shaping path for pinned cell text, cursor repaint, and titles. +//! +//! A cell holds one grapheme: one displayed unit that can contain more than +//! one Unicode scalar value (a base character plus combining marks). The +//! shaper (rustybuzz) positions each grapheme with a single face selected +//! from the pinned set in fallback order: the first face in the requested +//! style slot whose bytes map every scalar to a real glyph. A combining +//! sequence is never divided across unrelated faces. When no single face +//! covers the full grapheme, the result is a typed missing-glyph error that +//! names the scalar, never a silent scalar drop and never a `.notdef` box. +//! +//! Each cell is shaped with its own shaper call, so OpenType features can +//! only merge glyphs inside one cell: ligatures never reach across cell +//! boundaries. Glyph positions stay in font units here; the caller scales +//! them into the cell geometry the snapshot carries, so terminal column +//! widths do not change after shaping. Titles are split into grapheme +//! clusters with the shaper's own cluster indices (no local Unicode tables) +//! and each cluster draws through the same per-grapheme selection; title +//! shaping disables the ligature features so one cluster keeps its own +//! columns, exactly like the per-character layout it replaces. + +use super::draw::is_default_ignorable; +use super::font::{GlyphKey, GlyphOutline, PinnedFontSystem}; +use crate::render::pinned::PinnedFontError; + +/// rustybuzz 0.20 reports advances and offsets in font units: its face +/// carries no scale factor and the fallback shaper stores +/// `glyph_h_advance` verbatim (verified: the `e` advance below equals the +/// `hmtx` advance). There is no 26.6 divisor. +const POSITION_SCALE: f32 = 1.0; + +/// One glyph the shaper placed: its outline, the pen offset in font units +/// (right and up positive) from the grapheme origin, and the pen advance +/// in font units the next glyph starts after. +pub(super) struct ShapedGlyph { + pub outline: GlyphOutline, + pub dx: f32, + pub dy: f32, + pub advance: f32, +} + +/// One cell's worth of shaped text. +pub(super) enum ShapedRun { + /// Nothing to draw: the text is empty or only whitespace and default + /// ignorables. + Empty, + /// One cached outline drawn exactly like a legacy single scalar, so + /// single-scalar cells keep their recorded pixels. + Verbatim(GlyphOutline), + /// Positioned glyphs from one face. `advance` is the total pen advance + /// in font units; the caller maps it onto the cell width. + Shaped { + glyphs: Vec, + advance: f32, + }, +} + +impl ShapedRun { + /// Whether the run went through the shaper (as opposed to the verbatim + /// single-scalar path). The renderer records these graphemes as glyph + /// evidence. + pub fn is_shaped(&self) -> bool { + matches!(self, Self::Shaped { .. }) + } +} + +/// Shape one pinned cell grapheme with one face. +/// +/// Empty, whitespace-only, and ignorable-only text draws nothing. +/// A single scalar resolves through the pinned cache and draws verbatim. +/// A multi-scalar grapheme is shaped with the first face in the requested +/// style slot covering every scalar; a grapheme no single face covers is a +/// missing-glyph error naming the scalar. Cells shape with the default +/// OpenType features. +pub(super) fn shape_pinned_text( + fonts: &mut PinnedFontSystem, + text: &str, + bold: bool, + italic: bool, +) -> Result { + shape_pinned_text_with_features(fonts, text, bold, italic, &[]) +} + +/// Shape one grapheme with explicit OpenType features. Titles pass +/// [`NO_LIGATURES`] so one cluster keeps its own columns; cells use the +/// shaper defaults so the font positions combining marks as designed. +fn shape_pinned_text_with_features( + fonts: &mut PinnedFontSystem, + text: &str, + bold: bool, + italic: bool, + features: &[rustybuzz::Feature], +) -> Result { + let mut scalars = text.chars(); + let Some(first) = scalars.next() else { + return Ok(ShapedRun::Empty); + }; + if scalars.next().is_none() { + if first.is_whitespace() || is_default_ignorable(first) { + return Ok(ShapedRun::Empty); + } + let key = GlyphKey { + character: first, + bold, + italic, + }; + return Ok(ShapedRun::Verbatim(fonts.resolve(key)?.clone())); + } + if text + .chars() + .all(|scalar| scalar.is_whitespace() || is_default_ignorable(scalar)) + { + return Ok(ShapedRun::Empty); + } + let positions = fonts.style_positions(bold, italic); + if positions.is_empty() { + return Err(PinnedFontError::MissingFace { bold, italic }); + } + let mut selected = None; + for &position in &positions { + if text.chars().all(|scalar| fonts.has_glyph(position, scalar)) { + selected = Some(position); + break; + } + } + let Some(position) = selected else { + return Err(missing_glyph_for(text, &positions, fonts, bold, italic)); + }; + shape_with_face(fonts, position, text, bold, italic, features) +} + +/// Name the scalar a missing-glyph error reports. A scalar no candidate +/// face holds is a genuine hole and is named first; otherwise the grapheme +/// is divided across faces, and the first scalar the base face lacks is +/// named. Both keep the full grapheme in the caller's evidence. +fn missing_glyph_for( + text: &str, + positions: &[usize], + fonts: &PinnedFontSystem, + bold: bool, + italic: bool, +) -> PinnedFontError { + for scalar in text.chars() { + if positions + .iter() + .all(|&position| !fonts.has_glyph(position, scalar)) + { + return PinnedFontError::MissingGlyph { + character: scalar, + bold, + italic, + }; + } + } + let base = text.chars().next().unwrap_or('\0'); + let character = positions + .iter() + .find(|&&position| fonts.has_glyph(position, base)) + .and_then(|&position| { + text.chars() + .find(|&scalar| !fonts.has_glyph(position, scalar)) + }) + .unwrap_or(base); + PinnedFontError::MissingGlyph { + character, + bold, + italic, + } +} + +/// Shape `text` with the pinned face at `position`. Every scalar is covered +/// by construction; a `.notdef` id the shaper still emits (for example a +/// variation selector the font maps but cannot shape) is a missing-glyph +/// error naming the scalar at that glyph's cluster, never tofu. Glyphs with +/// no outline (spaces, zero-width marks) keep their advance but draw +/// nothing, so no positioned scalar is silently dropped. +fn shape_with_face( + fonts: &PinnedFontSystem, + position: usize, + text: &str, + bold: bool, + italic: bool, + features: &[rustybuzz::Feature], +) -> Result { + let Some((bytes, index)) = fonts.face_bytes(position) else { + return Err(PinnedFontError::InvalidFace { + detail: format!("pinned face {position} has no bytes for shaping"), + }); + }; + let Some(face) = rustybuzz::Face::from_slice(bytes, index) else { + return Err(PinnedFontError::InvalidFace { + detail: format!("pinned face {index} does not parse for shaping"), + }); + }; + let mut buffer = rustybuzz::UnicodeBuffer::new(); + buffer.push_str(text); + let shaped = rustybuzz::shape(&face, features, buffer); + let infos = shaped.glyph_infos(); + let advances = shaped.glyph_positions(); + let mut glyphs = Vec::new(); + let mut advance = 0.0f32; + for (info, placement) in infos.iter().zip(advances.iter()) { + advance += placement.x_advance as f32 / POSITION_SCALE; + let glyph_id = u16::try_from(info.glyph_id).map_err(|_| PinnedFontError::InvalidFace { + detail: format!("shaper glyph id {} does not fit u16", info.glyph_id), + })?; + if glyph_id == 0 { + return Err(PinnedFontError::MissingGlyph { + character: scalar_at_cluster(text, info.cluster), + bold, + italic, + }); + } + if let Some(outline) = + fonts.outline_for_glyph(position, ttf_parser::GlyphId(glyph_id), bold, italic) + { + glyphs.push(ShapedGlyph { + outline, + dx: placement.x_offset as f32 / POSITION_SCALE, + dy: placement.y_offset as f32 / POSITION_SCALE, + advance: placement.x_advance as f32 / POSITION_SCALE, + }); + } + } + Ok(ShapedRun::Shaped { glyphs, advance }) +} + +/// The scalar at a shaper cluster byte offset, for missing-glyph errors. +/// Falls back to the first scalar when the offset is not a boundary. +fn scalar_at_cluster(text: &str, cluster: u32) -> char { + let offset = (cluster as usize).min(text.len()); + text.get(offset..) + .and_then(|tail| tail.chars().next()) + .or_else(|| text.chars().next()) + .unwrap_or('\0') +} + +/// One title grapheme cluster: a byte range plus the terminal columns it +/// keeps. Columns come from the terminal width function, so shaping never +/// changes the title's total width or centering. +pub(super) struct TitleCluster<'a> { + pub text: &'a str, + pub columns: u32, +} + +/// Split a title into grapheme clusters with the shaper's own cluster +/// indices, shaped once with the first bold-slot face. No local Unicode +/// tables: boundaries come from real shaping. The ligature features stay +/// off here and in [`shape_title_cluster`], so one cluster keeps its own +/// columns exactly like the per-character layout this replaces. +pub(super) fn title_clusters<'a>( + fonts: &PinnedFontSystem, + title: &'a str, +) -> Result>, PinnedFontError> { + if title.is_empty() { + return Ok(Vec::new()); + } + let positions = fonts.style_positions(true, false); + let Some(&first) = positions.first() else { + return Err(PinnedFontError::MissingFace { + bold: true, + italic: false, + }); + }; + let Some((bytes, index)) = fonts.face_bytes(first) else { + return Err(PinnedFontError::InvalidFace { + detail: format!("pinned face {first} has no bytes for shaping"), + }); + }; + let Some(face) = rustybuzz::Face::from_slice(bytes, index) else { + return Err(PinnedFontError::InvalidFace { + detail: format!("pinned face {index} does not parse for shaping"), + }); + }; + let mut buffer = rustybuzz::UnicodeBuffer::new(); + buffer.push_str(title); + let shaped = rustybuzz::shape(&face, &NO_LIGATURES, buffer); + let mut bounds: Vec = shaped + .glyph_infos() + .iter() + .map(|info| (info.cluster as usize).min(title.len())) + .collect(); + bounds.push(0); + bounds.push(title.len()); + bounds.sort_unstable(); + bounds.dedup(); + let mut clusters = Vec::new(); + for pair in bounds.windows(2) { + let (start, end) = (pair[0], pair[1]); + if start >= end { + continue; + } + let Some(text) = title.get(start..end) else { + continue; + }; + if text.is_empty() { + continue; + } + let columns = crate::terminal::cell::display_width(text).max(1) as u32; + clusters.push(TitleCluster { text, columns }); + } + Ok(clusters) +} + +/// Shape one title cluster with the same per-grapheme selection as cells. +/// Titles always draw in the bold slot, matching the title renderer. +pub(super) fn shape_title_cluster( + fonts: &mut PinnedFontSystem, + cluster: &str, +) -> Result { + shape_pinned_text_with_features(fonts, cluster, true, false, &NO_LIGATURES) +} + +/// Probe the raw shaper output for one grapheme: glyph ids, advances, +/// and offsets in font units. The acceptance tests below pin the +/// corpus-relevant values. +#[cfg(test)] +pub(super) fn probe_shape(bytes: &[u8], index: u32, text: &str) -> Vec<(u32, f32, f32, f32)> { + let face = rustybuzz::Face::from_slice(bytes, index).expect("probe face parses"); + let mut buffer = rustybuzz::UnicodeBuffer::new(); + buffer.push_str(text); + let shaped = rustybuzz::shape(&face, &[], buffer); + shaped + .glyph_infos() + .iter() + .zip(shaped.glyph_positions().iter()) + .map(|(info, placement)| { + ( + info.glyph_id, + placement.x_advance as f32 / POSITION_SCALE, + placement.x_offset as f32 / POSITION_SCALE, + placement.y_offset as f32 / POSITION_SCALE, + ) + }) + .collect() +} + +/// Ligature features off for titles: shaping must not merge clusters that +/// the column layout counts separately. +const NO_LIGATURES: [rustybuzz::Feature; 4] = [ + rustybuzz::Feature { + tag: ttf_parser::Tag::from_bytes(b"liga"), + value: 0, + start: 0, + end: u32::MAX, + }, + rustybuzz::Feature { + tag: ttf_parser::Tag::from_bytes(b"clig"), + value: 0, + start: 0, + end: u32::MAX, + }, + rustybuzz::Feature { + tag: ttf_parser::Tag::from_bytes(b"dlig"), + value: 0, + start: 0, + end: u32::MAX, + }, + rustybuzz::Feature { + tag: ttf_parser::Tag::from_bytes(b"calt"), + value: 0, + start: 0, + end: u32::MAX, + }, +]; + +#[cfg(test)] +mod tests { + use super::super::font::PinnedFontSystem; + use super::*; + use crate::render::pinned::{PinnedFace, PinnedFontSet}; + + fn regular_bytes() -> &'static [u8] { + include_bytes!("../../../assets/jetbrains-mono/JetBrainsMono-Regular.ttf") + } + + fn regular_system() -> PinnedFontSystem { + let face = PinnedFace::new(regular_bytes().to_vec(), 0, false, false) + .expect("bundled face parses"); + let set = PinnedFontSet::new(vec![face]).expect("set builds"); + PinnedFontSystem::new(&set).expect("system builds") + } + + /// The shaper composes `e` plus U+0301 through the font's `ccmp` table + /// into one glyph: one face, no `.notdef`, and the advance the font + /// table reports for that glyph. + #[test] + fn e_acute_composes_to_one_glyph() { + let probe = probe_shape(regular_bytes(), 0, "e\u{301}"); + assert_eq!(probe.len(), 1); + let (glyph_id, advance, dx, dy) = probe[0]; + assert_ne!(glyph_id, 0, "no .notdef from the shaper"); + assert_eq!((dx, dy), (0.0, 0.0)); + let parsed = ttf_parser::Face::parse(regular_bytes(), 0).expect("bundled face parses"); + let glyph_id = ttf_parser::GlyphId(u16::try_from(glyph_id).expect("glyph id fits u16")); + assert_eq!( + advance, + f32::from(parsed.glyph_hor_advance(glyph_id).unwrap_or(0)), + "shaped advance is the font advance in font units" + ); + assert!(advance > 0.0); + } + + /// The cell run carries that composed glyph with a positive advance so + /// the caller maps it onto the cell width. + #[test] + fn e_acute_run_positions_composed_glyph() { + let mut system = regular_system(); + let run = shape_pinned_text(&mut system, "e\u{301}", false, false).expect("e-acute shapes"); + match &run { + ShapedRun::Shaped { glyphs, advance } => { + assert_eq!(glyphs.len(), 1); + assert!(*advance > 0.0); + assert_eq!(glyphs[0].advance, *advance); + assert_eq!((glyphs[0].dx, glyphs[0].dy), (0.0, 0.0)); + } + other => panic!("expected a shaped run, got shaped={}", other.is_shaped()), + } + } +} From a9a412bf6961be1053a5a748de338ec25d50ba3d Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Sun, 25 Oct 2026 19:14:09 +0700 Subject: [PATCH 13/17] U05: cover mandatory graphemes with U05-A..U05-G tests Each P02 Unicode case renders with pinned repo bytes (JetBrains Mono plus a vendored Noto Sans JP subset for CJK); cell and cursor repaint keep shaped positions; wide clipping stays in the permitted region; missing glyphs fail without image success; verbatim cells record no shaping. Structural ink assertions plus decoded image evidence; never digest-only acceptance. Signed-off-by: Alexey Zhokhov --- crates/tui-test/tests/graphemes.rs | 677 +++++++++++++++++++++++++++++ 1 file changed, 677 insertions(+) create mode 100644 crates/tui-test/tests/graphemes.rs diff --git a/crates/tui-test/tests/graphemes.rs b/crates/tui-test/tests/graphemes.rs new file mode 100644 index 00000000..a896f0ea --- /dev/null +++ b/crates/tui-test/tests/graphemes.rs @@ -0,0 +1,677 @@ +//! U05: mandatory grapheme rendering through one shaping path. +//! +//! The pinned cell, cursor-repaint, and title paths share a single shaper +//! call per grapheme (rustybuzz), with one face selected per grapheme in +//! fallback order. These tests cover the required U05 behaviors over the +//! P02 corpus: each Unicode case renders (U05-A), cell and cursor repaint +//! shape equally (U05-B), wide-cell clipping stays in the permitted region +//! (U05-C), missing glyphs fail (U05-D), single-scalar cells keep the +//! verbatim path (U05-E), every positive case gives glyph evidence and an +//! image (U05-F), and the named negative errors without image success +//! (U05-G). +//! +//! Font bytes come from the repo assets read as files, so host fonts +//! cannot leak into strict output: +//! +//! - JetBrains Mono static TTFs (OFL, see +//! `assets/jetbrains-mono/SOURCE`): regular, bold, italic, bold-italic. +//! Covers ASCII, box drawing, and the combining marks U+0301 and U+0308. +//! - `assets/noto-sans-jp/NotoSansJP-subset.ttf` (OFL-1.1, see +//! `assets/noto-sans-jp/SOURCE`): a development-time subset keeping only +//! U+6771 (東) plus `.notdef`, for the CJK corpus case. Nothing fetches +//! fonts at test time. +//! - U+1F980 (🦀) is in none of these faces and is the named negative +//! missing-glyph case. + +#![cfg(feature = "recording-raster")] + +use tui_test::profile::Rgb; +use tui_test::render::style::Padding; +use tui_test::terminal::cell::Attrs; +use tui_test::{ + render_snapshot_png, PinnedFace, PinnedFontError, PinnedFontSet, RenderError, SnapshotCell, + SnapshotColor, SnapshotCursor, SnapshotCursorShape, SnapshotMetadata, SnapshotParts, + SnapshotRenderOptions, SnapshotScope, SnapshotUnderline, TerminalSnapshot, +}; + +const CELL_W: u32 = 10; +const CELL_H: u32 = 21; + +const E_ACUTE: &str = "e\u{301}"; +const A_DIAERESIS: &str = "a\u{308}"; + +fn regular_bytes() -> Vec { + include_bytes!("../assets/jetbrains-mono/JetBrainsMono-Regular.ttf").to_vec() +} + +fn bold_bytes() -> Vec { + include_bytes!("../assets/jetbrains-mono/JetBrainsMono-Bold.ttf").to_vec() +} + +fn italic_bytes() -> Vec { + include_bytes!("../assets/jetbrains-mono/JetBrainsMono-Italic.ttf").to_vec() +} + +fn bold_italic_bytes() -> Vec { + include_bytes!("../assets/jetbrains-mono/JetBrainsMono-BoldItalic.ttf").to_vec() +} + +fn cjk_bytes() -> Vec { + include_bytes!("../assets/noto-sans-jp/NotoSansJP-subset.ttf").to_vec() +} + +/// Every style slot the corpus needs, plus the CJK face in the regular +/// slot after JetBrains Mono. No title is drawn, but the pair mirrors the +/// U04 convention of explicit style faces. +fn corpus_set() -> PinnedFontSet { + let face = |bytes: Vec, bold: bool, italic: bool| { + PinnedFace::new(bytes, 0, bold, italic).expect("bundled face parses") + }; + PinnedFontSet::new(vec![ + face(regular_bytes(), false, false), + face(bold_bytes(), true, false), + face(italic_bytes(), false, true), + face(bold_italic_bytes(), true, true), + face(cjk_bytes(), false, false), + ]) + .expect("corpus set builds") +} + +fn resolved_palette() -> [Rgb; 256] { + let profile = tui_test::profile::Profile::default(); + std::array::from_fn(|index| profile.colors.rgb(index as u8)) +} + +fn cell(text: &str, width: u16, attrs: Attrs) -> SnapshotCell { + SnapshotCell::new( + text.to_string(), + width, + SnapshotColor::Default, + SnapshotColor::Default, + attrs, + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("test cell is valid") +} + +fn plain(text: &str, width: u16) -> SnapshotCell { + cell(text, width, Attrs::empty()) +} + +fn blank() -> SnapshotCell { + plain(" ", 1) +} + +fn continuation() -> SnapshotCell { + SnapshotCell::new( + String::new(), + 0, + SnapshotColor::Default, + SnapshotColor::Default, + Attrs::empty(), + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("continuation cell is valid") +} + +fn hidden_cursor() -> SnapshotCursor { + SnapshotCursor::new( + 0, + 0, + false, + true, + SnapshotCursorShape::Block, + Rgb::new(9, 9, 9), + Some(true), + ) +} + +fn block_cursor_at(col: u32, row: u32) -> SnapshotCursor { + SnapshotCursor::new( + col, + row, + true, + true, + SnapshotCursorShape::Block, + Rgb::new(9, 9, 9), + Some(true), + ) +} + +fn snapshot_with_cells( + cols: u32, + rows: u32, + cells: Vec, + cursor: SnapshotCursor, +) -> TerminalSnapshot { + assert_eq!(cells.len() as u32, cols * rows); + TerminalSnapshot::try_new(SnapshotParts { + cols, + rows, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells, + palette: resolved_palette(), + default_foreground: Rgb::new(192, 192, 192), + default_background: Rgb::new(0, 0, 0), + default_cursor: Rgb::new(9, 9, 9), + cursor, + title: None, + metadata: SnapshotMetadata::default(), + }) + .expect("test snapshot is valid") +} + +fn strict_options(set: &PinnedFontSet) -> SnapshotRenderOptions { + SnapshotRenderOptions::default() + .with_canvas_padding(Padding::Uniform(0)) + .with_content_padding(Padding::Uniform(0)) + .with_title_bar(false) + .with_title_controls(false) + .with_shadow(false) + .with_border_width(0.0) + .with_border_radius(0.0) + .with_background(Some(tui_test::CaptureBackground::Color(Rgb::new(0, 0, 0)))) + .with_pinned_fonts(set.clone()) +} + +fn decode_png(bytes: &[u8]) -> (u32, u32, Vec) { + let decoder = png::Decoder::new(bytes); + let mut reader = decoder.read_info().expect("PNG header decodes"); + let mut pixels = vec![0; reader.output_buffer_size()]; + let info = reader.next_frame(&mut pixels).expect("PNG frame decodes"); + assert_eq!(info.color_type, png::ColorType::Rgba); + assert_eq!(info.bit_depth, png::BitDepth::Eight); + (info.width, info.height, pixels) +} + +/// Pixel offsets with a color different from `background`. +fn ink_offsets(pixels: &[u8], image_width: u32, background: [u8; 4]) -> Vec<(u32, u32)> { + pixels + .as_chunks::<4>() + .0 + .iter() + .enumerate() + .filter(|(_, pixel)| pixel != &&background) + .map(|(index, _)| { + let index = index as u32; + (index % image_width, index / image_width) + }) + .collect() +} + +/// Pixel offsets of ink inside one cell rect, relative to the rect origin. +fn cell_ink( + pixels: &[u8], + image_width: u32, + background: [u8; 4], + left: u32, + top: u32, + width: u32, + height: u32, +) -> Vec<(u32, u32)> { + ink_offsets(pixels, image_width, background) + .into_iter() + .filter(|(x, y)| *x >= left && *x < left + width && *y >= top && *y < top + height) + .map(|(x, y)| (x - left, y - top)) + .collect() +} + +const BLACK: [u8; 4] = [0, 0, 0, 255]; +const CURSOR_FILL: [u8; 4] = [9, 9, 9, 255]; + +/// Render one row of `cols` cells with a hidden cursor. With zero chrome +/// the image is exactly the grid: `cols * CELL_W` by `rows * CELL_H`. +fn render_row(cells: Vec, set: &PinnedFontSet) -> (u32, u32, Vec) { + let cols = cells.len() as u32; + let snapshot = snapshot_with_cells(cols, 1, cells, hidden_cursor()); + let rendered = + render_snapshot_png(&snapshot, &strict_options(set)).expect("corpus row renders"); + assert!(rendered.evidence().width() > 0); + let (width, height, pixels) = decode_png(rendered.png()); + assert_eq!((width, height), (cols * CELL_W, CELL_H)); + assert_eq!( + (rendered.evidence().width(), rendered.evidence().height()), + (width, height) + ); + (width, height, pixels) +} + +/// U05-A: ASCII text renders through the verbatim path with ink in its +/// cell and no shaped-grapheme evidence. +#[test] +fn u05_a_ascii_renders() { + assert_eq!(E_ACUTE.chars().count(), 2); + assert_eq!(A_DIAERESIS.chars().count(), 2); + let set = corpus_set(); + let (width, _, pixels) = render_row(vec![plain("a", 1), blank(), blank()], &set); + let ink = cell_ink(&pixels, width, BLACK, 0, 0, CELL_W, CELL_H); + assert!(!ink.is_empty(), "ASCII cell holds ink"); + // Only the first cell holds text: all image ink is its ink. + assert_eq!(ink.len(), ink_offsets(&pixels, width, BLACK).len()); +} + +/// U05-A: box-drawing characters render through the verbatim path. +#[test] +fn u05_a_box_drawing_renders() { + let set = corpus_set(); + let snapshot = snapshot_with_cells( + 3, + 1, + vec![plain("─", 1), plain("│", 1), plain("┌", 1)], + hidden_cursor(), + ); + let rendered = + render_snapshot_png(&snapshot, &strict_options(&set)).expect("box drawing renders"); + assert!(rendered.evidence().shaped_graphemes().is_empty()); + assert!(!rendered.png().is_empty()); + let (width, _, pixels) = decode_png(rendered.png()); + for (index, _) in ["─", "│", "┌"].iter().enumerate() { + let left = index as u32 * CELL_W; + let ink = cell_ink(&pixels, width, BLACK, left, 0, CELL_W, CELL_H); + assert!(!ink.is_empty(), "box cell {index} holds ink"); + } +} + +/// U05-A: `e` plus U+0301 shapes with one face and draws its mark: the +/// image differs from the bare base and all ink stays in the cell. +#[test] +fn u05_a_e_acute_renders() { + let set = corpus_set(); + let snapshot = snapshot_with_cells(2, 1, vec![plain(E_ACUTE, 1), blank()], hidden_cursor()); + let rendered = + render_snapshot_png(&snapshot, &strict_options(&set)).expect("e plus acute renders"); + assert_eq!( + rendered.evidence().shaped_graphemes(), + &[E_ACUTE.to_string()] + ); + let (width, _, pixels) = decode_png(rendered.png()); + let ink = cell_ink(&pixels, width, BLACK, 0, 0, CELL_W, CELL_H); + assert!(!ink.is_empty(), "combined cell holds ink"); + assert_eq!( + ink.len(), + ink_offsets(&pixels, width, BLACK).len(), + "shaped ink stays in its cell" + ); + // The mark is really drawn: dropping it would give the base image. + let (_, _, base_pixels) = render_row(vec![plain("e", 1), blank()], &set); + assert_ne!(pixels, base_pixels, "combining mark changes the image"); +} + +/// U05-A: `a` plus U+0308 in the italic slot shapes and keeps its ink in +/// the cell. +#[test] +fn u05_a_a_diaeresis_italic_renders() { + let set = corpus_set(); + let snapshot = snapshot_with_cells( + 2, + 1, + vec![cell(A_DIAERESIS, 1, Attrs::ITALIC), blank()], + hidden_cursor(), + ); + let rendered = render_snapshot_png(&snapshot, &strict_options(&set)) + .expect("italic a plus diaeresis renders"); + assert_eq!( + rendered.evidence().shaped_graphemes(), + &[A_DIAERESIS.to_string()] + ); + let (width, _, pixels) = decode_png(rendered.png()); + let ink = cell_ink(&pixels, width, BLACK, 0, 0, CELL_W, CELL_H); + assert!(!ink.is_empty(), "italic combined cell holds ink"); + assert_eq!( + ink.len(), + ink_offsets(&pixels, width, BLACK).len(), + "shaped ink stays in its cell" + ); + let base = snapshot_with_cells( + 2, + 1, + vec![cell("a", 1, Attrs::ITALIC), blank()], + hidden_cursor(), + ); + let base = render_snapshot_png(&base, &strict_options(&set)).expect("italic base renders"); + let (_, _, base_pixels) = decode_png(base.png()); + assert_ne!(pixels, base_pixels, "combining mark changes the image"); +} + +/// U05-A: the two-column character 東 with its continuation cell renders +/// from the CJK face across both columns. +#[test] +fn u05_a_cjk_with_continuation_renders() { + let set = corpus_set(); + let snapshot = snapshot_with_cells( + 3, + 1, + vec![plain("東", 2), continuation(), blank()], + hidden_cursor(), + ); + let rendered = render_snapshot_png(&snapshot, &strict_options(&set)) + .expect("CJK cell with continuation renders"); + assert!(rendered.evidence().shaped_graphemes().is_empty()); + let (width, _, pixels) = decode_png(rendered.png()); + let ink = cell_ink(&pixels, width, BLACK, 0, 0, 2 * CELL_W, CELL_H); + assert!(!ink.is_empty(), "wide cell holds ink"); + assert_eq!( + ink.len(), + ink_offsets(&pixels, width, BLACK).len(), + "wide ink stays in its two columns" + ); +} + +/// U05-A: bold, italic, and bold-italic text shape in their style slots. +#[test] +fn u05_a_styles_render() { + let set = corpus_set(); + let snapshot = snapshot_with_cells( + 3, + 1, + vec![ + cell(E_ACUTE, 1, Attrs::BOLD), + cell(A_DIAERESIS, 1, Attrs::ITALIC), + cell("─", 1, Attrs::BOLD | Attrs::ITALIC), + ], + hidden_cursor(), + ); + let rendered = + render_snapshot_png(&snapshot, &strict_options(&set)).expect("styled cells render"); + assert_eq!( + rendered.evidence().shaped_graphemes(), + &[E_ACUTE.to_string(), A_DIAERESIS.to_string()] + ); + let (width, _, pixels) = decode_png(rendered.png()); + for index in 0..3 { + let ink = cell_ink(&pixels, width, BLACK, index * CELL_W, 0, CELL_W, CELL_H); + assert!(!ink.is_empty(), "styled cell {index} holds ink"); + } +} + +/// U05-A: a wide character at the last column boundary renders in its one +/// surviving column. +#[test] +fn u05_a_wide_at_last_column_renders() { + let set = corpus_set(); + let snapshot = snapshot_with_cells( + 3, + 1, + vec![blank(), blank(), plain("東", 1)], + hidden_cursor(), + ); + let rendered = render_snapshot_png(&snapshot, &strict_options(&set)) + .expect("wide char at last column renders"); + let (width, height, pixels) = decode_png(rendered.png()); + assert_eq!((width, height), (3 * CELL_W, CELL_H)); + let ink = cell_ink(&pixels, width, BLACK, 2 * CELL_W, 0, CELL_W, CELL_H); + assert!(!ink.is_empty(), "edge wide cell holds ink"); +} + +/// U05-A: a cursor over a combined cell and over a wide cell renders. +#[test] +fn u05_a_cursor_over_combined_and_wide_renders() { + let set = corpus_set(); + for (cells, cursor) in [ + ( + vec![plain(E_ACUTE, 1), blank(), blank()], + block_cursor_at(0, 0), + ), + ( + vec![plain("東", 2), continuation(), blank()], + block_cursor_at(0, 0), + ), + ] { + let snapshot = snapshot_with_cells(3, 1, cells, cursor); + let rendered = render_snapshot_png(&snapshot, &strict_options(&set)) + .expect("cursor over cell renders"); + assert!(rendered.evidence().cursor_drawn()); + assert!(!rendered.png().is_empty()); + } +} + +/// U05-B: cell rendering and cursor repaint use equal shaping results. +/// The ink positions relative to the cell origin agree between the plain +/// cell and the block-cursor repaint; only the colors change. +#[test] +fn u05_b_cell_and_cursor_repaint_shape_equally() { + let set = corpus_set(); + for (cells, cursor_col) in [ + (vec![plain(E_ACUTE, 1), blank(), blank()], 0), + (vec![blank(), plain("東", 2), continuation()], 1), + ] { + let plain_snapshot = snapshot_with_cells(3, 1, cells.clone(), hidden_cursor()); + let plain_rendered = + render_snapshot_png(&plain_snapshot, &strict_options(&set)).expect("plain renders"); + let (width, _, plain_pixels) = decode_png(plain_rendered.png()); + let cursor_snapshot = snapshot_with_cells(3, 1, cells, block_cursor_at(cursor_col, 0)); + let cursor_rendered = render_snapshot_png(&cursor_snapshot, &strict_options(&set)) + .expect("cursor repaint renders"); + let (_, _, cursor_pixels) = decode_png(cursor_rendered.png()); + let span = if cursor_col == 1 { 2 } else { 1 }; + let left = cursor_col * CELL_W; + let plain_ink = cell_ink(&plain_pixels, width, BLACK, left, 0, span * CELL_W, CELL_H); + let cursor_ink = cell_ink( + &cursor_pixels, + width, + CURSOR_FILL, + left, + 0, + span * CELL_W, + CELL_H, + ); + assert!(!plain_ink.is_empty(), "plain cell holds ink"); + // Same coverage up to blend quantization: the repaint draws black + // on the cursor fill while the plain cell draws gray on black, so + // the faintest fringe pixels can round to the fill color and drop + // out of the binary ink set. Every repaint pixel is a plain pixel, + // and every plain pixel neighbors a repaint pixel. + for pixel in &cursor_ink { + assert!( + plain_ink.contains(pixel), + "repaint pixel {pixel:?} is a plain pixel" + ); + } + for pixel in &plain_ink { + let (x, y) = *pixel; + let near = cursor_ink + .iter() + .any(|(cx, cy)| cx.abs_diff(x) <= 1 && cy.abs_diff(y) <= 1); + assert!(near, "plain pixel {pixel:?} neighbors a repaint pixel"); + } + assert_eq!( + plain_rendered.evidence().shaped_graphemes(), + cursor_rendered.evidence().shaped_graphemes(), + "both renders record the same shaping" + ); + } +} + +/// U05-C: wide-cell clipping paints only in the permitted region. With a +/// single non-space cell, every non-background pixel of the whole image +/// must lie inside that cell's rect. +#[test] +fn u05_c_wide_clipping_stays_in_permitted_region() { + let set = corpus_set(); + // Wide char squeezed into the last column: only that column is permitted. + let edge = snapshot_with_cells( + 3, + 1, + vec![blank(), blank(), plain("東", 1)], + hidden_cursor(), + ); + let rendered = + render_snapshot_png(&edge, &strict_options(&set)).expect("edge wide cell renders"); + let (width, _, pixels) = decode_png(rendered.png()); + let all = ink_offsets(&pixels, width, BLACK); + assert!(!all.is_empty()); + for (x, y) in &all { + assert!( + *x >= 2 * CELL_W && *x < 3 * CELL_W && *y < CELL_H, + "edge ink ({x}, {y}) stays in the last column" + ); + } + // Full wide cell with continuation: only its two columns are permitted. + let full = snapshot_with_cells( + 3, + 1, + vec![plain("東", 2), continuation(), blank()], + hidden_cursor(), + ); + let rendered = + render_snapshot_png(&full, &strict_options(&set)).expect("full wide cell renders"); + let (width, _, pixels) = decode_png(rendered.png()); + let all = ink_offsets(&pixels, width, BLACK); + assert!(!all.is_empty()); + for (x, y) in &all { + assert!( + *x < 2 * CELL_W && *y < CELL_H, + "wide ink ({x}, {y}) stays in its two columns" + ); + } +} + +/// U05-D: missing glyphs cause an error; they never give a success +/// result. Covers a missing single scalar, a missing scalar inside a +/// combining sequence, and a sequence divided across faces. +#[test] +fn u05_d_missing_glyphs_fail() { + let set = corpus_set(); + // 🦀 is in no pinned face. + let crab = snapshot_with_cells(1, 1, vec![plain("🦀", 1)], hidden_cursor()); + let error = render_snapshot_png(&crab, &strict_options(&set)).expect_err("crab is missing"); + assert!( + matches!( + error, + RenderError::PinnedFont(PinnedFontError::MissingGlyph { + character: '🦀', + .. + }) + ), + "crab fails as missing glyph, got {error}" + ); + // Combining sequence with a scalar no face holds. + let bad_sequence = snapshot_with_cells(1, 1, vec![plain("e\u{10FFFF}", 1)], hidden_cursor()); + let error = render_snapshot_png(&bad_sequence, &strict_options(&set)) + .expect_err("bad sequence is missing"); + assert!( + matches!( + error, + RenderError::PinnedFont(PinnedFontError::MissingGlyph { .. }) + ), + "bad sequence fails as missing glyph, got {error}" + ); + // `a` lives in JetBrains Mono, 東 lives in the CJK face: no single + // face covers the grapheme, so shaping must not divide it. + let divided = snapshot_with_cells( + 3, + 1, + vec![plain("a東", 3), blank(), blank()], + hidden_cursor(), + ); + let error = render_snapshot_png(÷d, &strict_options(&set)) + .expect_err("divided sequence is missing"); + assert!( + matches!( + error, + RenderError::PinnedFont(PinnedFontError::MissingGlyph { + character: '東', + .. + }) + ), + "divided sequence names 東, got {error}" + ); +} + +/// U05-E: single-scalar cells keep the verbatim path: ASCII, box drawing, +/// styled text, and CJK record no shaped graphemes and render +/// deterministically. +#[test] +fn u05_e_verbatim_cells_keep_recorded_behavior() { + let set = corpus_set(); + let snapshot = snapshot_with_cells( + 4, + 1, + vec![ + plain("a", 1), + cell("─", 1, Attrs::BOLD | Attrs::ITALIC), + plain("東", 2), + continuation(), + ], + hidden_cursor(), + ); + let options = strict_options(&set); + let first = render_snapshot_png(&snapshot, &options).expect("verbatim renders"); + let second = render_snapshot_png(&snapshot, &options).expect("verbatim renders again"); + assert!(first.evidence().shaped_graphemes().is_empty()); + assert_eq!(decode_png(first.png()), decode_png(second.png())); +} + +/// U05-F: each positive corpus case gives glyph evidence and an image: +/// the recorded shaped graphemes plus PNG bytes decoding to the evidence +/// dimensions. +#[test] +fn u05_f_positive_cases_give_glyph_evidence_and_image() { + let set = corpus_set(); + let cases: Vec<(&str, Vec, Vec)> = vec![ + ("ascii", vec![plain("a", 1)], vec![]), + ("box", vec![plain("─", 1)], vec![]), + ( + "e-acute", + vec![plain(E_ACUTE, 1)], + vec![E_ACUTE.to_string()], + ), + ( + "a-diaeresis", + vec![cell(A_DIAERESIS, 1, Attrs::ITALIC)], + vec![A_DIAERESIS.to_string()], + ), + ("cjk", vec![plain("東", 2), continuation()], vec![]), + ( + "bold-combined", + vec![cell(E_ACUTE, 1, Attrs::BOLD)], + vec![E_ACUTE.to_string()], + ), + ]; + for (name, cells, expected) in cases { + let cols = cells.len() as u32; + let snapshot = snapshot_with_cells(cols, 1, cells, hidden_cursor()); + let rendered = render_snapshot_png(&snapshot, &strict_options(&set)) + .unwrap_or_else(|error| panic!("positive case {name} renders: {error}")); + assert_eq!( + rendered.evidence().shaped_graphemes(), + &expected, + "positive case {name} records glyph evidence" + ); + assert!( + !rendered.png().is_empty(), + "positive case {name} gives an image" + ); + let (width, height, _) = decode_png(rendered.png()); + assert_eq!( + (rendered.evidence().width(), rendered.evidence().height()), + (width, height), + "positive case {name} image matches its evidence" + ); + } +} + +/// U05-G: the named negative missing-glyph case returns an error without +/// an image success result. +#[test] +fn u05_g_missing_glyph_errors_without_image_success() { + let set = corpus_set(); + let snapshot = snapshot_with_cells(1, 1, vec![plain("🦀", 1)], hidden_cursor()); + let result = render_snapshot_png(&snapshot, &strict_options(&set)); + assert!( + matches!( + result, + Err(RenderError::PinnedFont( + PinnedFontError::MissingGlyph { .. } + )) + ), + "crab never renders successfully" + ); +} From edf2b525d6f8a5710cd56440545437ff4f146727 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Mon, 26 Oct 2026 03:33:24 +0700 Subject: [PATCH 14/17] U05: share grapheme shaping with the system font backend Ordinary offline rendering, live screenshots, and recordings rejected every multi-scalar grapheme (scalar-count check) even when a catalog face covered it, while pinned rendering shaped the same sequence. Cells and cursor repaint now share one shaping path on both backends with distinct selection policies: pinned keeps its set order and typed errors; the system backend shapes base-plus-combining-mark sequences with the first catalog candidate covering the full grapheme (host fallback order) and collects an undrawable sequence for the end-of-render failure. Single scalars keep the legacy verbatim path, so recorded pixels do not change; non-combining sequences (ZWJ emoji, regional indicators) keep the recorded report instead of a misrender, and shaped evidence stays pinned-only. unicode-properties 0.1.4 for General Category Mark detection: MIT/Apache-2.0, edition 2021, no_std, dependency-free, no declared rust-version; already in the tree via rustybuzz 0.20.1, so no new crate enters the lock. Optional under recording-raster; the no-default-features build is unchanged. Tests: ordinary offline, live screenshot, and recording coverage for e plus U+0301 and a plus U+0308 (ink in the cell, mark changes the image, live cell holds the two-scalar grapheme, recording exports animation frames). Signed-off-by: Alexey Zhokhov --- Cargo.lock | 1 + Cargo.toml | 1 + crates/tui-test/Cargo.toml | 3 +- crates/tui-test/src/render/offline.rs | 4 +- crates/tui-test/src/render/raster.rs | 74 ++++- crates/tui-test/src/render/raster/draw.rs | 29 +- crates/tui-test/src/render/raster/font.rs | 77 +++++ crates/tui-test/src/render/raster/shaping.rs | 104 +++++- crates/tui-test/tests/system_graphemes.rs | 329 +++++++++++++++++++ 9 files changed, 603 insertions(+), 19 deletions(-) create mode 100644 crates/tui-test/tests/system_graphemes.rs diff --git a/Cargo.lock b/Cargo.lock index b6c97fb6..5603b58c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1900,6 +1900,7 @@ dependencies = [ "tiny-skia", "toml", "ttf-parser", + "unicode-properties", "unicode-width", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index ba6bec55..9133f6e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ serde_json = "1.0.150" sha2 = "0.10.9" toml = "0.9" tui-test = { package = "tui-test-rs", path = "crates/tui-test" } +unicode-properties = "0.1.4" unicode-width = "0.2.2" vte = "0.15" tiny-skia = { version = "0.11.4", default-features = false, features = ["std"] } diff --git a/crates/tui-test/Cargo.toml b/crates/tui-test/Cargo.toml index d2d7a966..6633b1a0 100644 --- a/crates/tui-test/Cargo.toml +++ b/crates/tui-test/Cargo.toml @@ -17,7 +17,7 @@ default = [] ghostty = ["dep:ghostty-vt"] rio = ["dep:rio-vt"] xtermjs = ["dep:rquickjs"] -recording-raster = ["dep:fontdb", "dep:gif", "dep:png", "dep:tiny-skia", "dep:rustybuzz"] +recording-raster = ["dep:fontdb", "dep:gif", "dep:png", "dep:tiny-skia", "dep:rustybuzz", "dep:unicode-properties"] recording-font-jetbrains-mono = ["recording-raster"] recording-font-jetbrains-mono-styles = ["recording-font-jetbrains-mono"] recording-font-jetbrains-mono-full = ["recording-font-jetbrains-mono-styles"] @@ -45,6 +45,7 @@ sha2.workspace = true toml.workspace = true tiny-skia = { workspace = true, optional = true } ttf-parser.workspace = true +unicode-properties = { workspace = true, optional = true } unicode-width.workspace = true [target.'cfg(unix)'.dependencies] diff --git a/crates/tui-test/src/render/offline.rs b/crates/tui-test/src/render/offline.rs index ff9a022c..bb460e60 100644 --- a/crates/tui-test/src/render/offline.rs +++ b/crates/tui-test/src/render/offline.rs @@ -731,7 +731,9 @@ impl RenderedPng { /// not drawn, and the evidence records that. /// /// Without pinned fonts the rasterizer keeps its recorded system-font -/// behavior. With pinned fonts it loads only the specified bytes and +/// fallback: multi-scalar graphemes shape with the first catalog face +/// covering the full sequence, and a missing scalar fails the render. +/// With pinned fonts it loads only the specified bytes and /// reports them in the render contract; a missing style face or glyph is a /// [`RenderError::PinnedFont`] error with no host fallback. #[cfg(feature = "recording-raster")] diff --git a/crates/tui-test/src/render/raster.rs b/crates/tui-test/src/render/raster.rs index bc171d40..1d021e33 100644 --- a/crates/tui-test/src/render/raster.rs +++ b/crates/tui-test/src/render/raster.rs @@ -16,9 +16,10 @@ mod font; mod shaping; use draw::{ - cell_clip, draw_glyph, draw_shaped_run, fill_antialiased_rect, fill_circle, fill_pixel_rect, - fill_rounded_rect, fill_rounded_rect_alpha, fill_top_rounded_rect, format_glyph_sequence, - is_default_ignorable, stroke_rounded_rect, unpremultiply, unsupported_grapheme, + cell_clip, combining_sequence, draw_glyph, draw_shaped_run, fill_antialiased_rect, fill_circle, + fill_pixel_rect, fill_rounded_rect, fill_rounded_rect_alpha, fill_top_rounded_rect, + format_glyph_sequence, is_default_ignorable, multi_scalar_grapheme, stroke_rounded_rect, + unpremultiply, }; use font::{FontBackend, GlyphKey}; @@ -570,16 +571,22 @@ fn grid_y(origin_y: f32, row: usize, scale: f32, layout: &ResolvedLayout) -> u32 as u32 } -/// The one shaping path for cell rendering and cursor repaint. +/// The one shaping path for cell rendering and cursor repaint, shared by +/// both font backends. /// -/// The system backend keeps its recorded behavior: one scalar-count check -/// and per-character fallback with missing-glyph collection. The pinned -/// backend shapes the grapheme with one face selected for the full -/// sequence and draws the run: verbatim for a single scalar (recorded -/// pixels), positioned and clipped to `clip_rect` for a shaped run or a -/// wide scalar at the grid edge. A shaped cell is pushed to `shaped` for -/// glyph evidence when the caller passes the sink; cursor repaint passes -/// `None` because the cell already recorded. +/// Face selection stays per-backend: the pinned backend shapes with the +/// first pinned face covering the full grapheme and reports a missing +/// scalar as a typed error; the system backend shapes with the first +/// catalog candidate covering the full grapheme (host fallback order) and +/// collects an undrawable sequence for the end-of-render failure. Only +/// base-plus-combining-mark sequences shape; other multi-scalar sequences +/// keep the recorded report instead of a misrender. A shaped run draws +/// positioned and clipped to `clip_rect`; a single scalar keeps the +/// legacy verbatim path on both backends so recorded pixels do not change. +/// A shaped cell is pushed to `shaped` for glyph evidence when the caller +/// passes the sink; cursor repaint passes `None` because the cell already +/// recorded. Only the pinned backend records: the system backend draws the +/// same run geometry without recording. #[allow(clippy::too_many_arguments)] fn draw_cell_text( pixmap: &mut Pixmap, @@ -600,9 +607,9 @@ fn draw_cell_text( missing: &mut BTreeSet, shaped: Option<&mut Vec>, ) -> anyhow::Result<()> { + let wide_edge = crate::terminal::cell::display_width(text) == 2 && span < 2; if let Some(pinned) = fonts.pinned_mut() { let run = shaping::shape_pinned_text(pinned, text, bold, italic)?; - let wide_edge = crate::terminal::cell::display_width(text) == 2 && span < 2; if run.is_shaped() { if let Some(shaped) = shaped { shaped.push(text.to_string()); @@ -629,7 +636,46 @@ fn draw_cell_text( ); return Ok(()); } - if unsupported_grapheme(text) { + let FontBackend::System(system) = fonts else { + unreachable!("pinned backend returns above"); + }; + if multi_scalar_grapheme(text) { + if combining_sequence(text) { + // Shared shaping with the system selection policy: the first + // catalog face covering the full grapheme wins, and a grapheme + // no single face can draw joins the missing-glyph collection + // for the end-of-render failure. Shaped evidence stays + // pinned-only. + match shaping::shape_system_text(system, text, bold, italic) { + Ok(run) => { + let clip = if run.is_shaped() || wide_edge { + let (left, top, right, bottom) = clip_rect; + cell_clip(pixmap.width(), pixmap.height(), left, top, right, bottom) + } else { + None + }; + draw_shaped_run( + pixmap, + &run, + origin_x, + origin_y, + cell_width, + cell_height, + baseline, + color, + font_size, + scale, + clip.as_ref(), + ); + } + Err(entry) => { + missing.insert(entry); + } + } + return Ok(()); + } + // Not a combining sequence (ZWJ emoji, regional indicators, ...): + // keep the recorded report instead of a misrender. missing.insert(format_glyph_sequence(text)); return Ok(()); } diff --git a/crates/tui-test/src/render/raster/draw.rs b/crates/tui-test/src/render/raster/draw.rs index 4da38fca..b8fbfa46 100644 --- a/crates/tui-test/src/render/raster/draw.rs +++ b/crates/tui-test/src/render/raster/draw.rs @@ -272,10 +272,35 @@ pub(super) fn is_default_ignorable(character: char) -> bool { ) } -pub(super) fn unsupported_grapheme(grapheme: &str) -> bool { +/// Whether a cell grapheme holds more than one Unicode scalar value (a base +/// character plus combining marks). Both font backends shape these with one +/// face covering the full sequence; single scalars keep the legacy verbatim +/// path so recorded pixels do not change. +pub(super) fn multi_scalar_grapheme(grapheme: &str) -> bool { grapheme.chars().count() > 1 } +/// Whether a multi-scalar grapheme is a base character plus combining +/// marks: the sequences the shaper positions on both backends. Anything +/// else multi-scalar (ZWJ emoji, regional indicators, ...) keeps the +/// legacy report instead of a misrender. Mark detection uses the +/// maintained `unicode-properties` General Category tables the shaper +/// itself is built on — no local Unicode tables. +pub(super) fn combining_sequence(grapheme: &str) -> bool { + let mut scalars = grapheme.chars(); + if scalars.next().is_none() { + return false; + } + scalars.all(is_combining_mark) +} + +/// Whether a scalar is a combining mark (General Category M: spacing, +/// nonspacing, or enclosing). +fn is_combining_mark(character: char) -> bool { + use unicode_properties::{GeneralCategoryGroup, UnicodeGeneralCategory}; + character.general_category_group() == GeneralCategoryGroup::Mark +} + /// Draw one shaped cell run. A verbatim run draws exactly like a legacy /// single scalar. A shaped run maps its total advance onto the cell width /// (mirroring the legacy advance stretch) and places each glyph at its @@ -413,6 +438,8 @@ pub(super) fn cell_clip( Some(mask) } +/// Name a multi-scalar grapheme the renderer cannot draw: the sequence +/// plus every codepoint, so no scalar is silently dropped from the report. pub(super) fn format_glyph_sequence(grapheme: &str) -> String { let codepoints = grapheme .chars() diff --git a/crates/tui-test/src/render/raster/font.rs b/crates/tui-test/src/render/raster/font.rs index 067d0dee..a637a50d 100644 --- a/crates/tui-test/src/render/raster/font.rs +++ b/crates/tui-test/src/render/raster/font.rs @@ -46,6 +46,83 @@ impl FontSystem { self.glyphs.get(&key).and_then(Option::as_ref) } + /// Candidate faces for a style slot in catalog rank order: the recorded + /// system fallback policy. The system shaper tries these in order and + /// shapes a grapheme with the first covering every scalar; the pinned + /// backend never consults the catalog. + pub fn style_candidates( + &self, + bold: bool, + italic: bool, + character: char, + ) -> Vec { + self.catalog.candidates(bold, italic, character) + } + + /// Whether face `id` maps `character` to a real (non-`.notdef`) glyph. + pub fn face_has_glyph(&self, id: fontdb::ID, character: char) -> bool { + self.catalog + .database + .with_face_data(id, |data, index| { + Face::parse(data, index).ok().is_some_and(|face| { + face.glyph_index(character) + .is_some_and(|glyph_id| glyph_id.0 != 0) + }) + }) + .unwrap_or(false) + } + + /// Run a closure with the raw bytes and collection index of face `id`, + /// for the system shaper to parse. Returns `None` when the face has no + /// readable data. + pub fn with_face_data( + &self, + id: fontdb::ID, + fill: impl FnOnce(&[u8], u32) -> T, + ) -> Option { + self.catalog.database.with_face_data(id, fill) + } + + /// The outline for a glyph id the shaper placed on face `id`. The + /// synthetic style decisions match [`FontSystem::load`]; a `.notdef` id + /// or an empty outline is `None` so the caller keeps the advance without + /// drawing, exactly like the pinned path. Shaped runs keep their shaper + /// positions, so powerline stretch stays off here. + pub fn outline_for_shaped_glyph( + &self, + id: fontdb::ID, + glyph_id: ttf_parser::GlyphId, + bold: bool, + italic: bool, + ) -> Option { + if glyph_id.0 == 0 { + return None; + } + let info = self.catalog.database.face(id)?; + let synthetic_bold = bold && !is_bold_face(info); + let synthetic_italic = italic && info.style == fontdb::Style::Normal; + self.catalog + .database + .with_face_data(id, |data, index| { + let parsed = Face::parse(data, index).ok()?; + let mut builder = TinyPathBuilder::default(); + let bounds = parsed.outline_glyph(glyph_id, &mut builder)?; + let path = builder.finish()?; + Some(GlyphOutline { + path, + bounds, + advance: parsed + .glyph_hor_advance(glyph_id) + .unwrap_or(parsed.units_per_em()), + units_per_em: parsed.units_per_em(), + synthetic_bold, + synthetic_italic, + powerline: false, + }) + }) + .flatten() + } + fn load(&self, key: GlyphKey) -> Option { for id in self.catalog.candidates(key.bold, key.italic, key.character) { let info = self.catalog.database.face(id)?; diff --git a/crates/tui-test/src/render/raster/shaping.rs b/crates/tui-test/src/render/raster/shaping.rs index 53914b74..4b34d55c 100644 --- a/crates/tui-test/src/render/raster/shaping.rs +++ b/crates/tui-test/src/render/raster/shaping.rs @@ -19,8 +19,8 @@ //! shaping disables the ligature features so one cluster keeps its own //! columns, exactly like the per-character layout it replaces. -use super::draw::is_default_ignorable; -use super::font::{GlyphKey, GlyphOutline, PinnedFontSystem}; +use super::draw::{format_glyph_sequence, is_default_ignorable}; +use super::font::{FontSystem, GlyphKey, GlyphOutline, PinnedFontSystem}; use crate::render::pinned::PinnedFontError; /// rustybuzz 0.20 reports advances and offsets in font units: its face @@ -129,6 +129,106 @@ fn shape_pinned_text_with_features( shape_with_face(fonts, position, text, bold, italic, features) } +/// Shape one system-backend cell grapheme with one catalog face. +/// +/// The run geometry is shared with the pinned path: a [`ShapedRun`] the +/// caller draws through the same positioned run, so cells and cursor +/// repaint shape equally on both backends. Only the selection policy +/// differs: the first catalog candidate for the style slot covering every +/// scalar wins, keeping the recorded host fallback order, and a grapheme +/// no single face can draw reports the full sequence for the caller's +/// missing-glyph collection instead of a typed error. Empty, +/// whitespace-only, and ignorable-only text draws nothing. Single-scalar +/// text never reaches here: the caller keeps the legacy verbatim path so +/// recorded pixels do not change. +pub(super) fn shape_system_text( + fonts: &FontSystem, + text: &str, + bold: bool, + italic: bool, +) -> Result { + debug_assert!( + text.chars().count() > 1, + "single scalars keep the legacy verbatim path" + ); + if text + .chars() + .all(|scalar| scalar.is_whitespace() || is_default_ignorable(scalar)) + { + return Ok(ShapedRun::Empty); + } + let base = text.chars().next().unwrap_or('\0'); + for id in fonts.style_candidates(bold, italic, base) { + if !text.chars().all(|scalar| fonts.face_has_glyph(id, scalar)) { + continue; + } + let shaped = fonts.with_face_data(id, |data, index| { + shape_system_data(fonts, id, data, index, text, bold, italic) + }); + match shaped.flatten() { + Some(result) => return result, + // Readable coverage but unreadable bytes: try the next face + // covering the full grapheme rather than failing here. + None => continue, + } + } + Err(format_glyph_sequence(text)) +} + +/// Shape `text` with the catalog face `id`, already known to cover every +/// scalar. `None` means the face bytes do not parse, so the caller tries +/// the next covering face. A `.notdef` id the shaper still emits is a +/// missing sequence naming every scalar, never tofu. Glyphs with no +/// outline (spaces, zero-width marks) keep their advance but draw nothing; +/// when no glyph draws anything visible, the sequence is reported instead +/// of a blank success, so a visible scalar is never silently dropped. +fn shape_system_data( + fonts: &FontSystem, + id: fontdb::ID, + data: &[u8], + index: u32, + text: &str, + bold: bool, + italic: bool, +) -> Option> { + let face = rustybuzz::Face::from_slice(data, index)?; + let mut buffer = rustybuzz::UnicodeBuffer::new(); + buffer.push_str(text); + let shaped = rustybuzz::shape(&face, &[], buffer); + let infos = shaped.glyph_infos(); + let advances = shaped.glyph_positions(); + let mut glyphs = Vec::new(); + let mut advance = 0.0f32; + for (info, placement) in infos.iter().zip(advances.iter()) { + advance += placement.x_advance as f32 / POSITION_SCALE; + let glyph_id = u16::try_from(info.glyph_id).ok()?; + if glyph_id == 0 { + return Some(Err(format_glyph_sequence(text))); + } + if let Some(outline) = fonts.outline_for_shaped_glyph( + id, + ttf_parser::GlyphId(glyph_id), + bold, + italic, + ) { + glyphs.push(ShapedGlyph { + outline, + dx: placement.x_offset as f32 / POSITION_SCALE, + dy: placement.y_offset as f32 / POSITION_SCALE, + advance: placement.x_advance as f32 / POSITION_SCALE, + }); + } + } + if glyphs.is_empty() + && text + .chars() + .any(|scalar| !scalar.is_whitespace() && !is_default_ignorable(scalar)) + { + return Some(Err(format_glyph_sequence(text))); + } + Some(Ok(ShapedRun::Shaped { glyphs, advance })) +} + /// Name the scalar a missing-glyph error reports. A scalar no candidate /// face holds is a genuine hole and is named first; otherwise the grapheme /// is divided across faces, and the first scalar the base face lacks is diff --git a/crates/tui-test/tests/system_graphemes.rs b/crates/tui-test/tests/system_graphemes.rs new file mode 100644 index 00000000..d4aae9c6 --- /dev/null +++ b/crates/tui-test/tests/system_graphemes.rs @@ -0,0 +1,329 @@ +//! U05-R13: mandatory combining sequences on the system font backend. +//! +//! The pinned path shapes every grapheme with one face; the ordinary +//! offline, live screenshot, and recording paths share that shaping through +//! the system catalog instead of scalar-count rejection. Each required +//! combining sequence (`e` plus U+0301, `a` plus U+0308) renders on all +//! three paths. Synthetic snapshots cover the offline path with no child, +//! PTY, session, or daemon; live content is produced by the child (`printf` +//! at startup or via submit, then `sleep`), so no PTY echo path can mangle +//! the combining bytes, and each live assertion polls to a known marker +//! before rendering. + +#![cfg(feature = "recording-raster")] + +use tui_test::profile::{Profile, Rgb}; +use tui_test::terminal::cell::Attrs; +use tui_test::{ + SnapshotCell, SnapshotColor, SnapshotCursor, SnapshotCursorShape, SnapshotMetadata, + SnapshotParts, SnapshotRenderOptions, SnapshotScope, SnapshotUnderline, TerminalSnapshot, +}; + +const E_ACUTE: &str = "e\u{301}"; +const A_DIAERESIS: &str = "a\u{308}"; + +fn resolved_palette() -> [Rgb; 256] { + let profile = Profile::default(); + std::array::from_fn(|index| profile.colors.rgb(index as u8)) +} + +fn styled_cell(text: &str, attrs: Attrs) -> SnapshotCell { + SnapshotCell::new( + text.to_string(), + 1, + SnapshotColor::Default, + SnapshotColor::Default, + attrs, + SnapshotUnderline::None, + SnapshotColor::Default, + None, + None, + ) + .expect("test cell is valid") +} + +fn plain_cell(text: &str) -> SnapshotCell { + styled_cell(text, Attrs::empty()) +} + +fn blank_cell() -> SnapshotCell { + plain_cell(" ") +} + +fn hidden_cursor() -> SnapshotCursor { + SnapshotCursor::new( + 0, + 0, + true, + false, + SnapshotCursorShape::Block, + Rgb::new(9, 9, 9), + None, + ) +} + +fn snapshot_of(cells: Vec) -> TerminalSnapshot { + let cols = cells.len() as u32; + TerminalSnapshot::try_new(SnapshotParts { + cols, + rows: 1, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells, + palette: resolved_palette(), + default_foreground: Rgb::new(192, 192, 192), + default_background: Rgb::new(0, 0, 0), + default_cursor: Rgb::new(9, 9, 9), + cursor: hidden_cursor(), + title: None, + metadata: SnapshotMetadata::default(), + }) + .expect("test snapshot is valid") +} + +/// Ordinary offline rendering without pinned fonts: zero chrome, so the +/// image is exactly the grid and the first cell is its left half. +fn undecorated_options() -> SnapshotRenderOptions { + use tui_test::render::style::Padding; + SnapshotRenderOptions::default() + .with_canvas_padding(Padding::Uniform(0)) + .with_content_padding(Padding::Uniform(0)) + .with_title_bar(false) + .with_title_controls(false) + .with_shadow(false) + .with_border_width(0.0) + .with_border_radius(0.0) +} + +fn decode_png(bytes: &[u8]) -> (u32, u32, Vec) { + let decoder = png::Decoder::new(bytes); + let mut reader = decoder.read_info().expect("PNG header decodes"); + let mut pixels = vec![0; reader.output_buffer_size()]; + let info = reader.next_frame(&mut pixels).expect("PNG frame decodes"); + assert_eq!(info.color_type, png::ColorType::Rgba); + assert_eq!(info.bit_depth, png::BitDepth::Eight); + (info.width, info.height, pixels) +} + +fn ink_offsets(pixels: &[u8], image_width: u32) -> Vec<(u32, u32)> { + pixels + .as_chunks::<4>() + .0 + .iter() + .enumerate() + .filter(|(_, pixel)| pixel != &&[0, 0, 0, 255]) + .map(|(index, _)| { + let index = index as u32; + (index % image_width, index / image_width) + }) + .collect() +} + +/// Ordinary offline render of one combining cell plus a blank: the mark is +/// really drawn (the image differs from the bare base) and all ink stays in +/// the combining cell's half of the grid. +fn check_ordinary_offline_combining(sequence: &str, base: &str, attrs: Attrs) { + assert_eq!(sequence.chars().count(), 2); + let snapshot = snapshot_of(vec![styled_cell(sequence, attrs), blank_cell()]); + let rendered = tui_test::render_snapshot_png(&snapshot, &undecorated_options()) + .expect("ordinary offline renders the combining sequence"); + let (width, height, pixels) = decode_png(rendered.png()); + assert_eq!(width % 2, 0, "two grid columns split the image evenly"); + let cell_width = width / 2; + let ink = ink_offsets(&pixels, width); + assert!(!ink.is_empty(), "combined cell holds ink"); + assert!( + ink.iter().all(|(x, _)| *x < cell_width), + "shaped ink stays in its cell" + ); + let base_snapshot = snapshot_of(vec![styled_cell(base, attrs), blank_cell()]); + let base = tui_test::render_snapshot_png(&base_snapshot, &undecorated_options()) + .expect("ordinary offline renders the bare base"); + let (base_width, base_height, base_pixels) = decode_png(base.png()); + assert_eq!((base_width, base_height), (width, height)); + assert_ne!( + pixels, base_pixels, + "combining mark changes the image" + ); +} + +/// U05-R13 (offline): `e` plus U+0301 renders on the system backend. +#[test] +fn ordinary_offline_renders_e_acute() { + check_ordinary_offline_combining(E_ACUTE, "e", Attrs::empty()); +} + +/// U05-R13 (offline): `a` plus U+0308 renders on the system backend in the +/// italic slot. +#[test] +fn ordinary_offline_renders_a_diaeresis_italic() { + check_ordinary_offline_combining(A_DIAERESIS, "a", Attrs::ITALIC); +} + +#[cfg(unix)] +mod live_tests { + use super::*; + use std::time::{Duration, Instant}; + use tui_test::{OpenOptions, Operation, OperationResult, RunOptions, Session}; + + /// The shell prints `sequence` via octal escapes (portable across + /// `/bin/sh` implementations: no `\x` hex assumption), then sleeps. + fn run_options_with_printf(printf_arg: &str) -> RunOptions { + let defaults = OpenOptions::default(); + RunOptions { + backend: defaults.backend, + program: "sh".to_string(), + args: vec![ + "-c".to_string(), + format!("printf '{printf_arg}'; sleep 30"), + ], + profile: defaults.profile, + style: defaults.style, + cols: 16, + rows: 2, + cwd: defaults.cwd, + env: defaults.env, + wait_ready: Some(false), + restart: defaults.restart, + timeouts: defaults.timeouts, + recording: defaults.recording, + } + } + + fn wait_for_marker(session: &Session, marker: &str) -> String { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let text = match session.execute(Operation::Text { full: false }) { + Ok(OperationResult::Text(text)) => text, + other => panic!("text operation gave {other:?}"), + }; + if text.contains(marker) { + return text; + } + if Instant::now() >= deadline { + panic!("timed out waiting for marker; got {text:?}"); + } + std::thread::sleep(Duration::from_millis(20)); + } + } + + /// Live PNG screenshot of one combining sequence: the snapshot proves + /// the terminal holds the two-scalar grapheme in one cell, and the + /// screenshot operation (system-backend rasterizer) renders it to a + /// non-blank image instead of failing with missing glyphs. + fn check_live_screenshot_combining(name: &str, sequence: &str, printf_arg: &str) { + let session = Session::new(format!("u05-r13-shot-{name}-{}", std::process::id())); + session + .run(run_options_with_printf(printf_arg)) + .expect("session starts"); + wait_for_marker(&session, sequence); + let snapshot = session + .capture_snapshot(SnapshotScope::Viewport) + .expect("live capture works"); + assert!( + snapshot.cells().iter().any(|cell| cell.text() == sequence), + "one live cell holds the combining sequence" + ); + let path = std::env::temp_dir().join(format!( + "u05-r13-shot-{name}-{}.png", + std::process::id() + )); + let result = session + .execute(Operation::Screenshot { + full: false, + path: Some(path.to_string_lossy().into_owned()), + zoom: None, + background: None, + }) + .expect("live screenshot works"); + session.close().expect("session closes"); + let OperationResult::Screenshot(tui_test::ScreenshotResult::Path(saved)) = result + else { + panic!("screenshot operation gave {result:?}"); + }; + assert_eq!(saved, path.to_string_lossy()); + let bytes = std::fs::read(&path).expect("live PNG reads back"); + std::fs::remove_file(&path).ok(); + let (width, height, pixels) = decode_png(&bytes); + assert!(width > 0 && height > 0); + assert!( + pixels.as_chunks::<4>().0.iter().any(|pixel| pixel != &[0, 0, 0, 255]), + "live screenshot of the combining sequence is not blank" + ); + } + + /// U05-R13 (live screenshot): `e` plus U+0301. + #[test] + fn live_screenshot_renders_e_acute() { + check_live_screenshot_combining("e-acute", E_ACUTE, "e\\314\\201"); + } + + /// U05-R13 (live screenshot): `a` plus U+0308. + #[test] + fn live_screenshot_renders_a_diaeresis() { + check_live_screenshot_combining("a-diaeresis", A_DIAERESIS, "a\\314\\210"); + } + + /// Recording export of one combining sequence: the submitted `printf` + /// puts the two-scalar grapheme on the grid, and stopping the recording + /// (system-backend rasterizer) exports an animated PNG instead of + /// failing with missing glyphs. + fn check_recording_combining(name: &str, sequence: &str, printf_arg: &str) { + let session = Session::new(format!("u05-r13-rec-{name}-{}", std::process::id())); + session.open(OpenOptions::default()).expect("open terminal"); + let path = std::env::temp_dir().join(format!( + "u05-r13-rec-{name}-{}.png", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + session + .execute(Operation::StartRecording { + path: path.to_string_lossy().into_owned(), + format: None, + fps: Some(30), + speed: Some(1.0), + idle_time_limit: Some(5.0), + zoom: Some(0.5), + background: None, + }) + .expect("start recording"); + session + .execute(Operation::Submit { + data: Some(format!("printf '{printf_arg}'")), + }) + .expect("submit command"); + session + .execute(Operation::WaitCommand { + timeout_ms: Some(30_000), + }) + .expect("wait for command"); + wait_for_marker(&session, sequence); + let OperationResult::Recording(recorded) = session + .execute(Operation::StopRecording) + .expect("stop recording") + else { + panic!("unexpected recording result"); + }; + assert_eq!(recorded, path.to_string_lossy()); + session.close().expect("close terminal"); + let bytes = std::fs::read(&path).expect("read apng"); + std::fs::remove_file(&path).ok(); + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); + assert!( + bytes.windows(4).any(|window| window == b"acTL"), + "recording exports animation frames" + ); + } + + /// U05-R13 (recording): `e` plus U+0301. + #[test] + fn recording_renders_e_acute() { + check_recording_combining("e-acute", E_ACUTE, "e\\314\\201"); + } + + /// U05-R13 (recording): `a` plus U+0308. + #[test] + fn recording_renders_a_diaeresis() { + check_recording_combining("a-diaeresis", A_DIAERESIS, "a\\314\\210"); + } +} From 517bd228b3847e671d1cacfd96db9ad1fd315112 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Mon, 26 Oct 2026 03:31:56 +0700 Subject: [PATCH 15/17] U04: share pinned renderer between PNG entry points and cover output settings in contract R5: write_snapshot_png unconditionally built the system-font renderer, so bold text with a regular-only pinned set wrote a PNG while render_snapshot_png returned typed MissingFace. Both entry points now build the shared snapshot_renderer, and the path API maps staged-write failures through map_pinned_error, so both report the same typed pinned-font failures and decode to equal pixels. R6: RenderContract copied only part of PinnedCacheKey, so a padding change (10x21 vs 30x41 images) and the synthetic-style selection left the contract equal while pixels differed. The contract is now at schema version 2 and records the synthetic-style selection plus every normalized output setting (title chrome, content and canvas padding, backgrounds, window and border chrome, shadow, exact-size switch), each with an accessor; RenderContract::system takes the background override and exact-size flag alongside the layout. U04-G checks path/bytes parity for the MissingFace failure and the success pixels; U04-H checks the returned contract changes after each output-affecting option. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/render/offline.rs | 92 ++++++----- crates/tui-test/src/render/pinned.rs | 214 +++++++++++++++++++++++++- crates/tui-test/tests/pinned_fonts.rs | 159 ++++++++++++++++++- 3 files changed, 421 insertions(+), 44 deletions(-) diff --git a/crates/tui-test/src/render/offline.rs b/crates/tui-test/src/render/offline.rs index bb460e60..0a78b36d 100644 --- a/crates/tui-test/src/render/offline.rs +++ b/crates/tui-test/src/render/offline.rs @@ -696,8 +696,9 @@ impl RenderedPng { } /// The versioned render contract: renderer and raster policy versions, - /// fonts, geometry, scale, palette digest, and cursor phase with the - /// blink intent kept from the snapshot state. + /// fonts, synthetic-style selection, geometry, scale, every normalized + /// output setting, palette digest, and cursor phase with the blink + /// intent kept from the snapshot state. pub fn contract(&self) -> &RenderContract { &self.contract } @@ -755,18 +756,11 @@ pub fn render_snapshot_png( title: shown_title(snapshot.title(), cols, rows, &layout), shaped_graphemes: Vec::new(), }; + // One shared renderer: the path entry point below builds the same one, + // so pinned bytes, system fonts, and their typed failures agree. + let mut renderer = snapshot_renderer(cols, rows, layout.clone(), options)?; match options.pinned_fonts.as_ref() { None => { - let mut renderer = super::raster::GridRenderer::with_layout( - cols, - rows, - layout.clone(), - options.background, - true, - ) - .map_err(|error| RenderError::RasterFailed { - message: error.to_string(), - })?; let png = super::encode::encode_png_bytes(&frame, &mut renderer).map_err(|error| { RenderError::RasterFailed { message: error.to_string(), @@ -779,19 +773,17 @@ pub fn render_snapshot_png( Ok(RenderedPng { png, evidence: rendered_evidence, - contract: RenderContract::system(&layout, options.scale, snapshot, cursor_drawn), + contract: RenderContract::system( + &layout, + options.scale, + snapshot, + cursor_drawn, + options.background, + true, + ), }) } Some(set) => { - let mut renderer = super::raster::GridRenderer::with_pinned_layout( - cols, - rows, - layout.clone(), - set, - options.background, - true, - ) - .map_err(map_pinned_error)?; let key = super::raster::GridRenderer::pinned_cache_key( &layout, set, @@ -832,11 +824,45 @@ fn map_pinned_error(error: anyhow::Error) -> RenderError { } } +/// Build the frame renderer both PNG entry points share. +/// +/// Without pinned fonts this is the recorded system-font renderer; with +/// pinned fonts it loads only the specified bytes. A missing style face or +/// glyph keeps its typed [`PinnedFontError`], so construction failures +/// agree between [`render_snapshot_png`] and [`write_snapshot_png`]. +#[cfg(feature = "recording-raster")] +fn snapshot_renderer( + cols: u16, + rows: usize, + layout: ResolvedLayout, + options: &SnapshotRenderOptions, +) -> Result { + match options.pinned_fonts.as_ref() { + None => { + super::raster::GridRenderer::with_layout(cols, rows, layout, options.background, true) + .map_err(|error| RenderError::RasterFailed { + message: error.to_string(), + }) + } + Some(set) => super::raster::GridRenderer::with_pinned_layout( + cols, + rows, + layout, + set, + options.background, + true, + ) + .map_err(map_pinned_error), + } +} + /// Draw a snapshot as PNG and save it to `path`, without a live session. /// /// Shares the staged file writer the live screenshots use, so bytes and path /// wrappers decode to equal pixels. Returns the same evidence -/// [`render_snapshot_png`] returns for the same inputs. +/// [`render_snapshot_png`] returns for the same inputs, with the same typed +/// pinned-font failures: the renderer is the shared [`snapshot_renderer`], +/// not an unconditional system-font renderer. #[cfg(feature = "recording-raster")] pub fn write_snapshot_png( path: &Path, @@ -847,20 +873,16 @@ pub fn write_snapshot_png( // Checked before the renderer allocates its pixmap. let _ = checked_output_size(&layout, snapshot.cols(), snapshot.rows())?; let (cols, rows) = snapshot_grid_size(snapshot)?; - let mut renderer = super::raster::GridRenderer::with_layout( - cols, - rows, - layout.clone(), - options.background, - true, - ) - .map_err(|error| RenderError::RasterFailed { - message: error.to_string(), - })?; + let pinned = options.pinned_fonts.is_some(); + let mut renderer = snapshot_renderer(cols, rows, layout.clone(), options)?; let frame = snapshot_frame(snapshot); super::encode::encode_png(path, &frame, &mut renderer).map_err(|error| { - RenderError::RasterFailed { - message: error.to_string(), + if pinned { + map_pinned_error(error) + } else { + RenderError::RasterFailed { + message: error.to_string(), + } } })?; let (width, height) = renderer.pixel_size(); diff --git a/crates/tui-test/src/render/pinned.rs b/crates/tui-test/src/render/pinned.rs index 3b036ad3..c2acd2f4 100644 --- a/crates/tui-test/src/render/pinned.rs +++ b/crates/tui-test/src/render/pinned.rs @@ -39,7 +39,11 @@ use crate::api::CaptureBackground; use crate::profile::Rgb; /// Schema version of [`RenderContract`]. -pub const RENDER_CONTRACT_VERSION: u32 = 1; +/// +/// Version 2 records every normalized output setting and the +/// synthetic-style selection alongside the fonts and geometry, so two +/// renders with different pixels never share a contract. +pub const RENDER_CONTRACT_VERSION: u32 = 2; /// Raster policy name recorded when no pinned fonts are selected. pub const RASTER_POLICY_SYSTEM_V1: &str = "system-v1"; @@ -730,22 +734,51 @@ impl ContractFont { /// /// The contract names the renderer version, the raster policy version, the /// pinned fonts (digests, face indices, styles, fallback order), the -/// geometry, the output scale, the palette digest, and the cursor phase -/// with the blink intent kept from the snapshot state. Strict image gates -/// compare decoded pixels; the contract tells reviewers and future runs -/// which font and raster configuration produced them. +/// synthetic-style selection, the geometry, the output scale, every +/// normalized output setting (title chrome, content and canvas padding, +/// backgrounds, window and border chrome, the shadow, and the exact-size +/// switch), the palette digest, and the cursor phase with the blink intent +/// kept from the snapshot state. Strict image gates compare decoded pixels; +/// the contract tells reviewers and future runs which font and raster +/// configuration produced them. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenderContract { contract_version: u32, renderer_version: String, raster_policy: String, fonts: Vec, + synthetic: bool, cell_width_bits: u32, cell_height_bits: u32, font_size_bits: u32, baseline_bits: u32, + title_font_size_bits: u32, + header_height_bits: u32, + divider_height_bits: u32, + content_left_bits: u32, + content_top_bits: u32, + content_right_bits: u32, + content_bottom_bits: u32, + canvas_left: u32, + canvas_right: u32, + canvas_top: u32, + canvas_bottom: u32, + canvas_background: Rgb, + background: Option, scale_bits: u64, title_bar: bool, + title_controls: bool, + window_background: Rgb, + window_foreground: Rgb, + window_divider: Rgb, + border_width_bits: u32, + border_color: Rgb, + border_radius_bits: u32, + shadow_enabled: bool, + shadow_color: Rgb, + shadow_offset_bits: u32, + shadow_spread_bits: u32, + exact_size: bool, palette_digest: String, cursor_phase: CursorPhase, blink: Option, @@ -754,6 +787,10 @@ pub struct RenderContract { impl RenderContract { /// Build the contract for a pinned render from the cache key that /// identifies the render and the snapshot it drew. + /// + /// The key carries the synthetic-style selection, the capture + /// background override, and the exact-size switch the layout alone + /// does not hold; everything else comes from the shared layout. #[cfg(feature = "recording-raster")] pub(crate) fn pinned( key: &PinnedCacheKey, @@ -775,8 +812,11 @@ impl RenderContract { fallback_position: position, }) .collect(), + key.synthetic(), layout, scale, + key.background(), + key.exact_size(), snapshot, cursor_drawn, ) @@ -791,12 +831,17 @@ impl RenderContract { scale: f64, snapshot: &crate::snapshot::TerminalSnapshot, cursor_drawn: bool, + background: Option, + exact_size: bool, ) -> Self { Self::for_render( RASTER_POLICY_SYSTEM_V1, Vec::new(), + false, layout, scale, + background, + exact_size, snapshot, cursor_drawn, ) @@ -807,8 +852,11 @@ impl RenderContract { fn for_render( raster_policy: &str, fonts: Vec, + synthetic: bool, layout: &crate::render::svg::ResolvedLayout, scale: f64, + background: Option, + exact_size: bool, snapshot: &crate::snapshot::TerminalSnapshot, cursor_drawn: bool, ) -> Self { @@ -829,12 +877,38 @@ impl RenderContract { renderer_version: env!("CARGO_PKG_VERSION").to_string(), raster_policy: raster_policy.to_string(), fonts, + synthetic, cell_width_bits: layout.cell_w.to_bits(), cell_height_bits: layout.cell_h.to_bits(), font_size_bits: layout.font_size.to_bits(), baseline_bits: layout.baseline.to_bits(), + title_font_size_bits: layout.title_font_size.to_bits(), + header_height_bits: layout.header_h.to_bits(), + divider_height_bits: layout.divider_h.to_bits(), + content_left_bits: layout.content_left.to_bits(), + content_top_bits: layout.content_top.to_bits(), + content_right_bits: layout.content_right.to_bits(), + content_bottom_bits: layout.content_bottom.to_bits(), + canvas_left: layout.canvas_left, + canvas_right: layout.canvas_right, + canvas_top: layout.canvas_top, + canvas_bottom: layout.canvas_bottom, + canvas_background: layout.canvas_background, + background, scale_bits: scale.to_bits(), title_bar: layout.title_bar, + title_controls: layout.title_controls, + window_background: layout.window_background, + window_foreground: layout.window_foreground, + window_divider: layout.window_divider, + border_width_bits: layout.border_width.to_bits(), + border_color: layout.border_color, + border_radius_bits: layout.border_radius.to_bits(), + shadow_enabled: layout.shadow_enabled, + shadow_color: layout.shadow_color, + shadow_offset_bits: layout.shadow_offset.to_bits(), + shadow_spread_bits: layout.shadow_spread.to_bits(), + exact_size, palette_digest: format!("{:x}", hasher.finalize()), cursor_phase: if cursor_drawn { CursorPhase::Shown @@ -865,6 +939,11 @@ impl RenderContract { &self.fonts } + /// Whether synthetic bold or italic rendering was selected. + pub fn synthetic(&self) -> bool { + self.synthetic + } + /// The cell width in pixels. pub fn cell_width(&self) -> f32 { f32::from_bits(self.cell_width_bits) @@ -895,6 +974,131 @@ impl RenderContract { self.title_bar } + /// The title font size in pixels. + pub fn title_font_size(&self) -> f32 { + f32::from_bits(self.title_font_size_bits) + } + + /// The title header height in pixels. + pub fn header_height(&self) -> f32 { + f32::from_bits(self.header_height_bits) + } + + /// The title divider height in pixels. + pub fn divider_height(&self) -> f32 { + f32::from_bits(self.divider_height_bits) + } + + /// The left content gap in pixels. + pub fn content_left(&self) -> f32 { + f32::from_bits(self.content_left_bits) + } + + /// The top content gap in pixels. + pub fn content_top(&self) -> f32 { + f32::from_bits(self.content_top_bits) + } + + /// The right content gap in pixels. + pub fn content_right(&self) -> f32 { + f32::from_bits(self.content_right_bits) + } + + /// The bottom content gap in pixels. + pub fn content_bottom(&self) -> f32 { + f32::from_bits(self.content_bottom_bits) + } + + /// The left canvas padding in pixels. + pub fn canvas_left(&self) -> u32 { + self.canvas_left + } + + /// The right canvas padding in pixels. + pub fn canvas_right(&self) -> u32 { + self.canvas_right + } + + /// The top canvas padding in pixels. + pub fn canvas_top(&self) -> u32 { + self.canvas_top + } + + /// The bottom canvas padding in pixels. + pub fn canvas_bottom(&self) -> u32 { + self.canvas_bottom + } + + /// The canvas fill used without a capture override. + pub fn canvas_background(&self) -> Rgb { + self.canvas_background + } + + /// The capture background override, if any. + pub fn background(&self) -> Option { + self.background + } + + /// Whether the window controls were drawn. + pub fn title_controls(&self) -> bool { + self.title_controls + } + + /// The title bar fill. + pub fn window_background(&self) -> Rgb { + self.window_background + } + + /// The title text color. + pub fn window_foreground(&self) -> Rgb { + self.window_foreground + } + + /// The title divider color. + pub fn window_divider(&self) -> Rgb { + self.window_divider + } + + /// The panel border width in pixels. + pub fn border_width(&self) -> f32 { + f32::from_bits(self.border_width_bits) + } + + /// The panel border color. + pub fn border_color(&self) -> Rgb { + self.border_color + } + + /// The panel corner radius in pixels. + pub fn border_radius(&self) -> f32 { + f32::from_bits(self.border_radius_bits) + } + + /// Whether the drop shadow was drawn. + pub fn shadow_enabled(&self) -> bool { + self.shadow_enabled + } + + /// The drop shadow color. + pub fn shadow_color(&self) -> Rgb { + self.shadow_color + } + + /// The drop shadow offset in pixels. + pub fn shadow_offset(&self) -> f32 { + f32::from_bits(self.shadow_offset_bits) + } + + /// The drop shadow spread in pixels. + pub fn shadow_spread(&self) -> f32 { + f32::from_bits(self.shadow_spread_bits) + } + + /// Whether exact (unpadded) output size was used. + pub fn exact_size(&self) -> bool { + self.exact_size + } + /// SHA-256 over the snapshot palette and default colors. pub fn palette_digest(&self) -> &str { &self.palette_digest diff --git a/crates/tui-test/tests/pinned_fonts.rs b/crates/tui-test/tests/pinned_fonts.rs index 6bc6a57e..a369b1fd 100644 --- a/crates/tui-test/tests/pinned_fonts.rs +++ b/crates/tui-test/tests/pinned_fonts.rs @@ -18,10 +18,11 @@ use tui_test::profile::{Profile, Rgb}; use tui_test::render::style::Padding; use tui_test::terminal::cell::Attrs; use tui_test::{ - render_snapshot_png, CursorPhase, PinnedFace, PinnedFontError, PinnedFontSet, RenderError, - SnapshotCell, SnapshotColor, SnapshotCursor, SnapshotCursorShape, SnapshotMetadata, - SnapshotParts, SnapshotRenderOptions, SnapshotScope, SnapshotUnderline, TerminalSnapshot, - RASTER_POLICY_PINNED_V1, RASTER_POLICY_SYSTEM_V1, RENDER_CONTRACT_VERSION, + render_snapshot_png, write_snapshot_png, CaptureBackground, CursorPhase, PinnedFace, + PinnedFontError, PinnedFontSet, RenderError, SnapshotCell, SnapshotColor, SnapshotCursor, + SnapshotCursorShape, SnapshotMetadata, SnapshotParts, SnapshotRenderOptions, SnapshotScope, + SnapshotUnderline, TerminalSnapshot, RASTER_POLICY_PINNED_V1, RASTER_POLICY_SYSTEM_V1, + RENDER_CONTRACT_VERSION, }; fn regular_bytes() -> Vec { @@ -434,3 +435,153 @@ fn u04_f_recorded_font_selection_is_unchanged() { ); assert!(!decode_png(rendered.png()).2.is_empty()); } + +/// U04-G (R5): the path entry point shares the byte entry point's pinned behavior. +/// +/// A regular-only set cannot draw bold text: both `render_snapshot_png` and +/// `write_snapshot_png` must report the same typed `MissingFace` failure and +/// stage no PNG file. With a complete set both entry points succeed with +/// equal evidence and equal decoded pixels. +#[test] +fn u04_g_path_and_bytes_share_pinned_behavior() { + let regular_only = PinnedFontSet::new(vec![regular_face()]).expect("regular set builds"); + let bold_snapshot = snapshot_with( + vec![ + bold_cell("b"), + plain_cell(" ", 1), + plain_cell(" ", 1), + plain_cell(" ", 1), + plain_cell(" ", 1), + plain_cell(" ", 1), + ], + None, + ); + let options = strict_options(®ular_only); + let byte_err = render_snapshot_png(&bold_snapshot, &options) + .expect_err("bytes entry point reports the missing bold face"); + assert!( + matches!( + byte_err, + RenderError::PinnedFont(PinnedFontError::MissingFace { + bold: true, + italic: false + }) + ), + "bytes failure stays typed, got: {byte_err}" + ); + let dir = TempDir::new("u04-g"); + let missing_path = dir.path().join("missing.png"); + let path_err = write_snapshot_png(&missing_path, &bold_snapshot, &options) + .expect_err("path entry point reports the same missing bold face"); + assert!( + matches!( + path_err, + RenderError::PinnedFont(PinnedFontError::MissingFace { + bold: true, + italic: false + }) + ), + "path failure stays typed, got: {path_err}" + ); + assert!( + !missing_path.exists(), + "a pinned failure stages no PNG file" + ); + + let set = titled_pair(); + let snapshot = ascii_snapshot(Some("pinned")); + let options = strict_options(&set); + let rendered = render_snapshot_png(&snapshot, &options).expect("bytes render works"); + let ok_path = dir.path().join("ok.png"); + let evidence = write_snapshot_png(&ok_path, &snapshot, &options).expect("path render works"); + assert_eq!(rendered.evidence(), &evidence); + let file_bytes = std::fs::read(&ok_path).expect("path render writes bytes"); + assert_eq!( + decode_png(rendered.png()), + decode_png(&file_bytes), + "path and byte entry points decode to equal pixels" + ); +} + +/// U04-H (R6): the returned contract covers every output-affecting option. +/// +/// Each single-option change below changes the decoded image or the raster +/// policy inputs, so the contract must change with it. Canvas padding and +/// the synthetic-style selection previously left the contract equal while +/// the pixels moved. +#[test] +fn u04_h_contract_covers_every_output_setting() { + let snapshot = ascii_snapshot(Some("pinned")); + let base_options = strict_options(&titled_pair()); + let base = render_snapshot_png(&snapshot, &base_options).expect("baseline renders"); + let base_pixels = decode_png(base.png()); + let render_contract = |options: &SnapshotRenderOptions| { + render_snapshot_png(&snapshot, options) + .expect("option-variant pinned render works") + .contract() + .clone() + }; + let changed = |label: &str, options: SnapshotRenderOptions| { + let contract = render_contract(&options); + assert_ne!( + base.contract(), + &contract, + "{label} joins the render contract" + ); + contract + }; + + let padded_options = base_options + .clone() + .with_canvas_padding(Padding::Uniform(12)); + let padded = render_snapshot_png(&snapshot, &padded_options).expect("padded renders"); + assert_ne!( + decode_png(padded.png()), + base_pixels, + "canvas padding moves the decoded pixels" + ); + assert_ne!( + base.contract(), + padded.contract(), + "canvas padding joins the render contract" + ); + + changed( + "content padding", + base_options + .clone() + .with_content_padding(Padding::Uniform(4)), + ); + changed( + "capture background", + base_options + .clone() + .with_background(Some(CaptureBackground::Transparent)), + ); + changed("title bar", base_options.clone().with_title_bar(true)); + changed( + "title controls", + base_options.clone().with_title_controls(true), + ); + changed("shadow", base_options.clone().with_shadow(true)); + changed("border width", base_options.clone().with_border_width(4.0)); + changed( + "border radius", + base_options.clone().with_border_radius(2.0), + ); + changed("output scale", base_options.clone().with_scale(2.0)); + changed("cell width", base_options.clone().with_cell_width(12.0)); + changed("cell height", base_options.clone().with_cell_height(23.0)); + changed("font size", base_options.clone().with_font_size(15.0)); + changed("baseline", base_options.clone().with_baseline(12.0)); + + let synthetic_set = titled_pair().with_synthetic_style(true); + let synthetic_options = strict_options(&synthetic_set); + let synthetic = + render_snapshot_png(&snapshot, &synthetic_options).expect("synthetic render works"); + assert_ne!( + base.contract(), + synthetic.contract(), + "synthetic-style selection joins the render contract" + ); +} From ee633fa5146110b5a792c3ebaa651f1a66d50af3 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Mon, 26 Oct 2026 22:22:27 +0700 Subject: [PATCH 16/17] U05: keep advance-only glyphs, relax ignorable coverage, reuse the clip mask P2 advance-only: shaping kept outline-less glyph advances in the run total but stored only outlined glyphs, so the draw loop never moved the pen for them. A width-1 cell holding space plus U+0301 rendered a successful blank PNG: the space advance was dropped and the mark landed outside the clip. Both backend loops now retain advance-only entries (Option outline) and the draw loop moves the pen for every entry while painting only drawable ones. P2 CGJ preflight: the cmap preflight required every scalar to map a standalone glyph, rejecting default-ignorables the shaper handles (a+CGJ failed as MissingGlyph although rustybuzz shapes base+CGJ). Coverage is now required only where visible glyphs are needed; the controls ride along in the shaping buffer and shaped output is inspected for genuine (.notdef on a visible scalar) holes. PERF: each shaped cell allocated a full-canvas clip mask (~12.4GB cumulative at 160x48/10x21: 7680 cells x 1600x1008 bytes). The renderer now keeps one mask per pixmap size and clears it per cell. A representative release workload (160x48 all-combining render) runs in ~0.3s with one mask allocation. Scope and evidence notes: shaping covers base-plus-combining-mark sequences only (ZWJ emoji and regional indicators keep the legacy report; no general emoji claim). Single-scalar cells keep the verbatim path on both backends, so single-scalar CJK was never rejected for continuation reasons. System cell and cursor repaint share the combining path; title shaping stays pinned-only. The two new dependencies (rustybuzz, unicode-properties) are optional under recording-raster, and shaped_graphemes records the input strings, not glyph IDs, positions, or faces. U05-H covers space-plus-mark and the noncomposable multi-glyph cluster with positional assertions (cell ink, cursor repaint, pinned titles); U05-I covers CGJ, variation sequences, and the unsupported-visible scalar negative control; the system backend gains an offline CGJ case. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/render/offline.rs | 3 +- crates/tui-test/src/render/raster.rs | 51 ++++- crates/tui-test/src/render/raster/draw.rs | 90 +++++---- crates/tui-test/src/render/raster/font.rs | 10 +- crates/tui-test/src/render/raster/shaping.rs | 190 +++++++++++++------ crates/tui-test/tests/graphemes.rs | 172 +++++++++++++++++ crates/tui-test/tests/system_graphemes.rs | 50 +++-- 7 files changed, 442 insertions(+), 124 deletions(-) diff --git a/crates/tui-test/src/render/offline.rs b/crates/tui-test/src/render/offline.rs index 0a78b36d..c60bc451 100644 --- a/crates/tui-test/src/render/offline.rs +++ b/crates/tui-test/src/render/offline.rs @@ -583,7 +583,8 @@ impl RenderEvidence { } /// The multi-scalar cell graphemes the pinned shaper placed, in - /// row-major cell order with one entry per shaped cell. Empty for SVG + /// row-major cell order with one entry per shaped cell. Each entry is + /// the input string (not glyph IDs, positions, or faces). Empty for SVG /// renders, system-font renders, and renders with no shaped cells: /// single-scalar cells draw verbatim and are not listed. pub fn shaped_graphemes(&self) -> &[String] { diff --git a/crates/tui-test/src/render/raster.rs b/crates/tui-test/src/render/raster.rs index 1d021e33..0dc775d0 100644 --- a/crates/tui-test/src/render/raster.rs +++ b/crates/tui-test/src/render/raster.rs @@ -1,6 +1,6 @@ use std::collections::BTreeSet; -use tiny_skia::Pixmap; +use tiny_skia::{Mask, Pixmap}; use crate::api::CaptureBackground; use crate::profile::{ColorSlot, Rgb}; @@ -16,10 +16,10 @@ mod font; mod shaping; use draw::{ - cell_clip, combining_sequence, draw_glyph, draw_shaped_run, fill_antialiased_rect, fill_circle, - fill_pixel_rect, fill_rounded_rect, fill_rounded_rect_alpha, fill_top_rounded_rect, - format_glyph_sequence, is_default_ignorable, multi_scalar_grapheme, stroke_rounded_rect, - unpremultiply, + cell_clip_reused, combining_sequence, draw_glyph, draw_shaped_run, fill_antialiased_rect, + fill_circle, fill_pixel_rect, fill_rounded_rect, fill_rounded_rect_alpha, + fill_top_rounded_rect, format_glyph_sequence, is_default_ignorable, multi_scalar_grapheme, + stroke_rounded_rect, unpremultiply, }; use font::{FontBackend, GlyphKey}; @@ -73,6 +73,10 @@ pub struct GridRenderer { /// recording never push here. The offline layer drains this for glyph /// evidence. shaped: Vec, + /// The clip mask reused across shaped cells. A full-canvas mask per + /// cell costs one pixmap-sized allocation each; this one is allocated + /// once per pixmap size and cleared per cell instead. + clip: Option, } impl GridRenderer { @@ -277,6 +281,7 @@ impl GridRenderer { layout, background, shaped: Vec::new(), + clip: None, }) } @@ -429,7 +434,12 @@ impl FrameRenderer for GridRenderer { } } - let (pixmap, fonts, shaped) = (&mut self.pixmap, &mut self.fonts, &mut self.shaped); + let (pixmap, fonts, shaped, clip_cache) = ( + &mut self.pixmap, + &mut self.fonts, + &mut self.shaped, + &mut self.clip, + ); for (y, row) in grid.iter().enumerate() { for x in 0..usize::from(cols) { let cell = row.get(x).unwrap_or(&blank); @@ -464,6 +474,7 @@ impl FrameRenderer for GridRenderer { draw_cell_text( pixmap, fonts, + clip_cache, cell.ch.as_str(), paint.bold, paint.italic, @@ -513,6 +524,7 @@ impl FrameRenderer for GridRenderer { draw_cursor( &mut self.pixmap, &mut self.fonts, + &mut self.clip, grid, cursor, colors, @@ -591,6 +603,7 @@ fn grid_y(origin_y: f32, row: usize, scale: f32, layout: &ResolvedLayout) -> u32 fn draw_cell_text( pixmap: &mut Pixmap, fonts: &mut FontBackend, + clip_cache: &mut Option, text: &str, bold: bool, italic: bool, @@ -617,7 +630,15 @@ fn draw_cell_text( } let clip = if run.is_shaped() || wide_edge { let (left, top, right, bottom) = clip_rect; - cell_clip(pixmap.width(), pixmap.height(), left, top, right, bottom) + cell_clip_reused( + clip_cache, + pixmap.width(), + pixmap.height(), + left, + top, + right, + bottom, + ) } else { None }; @@ -632,7 +653,7 @@ fn draw_cell_text( color, font_size, scale, - clip.as_ref(), + clip, ); return Ok(()); } @@ -650,7 +671,15 @@ fn draw_cell_text( Ok(run) => { let clip = if run.is_shaped() || wide_edge { let (left, top, right, bottom) = clip_rect; - cell_clip(pixmap.width(), pixmap.height(), left, top, right, bottom) + cell_clip_reused( + clip_cache, + pixmap.width(), + pixmap.height(), + left, + top, + right, + bottom, + ) } else { None }; @@ -665,7 +694,7 @@ fn draw_cell_text( color, font_size, scale, - clip.as_ref(), + clip, ); } Err(entry) => { @@ -713,6 +742,7 @@ fn draw_cell_text( fn draw_cursor( pixmap: &mut Pixmap, fonts: &mut FontBackend, + clip_cache: &mut Option, grid: &[Vec], (cx, cy): (u16, usize), colors: &dyn RenderColors, @@ -792,6 +822,7 @@ fn draw_cursor( draw_cell_text( pixmap, fonts, + clip_cache, cell.ch.as_str(), paint.bold, paint.italic, diff --git a/crates/tui-test/src/render/raster/draw.rs b/crates/tui-test/src/render/raster/draw.rs index b8fbfa46..c30b0a4e 100644 --- a/crates/tui-test/src/render/raster/draw.rs +++ b/crates/tui-test/src/render/raster/draw.rs @@ -280,6 +280,15 @@ pub(super) fn multi_scalar_grapheme(grapheme: &str) -> bool { grapheme.chars().count() > 1 } +/// Whether a scalar needs a visible glyph: anything but whitespace and +/// the default-ignorable scalars the shaper handles without ink (controls +/// like CGJ, variation selectors, ZWJ). Coverage preflights and +/// missing-glyph inspection only require these scalars; the rest ride +/// along in the shaping buffer and draw nothing. +pub(super) fn needs_visible_glyph(scalar: char) -> bool { + !scalar.is_whitespace() && !is_default_ignorable(scalar) +} + /// Whether a multi-scalar grapheme is a base character plus combining /// marks: the sequences the shaper positions on both backends. Anything /// else multi-scalar (ZWJ emoji, regional indicators, ...) keeps the @@ -331,7 +340,7 @@ pub(super) fn draw_shaped_run( if clip.is_some() && outline.advance != 0 && !outline.powerline { let single = ShapedRun::Shaped { glyphs: vec![super::shaping::ShapedGlyph { - outline: outline.clone(), + outline: Some(outline.clone()), dx: 0.0, dy: 0.0, advance: f32::from(outline.advance), @@ -367,10 +376,14 @@ pub(super) fn draw_shaped_run( ); } ShapedRun::Shaped { glyphs, advance } => { - let Some(first) = glyphs.first() else { + // Advance-only entries (spaces, zero-width marks) carry no + // outline but still move the pen; the scale comes from the + // first drawable glyph (one face per run, so the units agree) + // and a run with no drawable glyph paints nothing. + let Some(first) = glyphs.iter().find_map(|glyph| glyph.outline.as_ref()) else { return; }; - let units = f32::from(first.outline.units_per_em).max(1.0); + let units = f32::from(first.units_per_em).max(1.0); let scale_y = font_size * output_scale / units; // Mirror the legacy advance stretch: the whole run fills the // cell width. A zero total advance (only zero-width glyphs) @@ -382,27 +395,28 @@ pub(super) fn draw_shaped_run( }; let mut pen = 0.0f32; for glyph in glyphs { - let outline = &glyph.outline; - let shear = if outline.synthetic_italic { - 0.2 * scale_y - } else { - 0.0 - }; - let transform = Transform::from_row( - scale_x, - 0.0, - shear, - -scale_y, - origin_x + pen + glyph.dx * scale_x, - baseline - glyph.dy * scale_y, - ); - let mut paint = Paint::default(); - paint.set_color_rgba8(color.r, color.g, color.b, 255); - pixmap.fill_path(&outline.path, &paint, FillRule::Winding, transform, clip); - if outline.synthetic_bold { - let mut bold = transform; - bold.tx += 0.65 * output_scale; - pixmap.fill_path(&outline.path, &paint, FillRule::Winding, bold, clip); + if let Some(outline) = &glyph.outline { + let shear = if outline.synthetic_italic { + 0.2 * scale_y + } else { + 0.0 + }; + let transform = Transform::from_row( + scale_x, + 0.0, + shear, + -scale_y, + origin_x + pen + glyph.dx * scale_x, + baseline - glyph.dy * scale_y, + ); + let mut paint = Paint::default(); + paint.set_color_rgba8(color.r, color.g, color.b, 255); + pixmap.fill_path(&outline.path, &paint, FillRule::Winding, transform, clip); + if outline.synthetic_bold { + let mut bold = transform; + bold.tx += 0.65 * output_scale; + pixmap.fill_path(&outline.path, &paint, FillRule::Winding, bold, clip); + } } pen += glyph.advance * scale_x; } @@ -410,23 +424,35 @@ pub(super) fn draw_shaped_run( } } -/// A clip mask covering one pixel rectangle of a pixmap with the given -/// dimensions. Shaped runs paint through it so ink stays in the permitted -/// region (their cell, or the surviving column of a wide cell at the grid -/// edge). Returns `None` for an empty rectangle or when no mask can be -/// built; the caller then draws unclipped. -pub(super) fn cell_clip( +/// The clip mask for one pixel rectangle, reused across shaped cells. +/// Shaped runs paint through it so ink stays in the permitted region +/// (their cell, or the surviving column of a wide cell at the grid edge). +/// The cached mask is allocated once per pixmap size and cleared per cell +/// instead of allocating a full-canvas mask per shaped cell. Returns `None` +/// for an empty rectangle or when no mask can be built; the caller then +/// draws unclipped. +pub(super) fn cell_clip_reused( + cached: &mut Option, pixmap_width: u32, pixmap_height: u32, left: u32, top: u32, right: u32, bottom: u32, -) -> Option { +) -> Option<&Mask> { if left >= right || top >= bottom { return None; } - let mut mask = Mask::new(pixmap_width, pixmap_height)?; + let reuse = cached + .as_ref() + .is_some_and(|mask| mask.width() == pixmap_width && mask.height() == pixmap_height); + let mask = if reuse { + let mask = cached.as_mut().expect("cached mask is present"); + mask.clear(); + mask + } else { + cached.insert(Mask::new(pixmap_width, pixmap_height)?) + }; let mut builder = PathBuilder::new(); builder.move_to(left as f32, top as f32); builder.line_to(right as f32, top as f32); diff --git a/crates/tui-test/src/render/raster/font.rs b/crates/tui-test/src/render/raster/font.rs index a637a50d..93acbe96 100644 --- a/crates/tui-test/src/render/raster/font.rs +++ b/crates/tui-test/src/render/raster/font.rs @@ -50,12 +50,7 @@ impl FontSystem { /// system fallback policy. The system shaper tries these in order and /// shapes a grapheme with the first covering every scalar; the pinned /// backend never consults the catalog. - pub fn style_candidates( - &self, - bold: bool, - italic: bool, - character: char, - ) -> Vec { + pub fn style_candidates(&self, bold: bool, italic: bool, character: char) -> Vec { self.catalog.candidates(bold, italic, character) } @@ -345,7 +340,8 @@ impl PinnedFontSystem { /// The outline for a glyph id the shaper already placed. The synthetic /// style decisions match [`PinnedFontSystem::load`]; a `.notdef` id or - /// an empty outline is `None` so the caller falls back or fails. + /// an empty outline is `None` so the caller keeps an advance-only entry + /// that moves the pen without drawing. pub fn outline_for_glyph( &self, position: usize, diff --git a/crates/tui-test/src/render/raster/shaping.rs b/crates/tui-test/src/render/raster/shaping.rs index 4b34d55c..c1bbc561 100644 --- a/crates/tui-test/src/render/raster/shaping.rs +++ b/crates/tui-test/src/render/raster/shaping.rs @@ -19,7 +19,7 @@ //! shaping disables the ligature features so one cluster keeps its own //! columns, exactly like the per-character layout it replaces. -use super::draw::{format_glyph_sequence, is_default_ignorable}; +use super::draw::{format_glyph_sequence, is_default_ignorable, needs_visible_glyph}; use super::font::{FontSystem, GlyphKey, GlyphOutline, PinnedFontSystem}; use crate::render::pinned::PinnedFontError; @@ -29,11 +29,16 @@ use crate::render::pinned::PinnedFontError; /// `hmtx` advance). There is no 26.6 divisor. const POSITION_SCALE: f32 = 1.0; -/// One glyph the shaper placed: its outline, the pen offset in font units -/// (right and up positive) from the grapheme origin, and the pen advance -/// in font units the next glyph starts after. +/// One glyph the shaper placed: the pen offset in font units (right and +/// up positive) from the grapheme origin, and the pen advance in font +/// units the next glyph starts after. `outline` is `None` for +/// advance-only glyphs (a space, a zero-width mark, or a `.notdef` id the +/// shaper emitted for an ignorable scalar): the pen still moves by +/// `advance` so later drawable glyphs land at their shaper positions, but +/// nothing is painted. Dropping these entries instead would shift every +/// following glyph left by the dropped advance. pub(super) struct ShapedGlyph { - pub outline: GlyphOutline, + pub outline: Option, pub dx: f32, pub dy: f32, pub advance: f32, @@ -69,7 +74,9 @@ impl ShapedRun { /// Empty, whitespace-only, and ignorable-only text draws nothing. /// A single scalar resolves through the pinned cache and draws verbatim. /// A multi-scalar grapheme is shaped with the first face in the requested -/// style slot covering every scalar; a grapheme no single face covers is a +/// style slot covering every visible scalar (whitespace and +/// default-ignorables ride along in the shaping buffer without coverage); +/// a grapheme no single face covers for its visible scalars is a /// missing-glyph error naming the scalar. Cells shape with the default /// OpenType features. pub(super) fn shape_pinned_text( @@ -116,9 +123,17 @@ fn shape_pinned_text_with_features( if positions.is_empty() { return Err(PinnedFontError::MissingFace { bold, italic }); } + // Coverage is only required where a visible glyph is needed: + // whitespace and default-ignorables (CGJ, variation selectors, ...) + // stay in the shaping buffer and the shaper positions or drops them + // without ink. Requiring a standalone cmap entry for those scalars + // would reject sequences the shaper handles fine. let mut selected = None; for &position in &positions { - if text.chars().all(|scalar| fonts.has_glyph(position, scalar)) { + if text + .chars() + .all(|scalar| !needs_visible_glyph(scalar) || fonts.has_glyph(position, scalar)) + { selected = Some(position); break; } @@ -135,7 +150,7 @@ fn shape_pinned_text_with_features( /// caller draws through the same positioned run, so cells and cursor /// repaint shape equally on both backends. Only the selection policy /// differs: the first catalog candidate for the style slot covering every -/// scalar wins, keeping the recorded host fallback order, and a grapheme +/// visible scalar wins, keeping the recorded host fallback order, and a grapheme /// no single face can draw reports the full sequence for the caller's /// missing-glyph collection instead of a typed error. Empty, /// whitespace-only, and ignorable-only text draws nothing. Single-scalar @@ -159,7 +174,10 @@ pub(super) fn shape_system_text( } let base = text.chars().next().unwrap_or('\0'); for id in fonts.style_candidates(bold, italic, base) { - if !text.chars().all(|scalar| fonts.face_has_glyph(id, scalar)) { + if !text + .chars() + .all(|scalar| !needs_visible_glyph(scalar) || fonts.face_has_glyph(id, scalar)) + { continue; } let shaped = fonts.with_face_data(id, |data, index| { @@ -176,12 +194,15 @@ pub(super) fn shape_system_text( } /// Shape `text` with the catalog face `id`, already known to cover every -/// scalar. `None` means the face bytes do not parse, so the caller tries -/// the next covering face. A `.notdef` id the shaper still emits is a -/// missing sequence naming every scalar, never tofu. Glyphs with no -/// outline (spaces, zero-width marks) keep their advance but draw nothing; -/// when no glyph draws anything visible, the sequence is reported instead -/// of a blank success, so a visible scalar is never silently dropped. +/// visible scalar. `None` means the face bytes do not parse, so the caller +/// tries the next covering face. A `.notdef` id the shaper still emits for +/// a scalar needing a visible glyph is a missing sequence naming every +/// scalar, never tofu; a `.notdef` for an ignorable scalar keeps its +/// advance but draws nothing. Glyphs with no outline (spaces, zero-width +/// marks) keep their advance as advance-only entries but draw nothing, so +/// later glyphs land at their shaper positions; when no glyph draws +/// anything visible, the sequence is reported instead of a blank success, +/// so a visible scalar is never silently dropped. fn shape_system_data( fonts: &FontSystem, id: fontdb::ID, @@ -203,32 +224,43 @@ fn shape_system_data( advance += placement.x_advance as f32 / POSITION_SCALE; let glyph_id = u16::try_from(info.glyph_id).ok()?; if glyph_id == 0 { - return Some(Err(format_glyph_sequence(text))); - } - if let Some(outline) = fonts.outline_for_shaped_glyph( - id, - ttf_parser::GlyphId(glyph_id), - bold, - italic, - ) { - glyphs.push(ShapedGlyph { - outline, - dx: placement.x_offset as f32 / POSITION_SCALE, - dy: placement.y_offset as f32 / POSITION_SCALE, - advance: placement.x_advance as f32 / POSITION_SCALE, - }); + if needs_visible_glyph(scalar_at_cluster(text, info.cluster)) { + return Some(Err(format_glyph_sequence(text))); + } + glyphs.push(advance_only(placement)); + continue; } + glyphs.push(ShapedGlyph { + outline: fonts.outline_for_shaped_glyph( + id, + ttf_parser::GlyphId(glyph_id), + bold, + italic, + ), + dx: placement.x_offset as f32 / POSITION_SCALE, + dy: placement.y_offset as f32 / POSITION_SCALE, + advance: placement.x_advance as f32 / POSITION_SCALE, + }); } - if glyphs.is_empty() - && text - .chars() - .any(|scalar| !scalar.is_whitespace() && !is_default_ignorable(scalar)) + if !glyphs.iter().any(|glyph| glyph.outline.is_some()) && text.chars().any(needs_visible_glyph) { return Some(Err(format_glyph_sequence(text))); } Some(Ok(ShapedRun::Shaped { glyphs, advance })) } +/// An advance-only entry: the pen moves by the placement advance but +/// nothing is painted, so a later drawable glyph lands at its shaper +/// position. +fn advance_only(placement: &rustybuzz::GlyphPosition) -> ShapedGlyph { + ShapedGlyph { + outline: None, + dx: placement.x_offset as f32 / POSITION_SCALE, + dy: placement.y_offset as f32 / POSITION_SCALE, + advance: placement.x_advance as f32 / POSITION_SCALE, + } +} + /// Name the scalar a missing-glyph error reports. A scalar no candidate /// face holds is a genuine hole and is named first; otherwise the grapheme /// is divided across faces, and the first scalar the base face lacks is @@ -240,7 +272,7 @@ fn missing_glyph_for( bold: bool, italic: bool, ) -> PinnedFontError { - for scalar in text.chars() { + for scalar in text.chars().filter(|scalar| needs_visible_glyph(*scalar)) { if positions .iter() .all(|&position| !fonts.has_glyph(position, scalar)) @@ -268,12 +300,15 @@ fn missing_glyph_for( } } -/// Shape `text` with the pinned face at `position`. Every scalar is covered -/// by construction; a `.notdef` id the shaper still emits (for example a -/// variation selector the font maps but cannot shape) is a missing-glyph -/// error naming the scalar at that glyph's cluster, never tofu. Glyphs with -/// no outline (spaces, zero-width marks) keep their advance but draw -/// nothing, so no positioned scalar is silently dropped. +/// Shape `text` with the pinned face at `position`. Every visible scalar +/// is covered by construction; a `.notdef` id the shaper still emits for +/// a scalar needing a visible glyph (for example a variation selector the +/// font maps but cannot shape) is a missing-glyph error naming the scalar +/// at that glyph's cluster, never tofu, while a `.notdef` for an +/// ignorable scalar keeps its advance but draws nothing. Glyphs with no +/// outline (spaces, zero-width marks) keep their advance as advance-only +/// entries but draw nothing, so no positioned scalar is silently dropped +/// and later glyphs land at their shaper positions. fn shape_with_face( fonts: &PinnedFontSystem, position: usize, @@ -305,22 +340,23 @@ fn shape_with_face( detail: format!("shaper glyph id {} does not fit u16", info.glyph_id), })?; if glyph_id == 0 { - return Err(PinnedFontError::MissingGlyph { - character: scalar_at_cluster(text, info.cluster), - bold, - italic, - }); - } - if let Some(outline) = - fonts.outline_for_glyph(position, ttf_parser::GlyphId(glyph_id), bold, italic) - { - glyphs.push(ShapedGlyph { - outline, - dx: placement.x_offset as f32 / POSITION_SCALE, - dy: placement.y_offset as f32 / POSITION_SCALE, - advance: placement.x_advance as f32 / POSITION_SCALE, - }); + let character = scalar_at_cluster(text, info.cluster); + if needs_visible_glyph(character) { + return Err(PinnedFontError::MissingGlyph { + character, + bold, + italic, + }); + } + glyphs.push(advance_only(placement)); + continue; } + glyphs.push(ShapedGlyph { + outline: fonts.outline_for_glyph(position, ttf_parser::GlyphId(glyph_id), bold, italic), + dx: placement.x_offset as f32 / POSITION_SCALE, + dy: placement.y_offset as f32 / POSITION_SCALE, + advance: placement.x_advance as f32 / POSITION_SCALE, + }); } Ok(ShapedRun::Shaped { glyphs, advance }) } @@ -517,4 +553,48 @@ mod tests { other => panic!("expected a shaped run, got shaped={}", other.is_shaped()), } } + + /// A space plus a combining mark keeps the space as an advance-only + /// entry: the space has no outline but its advance positions the mark. + /// Dropping the entry would leave the mark at the cell origin. + #[test] + fn space_plus_mark_keeps_advance_only_entry() { + let mut system = regular_system(); + let run = + shape_pinned_text(&mut system, " \u{301}", false, false).expect("space-acute shapes"); + match &run { + ShapedRun::Shaped { glyphs, advance } => { + assert_eq!(glyphs.len(), 2); + assert!( + glyphs[0].outline.is_none(), + "the space moves the pen but draws nothing" + ); + assert!(glyphs[0].advance > 0.0); + assert!(glyphs[1].outline.is_some(), "the mark still draws"); + assert_eq!(*advance, glyphs[0].advance + glyphs[1].advance); + } + other => panic!("expected a shaped run, got shaped={}", other.is_shaped()), + } + } + + /// CGJ has no standalone cmap entry in the bundled face, yet the + /// shaper handles it: coverage is only required for visible scalars, + /// and the run still places the base with its full advance. + #[test] + fn cgj_shapes_without_standalone_coverage() { + let mut system = regular_system(); + assert!( + !system.has_glyph(0, '\u{34f}'), + "the bundled face maps no standalone CGJ glyph" + ); + let run = + shape_pinned_text(&mut system, "a\u{34f}", false, false).expect("base plus CGJ shapes"); + match &run { + ShapedRun::Shaped { glyphs, advance } => { + assert!(glyphs.iter().any(|glyph| glyph.outline.is_some())); + assert_eq!(*advance, 600.0, "the base keeps its full advance"); + } + other => panic!("expected a shaped run, got shaped={}", other.is_shaped()), + } + } } diff --git a/crates/tui-test/tests/graphemes.rs b/crates/tui-test/tests/graphemes.rs index a896f0ea..96b9570d 100644 --- a/crates/tui-test/tests/graphemes.rs +++ b/crates/tui-test/tests/graphemes.rs @@ -675,3 +675,175 @@ fn u05_g_missing_glyph_errors_without_image_success() { "crab never renders successfully" ); } + +/// U05-H: a width-1 cell holding a space plus a combining mark renders a +/// non-blank image with all ink inside the cell. The space contributes no +/// outline but its advance: dropping the advance-only entry would leave the +/// pen at the cell origin, so the mark would land outside the clip and the +/// render would succeed blank. The mark sits over the space, so its ink is +/// right of the cell origin rather than at the broken left edge. The +/// two-mark cluster below cannot precompose (a space never merges with a +/// mark), so unlike `e` plus U+0301 it exercises the multi-glyph pen path. +#[test] +fn u05_h_advance_only_glyphs_keep_pen_position() { + let set = corpus_set(); + for sequence in [" \u{301}", " \u{301}\u{308}"] { + let snapshot = + snapshot_with_cells(2, 1, vec![plain(sequence, 1), blank()], hidden_cursor()); + let rendered = render_snapshot_png(&snapshot, &strict_options(&set)) + .unwrap_or_else(|error| panic!("{sequence:?} renders, got {error}")); + assert_eq!( + rendered.evidence().shaped_graphemes(), + &[sequence.to_string()] + ); + let (width, _, pixels) = decode_png(rendered.png()); + let ink = cell_ink(&pixels, width, BLACK, 0, 0, CELL_W, CELL_H); + assert!(!ink.is_empty(), "{sequence:?} draws ink instead of blank"); + assert_eq!( + ink.len(), + ink_offsets(&pixels, width, BLACK).len(), + "{sequence:?} keeps all ink in its cell" + ); + let min_x = ink.iter().map(|(x, _)| *x).min().expect("ink is non-empty"); + assert!( + min_x >= 3, + "{sequence:?} draws over the space (min_x {min_x}), not at the dropped-pen edge" + ); + } +} + +/// U05-H: cursor repaint of an advance-only cell draws the mark at the same +/// shaper position as the plain cell; only the colors change. +#[test] +fn u05_h_cursor_repaint_keeps_advance_only_positions() { + let set = corpus_set(); + let sequence = " \u{301}"; + let plain_snapshot = + snapshot_with_cells(2, 1, vec![plain(sequence, 1), blank()], hidden_cursor()); + let plain_rendered = + render_snapshot_png(&plain_snapshot, &strict_options(&set)).expect("plain renders"); + let (width, _, plain_pixels) = decode_png(plain_rendered.png()); + let cursor_snapshot = snapshot_with_cells( + 2, + 1, + vec![plain(sequence, 1), blank()], + block_cursor_at(0, 0), + ); + let cursor_rendered = + render_snapshot_png(&cursor_snapshot, &strict_options(&set)).expect("cursor renders"); + assert!(cursor_rendered.evidence().cursor_drawn()); + let (_, _, cursor_pixels) = decode_png(cursor_rendered.png()); + let plain_ink = cell_ink(&plain_pixels, width, BLACK, 0, 0, CELL_W, CELL_H); + let cursor_ink = cell_ink(&cursor_pixels, width, CURSOR_FILL, 0, 0, CELL_W, CELL_H); + assert!(!plain_ink.is_empty(), "plain advance-only cell holds ink"); + for pixel in &cursor_ink { + assert!( + plain_ink.contains(pixel), + "repaint pixel {pixel:?} is a plain pixel" + ); + } + for pixel in &plain_ink { + let (x, y) = *pixel; + let near = cursor_ink + .iter() + .any(|(cx, cy)| cx.abs_diff(x) <= 1 && cy.abs_diff(y) <= 1); + assert!(near, "plain pixel {pixel:?} neighbors a repaint pixel"); + } +} + +/// U05-H: a pinned title containing an advance-only cluster shapes through +/// the same per-grapheme path as cells instead of failing or blanking. +#[test] +fn u05_h_pinned_title_shapes_advance_only_cluster() { + let set = corpus_set(); + let titled = |title: Option<&str>| { + TerminalSnapshot::try_new(SnapshotParts { + cols: 12, + rows: 1, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells: vec![ + plain("a", 1), + blank(), + blank(), + blank(), + blank(), + blank(), + blank(), + blank(), + blank(), + blank(), + blank(), + blank(), + ], + palette: resolved_palette(), + default_foreground: Rgb::new(192, 192, 192), + default_background: Rgb::new(0, 0, 0), + default_cursor: Rgb::new(9, 9, 9), + cursor: hidden_cursor(), + title: title.map(str::to_string), + metadata: SnapshotMetadata::default(), + }) + .expect("titled snapshot is valid") + }; + let options = strict_options(&set).with_title_bar(true); + let marked = + render_snapshot_png(&titled(Some("a \u{301}")), &options).expect("marked title renders"); + assert_eq!( + marked.evidence().title().unwrap_or_default(), + "a \u{301} - 12x1" + ); + let (_, _, marked_pixels) = decode_png(marked.png()); + let unmarked = + render_snapshot_png(&titled(Some("a ")), &options).expect("unmarked title renders"); + let (_, _, unmarked_pixels) = decode_png(unmarked.png()); + assert_ne!( + marked_pixels, unmarked_pixels, + "the title mark changes the image instead of blanking" + ); +} + +/// U05-I: default-ignorables ride along in the shaping buffer. CGJ +/// (U+034F, no standalone cmap entry in the bundled face) and a variation +/// selector (U+FE00) shape with the base and draw exactly the base image; +/// the old cmap preflight rejected both as missing glyphs. A +/// genuinely-unsupported visible scalar next to an ignorable still fails. +#[test] +fn u05_i_default_ignorables_shape_without_coverage() { + let set = corpus_set(); + let (_, _, base_pixels) = render_row(vec![plain("a", 1), blank()], &set); + for sequence in ["a\u{34f}", "a\u{fe00}"] { + assert_eq!(sequence.chars().count(), 2); + let snapshot = + snapshot_with_cells(2, 1, vec![plain(sequence, 1), blank()], hidden_cursor()); + let rendered = render_snapshot_png(&snapshot, &strict_options(&set)) + .unwrap_or_else(|error| panic!("{sequence:?} renders, got {error}")); + assert_eq!( + rendered.evidence().shaped_graphemes(), + &[sequence.to_string()] + ); + let (_, _, pixels) = decode_png(rendered.png()); + assert_eq!( + pixels, base_pixels, + "{sequence:?} draws exactly the base image" + ); + } + let bad = snapshot_with_cells( + 2, + 1, + vec![plain("a\u{34f}\u{10FFFF}", 1), blank()], + hidden_cursor(), + ); + let error = render_snapshot_png(&bad, &strict_options(&set)) + .expect_err("unsupported scalar next to CGJ still fails"); + assert!( + matches!( + error, + RenderError::PinnedFont(PinnedFontError::MissingGlyph { + character: '\u{10FFFF}', + .. + }) + ), + "unsupported scalar fails as missing glyph, got {error}" + ); +} diff --git a/crates/tui-test/tests/system_graphemes.rs b/crates/tui-test/tests/system_graphemes.rs index d4aae9c6..1b2328c5 100644 --- a/crates/tui-test/tests/system_graphemes.rs +++ b/crates/tui-test/tests/system_graphemes.rs @@ -141,10 +141,7 @@ fn check_ordinary_offline_combining(sequence: &str, base: &str, attrs: Attrs) { .expect("ordinary offline renders the bare base"); let (base_width, base_height, base_pixels) = decode_png(base.png()); assert_eq!((base_width, base_height), (width, height)); - assert_ne!( - pixels, base_pixels, - "combining mark changes the image" - ); + assert_ne!(pixels, base_pixels, "combining mark changes the image"); } /// U05-R13 (offline): `e` plus U+0301 renders on the system backend. @@ -160,6 +157,25 @@ fn ordinary_offline_renders_a_diaeresis_italic() { check_ordinary_offline_combining(A_DIAERESIS, "a", Attrs::ITALIC); } +/// U05-R13 (offline): `a` plus CGJ renders on the system backend. CGJ +/// needs no standalone coverage on either backend: it rides along in the +/// shaping buffer and the base still draws. +#[test] +fn ordinary_offline_renders_cgj() { + let sequence = "a\u{34f}"; + let snapshot = snapshot_of(vec![styled_cell(sequence, Attrs::empty()), blank_cell()]); + let rendered = tui_test::render_snapshot_png(&snapshot, &undecorated_options()) + .expect("ordinary offline renders base plus CGJ"); + let (width, _, pixels) = decode_png(rendered.png()); + let cell_width = width / 2; + let ink = ink_offsets(&pixels, width); + assert!(!ink.is_empty(), "base plus CGJ holds ink"); + assert!( + ink.iter().all(|(x, _)| *x < cell_width), + "CGJ ink stays in its cell" + ); +} + #[cfg(unix)] mod live_tests { use super::*; @@ -173,10 +189,7 @@ mod live_tests { RunOptions { backend: defaults.backend, program: "sh".to_string(), - args: vec![ - "-c".to_string(), - format!("printf '{printf_arg}'; sleep 30"), - ], + args: vec!["-c".to_string(), format!("printf '{printf_arg}'; sleep 30")], profile: defaults.profile, style: defaults.style, cols: 16, @@ -224,10 +237,8 @@ mod live_tests { snapshot.cells().iter().any(|cell| cell.text() == sequence), "one live cell holds the combining sequence" ); - let path = std::env::temp_dir().join(format!( - "u05-r13-shot-{name}-{}.png", - std::process::id() - )); + let path = + std::env::temp_dir().join(format!("u05-r13-shot-{name}-{}.png", std::process::id())); let result = session .execute(Operation::Screenshot { full: false, @@ -237,8 +248,7 @@ mod live_tests { }) .expect("live screenshot works"); session.close().expect("session closes"); - let OperationResult::Screenshot(tui_test::ScreenshotResult::Path(saved)) = result - else { + let OperationResult::Screenshot(tui_test::ScreenshotResult::Path(saved)) = result else { panic!("screenshot operation gave {result:?}"); }; assert_eq!(saved, path.to_string_lossy()); @@ -247,7 +257,11 @@ mod live_tests { let (width, height, pixels) = decode_png(&bytes); assert!(width > 0 && height > 0); assert!( - pixels.as_chunks::<4>().0.iter().any(|pixel| pixel != &[0, 0, 0, 255]), + pixels + .as_chunks::<4>() + .0 + .iter() + .any(|pixel| pixel != &[0, 0, 0, 255]), "live screenshot of the combining sequence is not blank" ); } @@ -271,10 +285,8 @@ mod live_tests { fn check_recording_combining(name: &str, sequence: &str, printf_arg: &str) { let session = Session::new(format!("u05-r13-rec-{name}-{}", std::process::id())); session.open(OpenOptions::default()).expect("open terminal"); - let path = std::env::temp_dir().join(format!( - "u05-r13-rec-{name}-{}.png", - std::process::id() - )); + let path = + std::env::temp_dir().join(format!("u05-r13-rec-{name}-{}.png", std::process::id())); let _ = std::fs::remove_file(&path); session .execute(Operation::StartRecording { From 48276aebaabc67e1c8f01b7e10efc1818a236af7 Mon Sep 17 00:00:00 2001 From: Alexey Zhokhov Date: Tue, 27 Oct 2026 04:17:33 +0700 Subject: [PATCH 17/17] U05: share synthetic style fallback across pinned selection Move the synthetic-style fallback into PinnedFontSystem::style_positions so the single-scalar, grapheme, and title paths select faces the same way: exact style-slot matches first in fallback order, every face in fallback order when the slot is empty and synthetic styles are selected, and empty otherwise. Previously a regular-only set with synthetic styles enabled failed bold combining text and titles with MissingFace. Cover the fallback with the synthetic_style_fallback graphemes test: regular-only face renders bold-italic combining text, block-cursor repaint, and a visible title, while the same render without synthetic styles fails as a missing face. Signed-off-by: Alexey Zhokhov --- crates/tui-test/src/render/raster/font.rs | 22 ++++-- crates/tui-test/tests/graphemes.rs | 89 +++++++++++++++++++++++ 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/crates/tui-test/src/render/raster/font.rs b/crates/tui-test/src/render/raster/font.rs index 93acbe96..9cc8de9f 100644 --- a/crates/tui-test/src/render/raster/font.rs +++ b/crates/tui-test/src/render/raster/font.rs @@ -250,7 +250,8 @@ impl PinnedFontSystem { /// Only faces serving the requested style slot are candidates: an /// empty slot is a missing-face error, and a glyph no candidate holds /// is a missing-glyph error. Synthetic bold or italic applies only - /// when the set selected it. + /// when the set selected it, in which case [`Self::style_positions`] + /// already widened the slot to every face in fallback order. pub fn resolve(&mut self, key: GlyphKey) -> Result<&GlyphOutline, pinned::PinnedFontError> { if !self.glyphs.contains_key(&key) { let positions = self.style_positions(key.bold, key.italic); @@ -304,16 +305,25 @@ impl PinnedFontSystem { self.build_outline(&parsed, glyph_id, key.bold, key.italic, true, key.character) } - /// Face positions serving one style slot, in fallback order. The shaper - /// selects one of these per grapheme; an empty result is a missing-face - /// error at the call site. + /// Face positions serving one style slot, in fallback order. Exact + /// style-slot matches come first in existing order; when no face serves + /// the requested slot and synthetic styles are selected, every face + /// joins in fallback order so the single-scalar, grapheme, and title + /// paths synthesize the style instead of failing. Without synthetic + /// styles an unserved slot stays empty, which is a missing-face error + /// at the call site. pub fn style_positions(&self, bold: bool, italic: bool) -> Vec { - self.faces + let exact: Vec = self + .faces .iter() .enumerate() .filter(|(_, face)| face.bold == bold && face.italic == italic) .map(|(position, _)| position) - .collect() + .collect(); + if !exact.is_empty() || !self.synthetic { + return exact; + } + (0..self.faces.len()).collect() } /// Whether the face at `position` maps `character` to a real glyph. A diff --git a/crates/tui-test/tests/graphemes.rs b/crates/tui-test/tests/graphemes.rs index 96b9570d..3770a3dc 100644 --- a/crates/tui-test/tests/graphemes.rs +++ b/crates/tui-test/tests/graphemes.rs @@ -847,3 +847,92 @@ fn u05_i_default_ignorables_shape_without_coverage() { "unsupported scalar fails as missing glyph, got {error}" ); } + +/// U05 synthetic fallback: with a regular-only face and synthetic styles +/// selected, bold-italic combining text shapes, block-cursor repaint of the +/// combined cell works, and a visible title renders. With synthetic styles +/// off, the same styled cell fails as a missing face. +#[test] +fn synthetic_style_fallback() { + let regular_only = || { + PinnedFontSet::new(vec![ + PinnedFace::new(regular_bytes(), 0, false, false).expect("bundled face parses") + ]) + .expect("regular-only set builds") + }; + let synthetic = regular_only().with_synthetic_style(true); + + // Bold-italic combining text shapes with the regular face. + let styled = snapshot_with_cells( + 2, + 1, + vec![cell(E_ACUTE, 1, Attrs::BOLD | Attrs::ITALIC), blank()], + hidden_cursor(), + ); + let rendered = render_snapshot_png(&styled, &strict_options(&synthetic)) + .expect("synthetic bold-italic combining cell renders"); + assert_eq!( + rendered.evidence().shaped_graphemes(), + &[E_ACUTE.to_string()] + ); + let (width, _, pixels) = decode_png(rendered.png()); + let ink = cell_ink(&pixels, width, BLACK, 0, 0, CELL_W, CELL_H); + assert!(!ink.is_empty(), "synthetic styled cell holds ink"); + assert_eq!( + ink.len(), + ink_offsets(&pixels, width, BLACK).len(), + "synthetic styled ink stays in its cell" + ); + + // Block-cursor repaint of the combined cell renders too. + let repainted = snapshot_with_cells( + 2, + 1, + vec![cell(E_ACUTE, 1, Attrs::BOLD | Attrs::ITALIC), blank()], + block_cursor_at(0, 0), + ); + let cursor_rendered = render_snapshot_png(&repainted, &strict_options(&synthetic)) + .expect("synthetic cursor repaint renders"); + assert!(cursor_rendered.evidence().cursor_drawn()); + assert!(!cursor_rendered.png().is_empty()); + + // A visible title renders: titles always use the bold slot, so the + // regular-only set needs the same synthetic fallback. + let titled = TerminalSnapshot::try_new(SnapshotParts { + cols: 12, + rows: 1, + scope: SnapshotScope::Viewport, + viewport_origin: 0, + cells: (0..12).map(|_| blank()).collect(), + palette: resolved_palette(), + default_foreground: Rgb::new(192, 192, 192), + default_background: Rgb::new(0, 0, 0), + default_cursor: Rgb::new(9, 9, 9), + cursor: hidden_cursor(), + title: Some(E_ACUTE.to_string()), + metadata: SnapshotMetadata::default(), + }) + .expect("titled snapshot is valid"); + let titled_rendered = + render_snapshot_png(&titled, &strict_options(&synthetic).with_title_bar(true)) + .expect("synthetic title renders"); + assert_eq!( + titled_rendered.evidence().title().unwrap_or_default(), + format!("{E_ACUTE} - 12x1").as_str() + ); + assert!(!titled_rendered.png().is_empty()); + + // Without synthetic styles the same styled cell is a missing face. + let error = render_snapshot_png(&styled, &strict_options(®ular_only())) + .expect_err("styled combining cell without synthetic fails"); + assert!( + matches!( + error, + RenderError::PinnedFont(PinnedFontError::MissingFace { + bold: true, + italic: true + }) + ), + "unselected style fails as missing face, got {error}" + ); +}