Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5cce8ea
Add public owned TerminalSnapshot with atomic live capture
donbeave Oct 25, 2026
f7acf83
Cover snapshot validation, corpus, and lock-atomic capture with tests
donbeave Oct 25, 2026
9e13fc1
Add owned_snapshot acceptance tests U02-A through U02-K
donbeave Oct 25, 2026
364719a
Fix bool assert lint and document try_new with an example
donbeave Oct 25, 2026
10b68c8
U03: share one resolved layout between live and offline renderers
donbeave Oct 25, 2026
6a3c88f
U03: add public offline SVG/PNG snapshot rendering with tests
donbeave Oct 25, 2026
f7774c7
U04: add pinned-font policy to offline raster rendering
donbeave Oct 25, 2026
18dadb7
U04: cover pinned-font policy with U04-A..U04-F tests
donbeave Oct 25, 2026
3c37c6a
U04: pin title, background, and padding settings in PinnedCacheKey
donbeave Oct 25, 2026
f6cb06f
U05: shape graphemes with rustybuzz 0.20.1
donbeave Oct 25, 2026
759f5e0
U05: vendor Noto Sans JP subset for the CJK corpus case
donbeave Oct 25, 2026
eb1a43f
U05: shape pinned graphemes through one path for cells, cursor, and t…
donbeave Oct 25, 2026
a9a412b
U05: cover mandatory graphemes with U05-A..U05-G tests
donbeave Oct 25, 2026
edf2b52
U05: share grapheme shaping with the system font backend
donbeave Oct 25, 2026
517bd22
U04: share pinned renderer between PNG entry points and cover output …
donbeave Oct 25, 2026
ee633fa
U05: keep advance-only glyphs, relax ignorable coverage, reuse the cl…
donbeave Oct 26, 2026
48276ae
U05: share synthetic style fallback across pinned selection
donbeave Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
4 changes: 3 additions & 1 deletion crates/tui-test/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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]
Expand Down
Binary file not shown.
93 changes: 93 additions & 0 deletions crates/tui-test/assets/noto-sans-jp/OFL.txt
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions crates/tui-test/assets/noto-sans-jp/SOURCE
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion crates/tui-test/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
53 changes: 53 additions & 0 deletions crates/tui-test/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<TerminalSnapshot, TuiTestError> {
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<TerminalSnapshot, TuiTestError> {
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<LiveFrame> {
let live = self
.live
Expand Down
18 changes: 18 additions & 0 deletions crates/tui-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
2 changes: 1 addition & 1 deletion crates/tui-test/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/tui-test/src/record/frames.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading