diff --git a/Cargo.lock b/Cargo.lock index e67b49ac..5603b58c 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,12 +1893,14 @@ dependencies = [ "regex", "rio-vt", "rquickjs", + "rustybuzz", "serde", "serde_json", "sha2", "tiny-skia", "toml", "ttf-parser", + "unicode-properties", "unicode-width", "windows-sys 0.61.2", ] @@ -1897,12 +1917,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..9133f6e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,11 +38,13 @@ 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" 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 580c7180..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"] +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"] @@ -38,12 +38,14 @@ 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 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/assets/noto-sans-jp/NotoSansJP-subset.ttf b/crates/tui-test/assets/noto-sans-jp/NotoSansJP-subset.ttf new file mode 100644 index 00000000..7a9a731a Binary files /dev/null and b/crates/tui-test/assets/noto-sans-jp/NotoSansJP-subset.ttf differ 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. 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/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..dbb11239 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; @@ -20,8 +21,25 @@ 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 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, }; +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/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/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..cf523fd1 100644 --- a/crates/tui-test/src/render/mod.rs +++ b/crates/tui-test/src/render/mod.rs @@ -1,4 +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 new file mode 100644 index 00000000..c60bc451 --- /dev/null +++ b/crates/tui-test/src/render/offline.rs @@ -0,0 +1,1135 @@ +//! 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::pinned::RenderContract; +use super::pinned::{PinnedFontError, PinnedFontSet}; +#[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 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), +} + +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::PinnedFont(error) => { + write!(f, "pinned font render failed: {error}") + } + 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), + RenderError::PinnedFont(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. 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, + 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, + pinned_fonts: 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, + pinned_fonts: 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 + } + + /// 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> { + 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, which title the bar showed, and which multi-scalar +/// graphemes the pinned shaper placed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenderEvidence { + width: u32, + height: u32, + cursor_drawn: bool, + transparent_background: bool, + title: Option, + shaped_graphemes: Vec, +} + +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() + } + + /// The multi-scalar cell graphemes the pinned shaper placed, in + /// 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] { + &self.shaped_graphemes + } +} + +/// 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), + shaped_graphemes: Vec::new(), + }, + }) +} + +/// 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 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")] +impl RenderedPng { + /// The encoded PNG bytes; decode them for strict pixel gates. + pub fn png(&self) -> &[u8] { + &self.png + } + + /// The versioned render contract: renderer and raster policy versions, + /// 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 + } + + /// 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. +/// +/// Without pinned fonts the rasterizer keeps its recorded system-font +/// 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")] +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 frame = snapshot_frame(snapshot); + 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), + 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 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, + options.background, + true, + ), + }) + } + Some(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(); + 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, + contract: RenderContract::pinned( + &key, + &layout, + options.scale, + snapshot, + cursor_drawn, + ), + }) + } + } +} + +/// 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::() { + Ok(pinned) => RenderError::PinnedFont(pinned), + Err(other) => RenderError::RasterFailed { + message: other.to_string(), + }, + } +} + +/// 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, 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, + 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 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| { + if pinned { + map_pinned_error(error) + } else { + 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), + shaped_graphemes: renderer.take_shaped_graphemes(), + }) +} + +/// 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; + if !options.title_bar { + // Same rule the live style uses: no bar, no header or divider. + layout.header_h = 0.0; + layout.divider_h = 0.0; + } + 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/pinned.rs b/crates/tui-test/src/render/pinned.rs new file mode 100644 index 00000000..c2acd2f4 --- /dev/null +++ b/crates/tui-test/src/render/pinned.rs @@ -0,0 +1,1231 @@ +//! 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}; + +use crate::api::CaptureBackground; +use crate::profile::Rgb; + +/// Schema version of [`RenderContract`]. +/// +/// 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"; + +/// 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 +/// 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, + faces: Vec, + synthetic: bool, + scale_bits: u64, + font_size_bits: u32, + 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. +#[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. + #[allow(clippy::too_many_arguments)] + pub fn new( + set: &PinnedFontSet, + scale: f64, + font_size: f32, + 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, + 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(), + 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, + } + } + + /// 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) + } + + /// 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-v2"); + 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()); + 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()) + } +} + +/// 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 +/// 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, +} + +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, + 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(), + key.synthetic(), + layout, + scale, + key.background(), + key.exact_size(), + 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, + background: Option, + exact_size: bool, + ) -> Self { + Self::for_render( + RASTER_POLICY_SYSTEM_V1, + Vec::new(), + false, + layout, + scale, + background, + exact_size, + snapshot, + cursor_drawn, + ) + } + + #[cfg(feature = "recording-raster")] + #[allow(clippy::too_many_arguments)] + 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 { + 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, + 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 + } 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 + } + + /// 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) + } + + /// 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 + } + + /// 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 + } + + /// 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()); + } + + 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 = 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); + 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 3fc2419d..0dc775d0 100644 --- a/crates/tui-test/src/render/raster.rs +++ b/crates/tui-test/src/render/raster.rs @@ -1,25 +1,29 @@ use std::collections::BTreeSet; -use tiny_skia::Pixmap; +use tiny_skia::{Mask, 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; use super::svg; -use super::svg::RenderColors; +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_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::{FontSystem, GlyphKey}; +use font::{FontBackend, GlyphKey}; + +use super::pinned::{PinnedCacheKey, PinnedFontSet}; use crate::render::style::Style; @@ -60,10 +64,19 @@ pub struct GridRenderer { width: u32, height: u32, pixmap: Pixmap, - fonts: FontSystem, - style: Style, - /// Overrides the style's canvas background for one capture. + fonts: FontBackend, + 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, + /// 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 { @@ -117,15 +130,127 @@ 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) + } + + /// 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, + background: Option, + exact_size: bool, + ) -> PinnedCacheKey { + PinnedCacheKey::new( + set, + layout.zoom, + layout.font_size, + 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, + ) + } + + 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 { - 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,11 +277,20 @@ 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, + layout, background, + shaped: Vec::new(), + clip: None, }) } + + /// 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 { @@ -176,19 +310,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 +337,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 +351,7 @@ impl FrameRenderer for GridRenderer { panel_width as f32, panel_height as f32, scale, - style, + layout, ); fill_rounded_rect( &mut self.pixmap, @@ -225,39 +359,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,8 +407,8 @@ impl FrameRenderer for GridRenderer { origin_y, scale, &mut missing, - style, - ); + layout, + )?; } let blank = EmuCell::blank(); @@ -290,17 +424,22 @@ 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; } } - let (pixmap, fonts) = (&mut self.pixmap, &mut self.fonts); + 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); @@ -320,52 +459,43 @@ 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()) { - 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(key) { - Some(glyph) => draw_glyph( - pixmap, - glyph, - cell_origin_x, - cell_origin_y, - cell_width, - cell_height, - baseline, - paint.fg, - style.font_size, - scale, - ), - None => { - missing.insert(format!("{character:?} (U+{:04X})", character as u32)); - } - } - } + draw_cell_text( + pixmap, + fonts, + clip_cache, + 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( @@ -381,7 +511,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, @@ -394,6 +524,7 @@ impl FrameRenderer for GridRenderer { draw_cursor( &mut self.pixmap, &mut self.fonts, + &mut self.clip, grid, cursor, colors, @@ -401,21 +532,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,20 +574,175 @@ 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 +} + +/// The one shaping path for cell rendering and cursor repaint, shared by +/// both font backends. +/// +/// 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, + fonts: &mut FontBackend, + clip_cache: &mut Option, + 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<()> { + 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)?; + 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_reused( + clip_cache, + 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, + ); + return Ok(()); + } + 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_reused( + clip_cache, + 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, + ); + } + 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(()); + } + 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)] fn draw_cursor( pixmap: &mut Pixmap, - fonts: &mut FontSystem, + fonts: &mut FontBackend, + clip_cache: &mut Option, grid: &[Vec], (cx, cy): (u16, usize), colors: &dyn RenderColors, @@ -464,13 +750,13 @@ fn draw_cursor( panel_origin_y: f32, scale: f32, missing: &mut BTreeSet, - style: &Style, -) { + 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) @@ -481,16 +767,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() { @@ -506,7 +791,7 @@ fn draw_cursor( bottom, color, ); - return; + return Ok(()); } CursorShape::Bar => { fill_pixel_rect( @@ -517,54 +802,43 @@ 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; - } - if unsupported_grapheme(cell.ch.as_str()) { - missing.insert(format_glyph_sequence(cell.ch.as_str())); - return; + return Ok(()); } 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) { - continue; - } - let key = GlyphKey { - character, - bold: paint.bold, - italic: paint.italic, - }; - match fonts.resolve(key) { - Some(glyph) => draw_glyph( - pixmap, - glyph, - origin_x, - origin_y, - cell_width, - cell_height, - baseline, - svg::bg_of(cell, colors), - style.font_size, - scale, - ), - None => { - missing.insert(format!("{character:?} (U+{:04X})", character as u32)); - } - } - } + // 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, + clip_cache, + 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( @@ -574,9 +848,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 +858,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, ); } @@ -594,7 +868,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, @@ -603,16 +877,42 @@ fn draw_title( origin_y: f32, scale: f32, missing: &mut BTreeSet, - style: &Style, -) { - let Some(title) = svg::visible_title(title, cols, rows, panel_width, style) else { - return; + layout: &ResolvedLayout, +) -> anyhow::Result<()> { + let Some(title) = svg::visible_title(title, cols, rows, panel_width, layout) else { + return Ok(()); }; - 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; + + 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; @@ -622,17 +922,17 @@ fn draw_title( bold: true, italic: false, }; - match fonts.resolve(key) { + match fonts.resolve_strict(key)? { Some(glyph) => draw_glyph( pixmap, glyph, 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 => { @@ -642,6 +942,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/draw.rs b/crates/tui-test/src/render/raster/draw.rs index f9de29da..c30b0a4e 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)] @@ -271,10 +272,200 @@ 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 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 +/// 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 +/// 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: Some(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 } => { + // 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.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 { + 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; + } + } + } +} + +/// 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<&Mask> { + if left >= right || top >= bottom { + return None; + } + 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); + 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) +} + +/// 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 3108709a..9cc8de9f 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, @@ -45,6 +46,78 @@ 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)?; @@ -80,6 +153,262 @@ 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)?)) + } + + /// 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 + /// 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, 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); + 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)?; + 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. 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 { + let exact: Vec = self + .faces + .iter() + .enumerate() + .filter(|(_, face)| face.bold == bold && face.italic == italic) + .map(|(position, _)| position) + .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 + /// 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 keeps an advance-only entry + /// that moves the pen without drawing. + 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 && bold && !is_bold_weight(parsed.weight().to_number()); + let synthetic_italic = self.synthetic && 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: powerline && nerd_font::is_powerline_separator(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") 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..c1bbc561 --- /dev/null +++ b/crates/tui-test/src/render/raster/shaping.rs @@ -0,0 +1,600 @@ +//! 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::{format_glyph_sequence, is_default_ignorable, needs_visible_glyph}; +use super::font::{FontSystem, 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: 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: Option, + 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 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( + 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 }); + } + // 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| !needs_visible_glyph(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) +} + +/// 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 +/// 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 +/// 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| !needs_visible_glyph(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 +/// 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, + 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 { + 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.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 +/// 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().filter(|scalar| needs_visible_glyph(*scalar)) { + 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 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, + 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 { + 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 }) +} + +/// 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()), + } + } + + /// 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/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) ); } 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..6b18ae27 --- /dev/null +++ b/crates/tui-test/src/snapshot.rs @@ -0,0 +1,1996 @@ +//! 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. + /// + /// ``` + /// 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) + } + + /// 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) +} + +#[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); + let error = serde_json::from_value::(wire).expect_err("schema 999 fails"); + assert!( + error.to_string().contains("unsupported snapshot schema"), + "unexpected error: {error}" + ); + } + + #[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 + ); + } +} diff --git a/crates/tui-test/tests/graphemes.rs b/crates/tui-test/tests/graphemes.rs new file mode 100644 index 00000000..3770a3dc --- /dev/null +++ b/crates/tui-test/tests/graphemes.rs @@ -0,0 +1,938 @@ +//! 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" + ); +} + +/// 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}" + ); +} + +/// 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}" + ); +} diff --git a/crates/tui-test/tests/offline_render.rs b/crates/tui-test/tests/offline_render.rs new file mode 100644 index 00000000..f06680f1 --- /dev/null +++ b/crates/tui-test/tests/offline_render.rs @@ -0,0 +1,488 @@ +//! U03 acceptance: public offline rendering from owned snapshots. +//! +//! Live content is produced by the child (`printf` at startup, then `sleep`), +//! so no PTY echo path can mangle escape sequences. Each live assertion is +//! polled to a known marker before rendering, so no assertion depends on +//! timing. Synthetic snapshots cover the offline-only paths with no child, +//! PTY, session, or daemon anywhere in the test. + +use std::time::{Duration, Instant}; + +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, +}; + +fn resolved_palette() -> [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())); + } +} 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); +} diff --git a/crates/tui-test/tests/pinned_fonts.rs b/crates/tui-test/tests/pinned_fonts.rs new file mode 100644 index 00000000..a369b1fd --- /dev/null +++ b/crates/tui-test/tests/pinned_fonts.rs @@ -0,0 +1,587 @@ +//! 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, 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 { + 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()); +} + +/// 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" + ); +} diff --git a/crates/tui-test/tests/system_graphemes.rs b/crates/tui-test/tests/system_graphemes.rs new file mode 100644 index 00000000..1b2328c5 --- /dev/null +++ b/crates/tui-test/tests/system_graphemes.rs @@ -0,0 +1,341 @@ +//! 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); +} + +/// 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::*; + 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"); + } +}