Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
911f18d
feat(config): allow a profile to carry its own recording settings
aymanbagabas Sep 10, 2026
6aff0f9
feat(render): describe how output is drawn as a Style
aymanbagabas Sep 10, 2026
c81cbd9
refactor(render): draw from a Style instead of constants
aymanbagabas Sep 10, 2026
66280d0
fix(config): validate and anchor a profile's recording table
aymanbagabas Sep 11, 2026
3e149f6
fix(render): make a Style express only what can be drawn
aymanbagabas Sep 11, 2026
302b680
feat(config): style screenshots and recordings from a profile
aymanbagabas Sep 11, 2026
92ceba6
fix(render): bound what a config can ask the renderers to draw
aymanbagabas Sep 11, 2026
eb2ff5b
test(render): prove a configured style reaches the picture
aymanbagabas Sep 11, 2026
4ae1325
feat(render): inherit a profile's style key by key and draw the border
aymanbagabas Sep 11, 2026
f65494a
feat(render): draw with the fonts a config names
aymanbagabas Sep 11, 2026
f3dc055
refactor(render): name the canvas background and padding for the canvas
aymanbagabas Sep 11, 2026
dcedf17
docs(cli): write down how a recording is styled
aymanbagabas Sep 11, 2026
cdd9df7
fix(render): bound the font files a config can ask to be read
aymanbagabas Sep 11, 2026
eb24bbf
docs: show what the style options look like
aymanbagabas Sep 11, 2026
3ceeb40
docs: show the style options and how recordings look
aymanbagabas Sep 11, 2026
246a322
feat(render): let each gap around the window differ
aymanbagabas Sep 11, 2026
8ef0d14
feat(render): make the gap inside the window configurable too
aymanbagabas Sep 11, 2026
ba7f2fd
docs: show the option gallery at one scale
aymanbagabas Sep 11, 2026
e55d337
docs: name the default gaps and show them removed
aymanbagabas Sep 11, 2026
c5c8820
Merge remote-tracking branch 'origin/main' into aymanbagabas/recordin…
aymanbagabas Sep 11, 2026
e92a3f7
fix(config): let a profile be named by the name the flag defaults to
aymanbagabas Sep 11, 2026
10b411a
docs: show a profile overriding the file's recording settings
aymanbagabas Sep 11, 2026
ed06668
Merge remote-tracking branch 'origin/main' into aymanbagabas/recordin…
aymanbagabas Sep 11, 2026
3720f19
Merge remote-tracking branch 'origin/main' into aymanbagabas/recordin…
aymanbagabas Sep 12, 2026
b34a049
test(bindings): follow the default gap the renderer now draws
aymanbagabas Sep 12, 2026
fa88f28
test(render): regenerate the render snapshots for the new gap
aymanbagabas Sep 12, 2026
211b7cb
Merge remote-tracking branch 'origin/main' into aymanbagabas/recordin…
aymanbagabas Sep 19, 2026
4261e39
docs: drop the recording mode a profile can no longer set
aymanbagabas Sep 19, 2026
db6edab
docs(style): describe the path a style actually takes
aymanbagabas Sep 19, 2026
c8df2b1
ci: run the tests that are behind the recording feature
aymanbagabas Sep 11, 2026
523c19b
Merge remote-tracking branch 'origin/main' into aymanbagabas/recordin…
aymanbagabas Oct 6, 2026
8f44da0
fix(render): address recording style review feedback
aymanbagabas Oct 6, 2026
182727e
fix(render): use the configured bold font for SVG titles
aymanbagabas Oct 9, 2026
c7d71c3
docs: center the recording style example
aymanbagabas Oct 9, 2026
3e7925c
Merge remote-tracking branch 'origin/main' into aymanbagabas/recordin…
aymanbagabas Oct 9, 2026
07ca431
fix(go): initialize recording styles in native options
aymanbagabas Oct 9, 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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ jobs:
- run: cargo clippy -p tui-test-rs --all-targets --no-default-features -- -D warnings
- run: cargo build
- run: cargo test --workspace -- --test-threads=1
# The recording renderer and its font catalog are behind a feature that
# is off by default, so the line above never compiles them in.
- run: cargo test -p tui-test-rs --features recording-font-jetbrains-mono-styles -- --test-threads=1

- name: Install Python binding
run: python -m pip install --disable-pip-version-check -e ./bindings/python
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,37 @@ directory = "./traces"
screen-history-limit = 10
```

Set the appearance of screenshots and recordings with `[recording.style]`:

```toml
[recording.style]
font_size = 18
canvas_background = "#05050a"
canvas_padding = 30

[recording.style.window]
background = "#15151f"
foreground = "#c8c8e0"
divider = "#2a2a3a"

[recording.style.border]
width = 1
color = "#3a3a52"
radius = 12

[profiles.default.colors]
background = "#0b0b12"
foreground = "#d8d8e8"
```

<p align="center">
<img alt="tui-test drawn with a dark canvas, chrome and palette" src="static/style-midnight.svg" width="400">
</p>

Profiles override individual keys and inherit the rest from the file.
See the [style types and defaults](crates/tui-test/src/render/style.rs) for all options.
Comment thread
aymanbagabas marked this conversation as resolved.
The [CLI reference](references/cli.md#styling-screenshots-and-recordings) explains profile overrides.

The CLI checks the current directory, the platform config directory, then `~/.tui-test`. Use `--config PATH` or `TUI_TEST_CONFIG` to select a file.

### Shells and backends
Expand Down
1 change: 1 addition & 0 deletions bindings/go/native/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ pub(crate) unsafe fn open(value: TuiOpenOptions) -> Result<OpenOptions> {
backend,
shell,
profile,
style: Default::default(),
cols: u16_option(value.cols, "cols")?.unwrap_or(80),
rows: u16_option(value.rows, "rows")?.unwrap_or(30),
cwd: unsafe { value.cwd.optional()? },
Expand Down
1 change: 1 addition & 0 deletions bindings/go/native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ pub unsafe extern "C" fn tui_run(
args: input::strings(args, args_len)?,
backend: o.backend,
profile: o.profile,
style: o.style,
cols: o.cols,
rows: o.rows,
cwd: o.cwd,
Expand Down
2 changes: 2 additions & 0 deletions bindings/js/native/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,7 @@ fn open_options(
Ok(CoreOpenOptions {
backend: value.backend.map(Into::into).unwrap_or_default(),
profile,
style: Default::default(),
shell: value.shell.map(Into::into),
cols: match value.cols {
Some(cols) => u16_value(cols, "cols")?,
Expand Down Expand Up @@ -1041,6 +1042,7 @@ fn run_options(
Ok(CoreRunOptions {
backend: value.backend.map(Into::into).unwrap_or_default(),
profile,
style: Default::default(),
program: value.program,
args: value.args.unwrap_or_default(),
cols: match value.cols {
Expand Down
7 changes: 4 additions & 3 deletions bindings/js/test/integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,8 @@ test("recording API exports styled Unicode to APNG and GIF", async () => {
await su.screenshot(screenshotPath, { zoom: 0.5 });
assert.match(
await readFile(screenshotPath, "utf8"),
/width="139" height="92" viewBox="0 0 278 184"/,
// Follows the default style; update deliberately if a gap moves.
/width="139" height="94" viewBox="0 0 278 188"/,
);
}
await su.startRecording(path, { format, fps: 30, zoom: 0.5 });
Expand All @@ -382,11 +383,11 @@ test("recording API exports styled Unicode to APNG and GIF", async () => {
assert.deepEqual(bytes.subarray(0, 8), Buffer.from("\x89PNG\r\n\x1a\n", "latin1"));
assert.ok(bytes.includes(Buffer.from("acTL")));
assert.equal(bytes.readUInt32BE(16), 278);
assert.equal(bytes.readUInt32BE(20), 184);
assert.equal(bytes.readUInt32BE(20), 188);
} else {
assert.equal(bytes.subarray(0, 6).toString("ascii"), "GIF89a");
assert.equal(bytes.readUInt16LE(6), 278);
assert.equal(bytes.readUInt16LE(8), 184);
assert.equal(bytes.readUInt16LE(8), 188);
}
}
} finally {
Expand Down
2 changes: 2 additions & 0 deletions bindings/python/native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ impl NativeSession {
Operation::Open(OpenOptions {
backend: parse_backend(backend.as_deref())?,
profile: profile_from_parts(profile_scrollback.as_ref(), &profile_colors)?,
style: Default::default(),
shell: parse_shell(shell.as_deref())?,
cols: integer_u16(&cols, "cols")?,
rows: integer_u16(&rows, "rows")?,
Expand Down Expand Up @@ -311,6 +312,7 @@ impl NativeSession {
Operation::Run(RunOptions {
backend: parse_backend(backend.as_deref())?,
profile: profile_from_parts(profile_scrollback.as_ref(), &profile_colors)?,
style: Default::default(),
program,
args,
cols: integer_u16(&cols, "cols")?,
Expand Down
10 changes: 6 additions & 4 deletions bindings/python/tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,10 @@ async def scenario():
str(screenshot), zoom=0.5
)
self.assertIn(
'width="139" height="92" '
'viewBox="0 0 278 184"',
# Follows the default style; update
# deliberately if a default gap moves.
'width="139" height="94" '
'viewBox="0 0 278 188"',
screenshot.read_text(encoding="utf-8"),
)
await su.start_recording(
Expand All @@ -231,15 +233,15 @@ async def scenario():
int.from_bytes(data[16:20], "big"), 278
)
self.assertEqual(
int.from_bytes(data[20:24], "big"), 184
int.from_bytes(data[20:24], "big"), 188
)
else:
self.assertEqual(data[:6], b"GIF89a")
self.assertEqual(
int.from_bytes(data[6:8], "little"), 278
)
self.assertEqual(
int.from_bytes(data[8:10], "little"), 184
int.from_bytes(data[8:10], "little"), 188
)

run(scenario())
Expand Down
2 changes: 2 additions & 0 deletions crates/tui-test-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ fn build_request(command: Command) -> anyhow::Result<Request> {
restart,
timeouts: settings.timeouts.with_overrides(timeouts.into()),
recording: Box::new(settings.recording),
style: Box::new(settings.style.clone()),
trace: settings.trace,
diagnostics: tui_test::DiagnosticRetentionOptions {
screen_history_limit: diagnostics
Expand Down Expand Up @@ -348,6 +349,7 @@ fn build_request(command: Command) -> anyhow::Result<Request> {
restart,
timeouts: settings.timeouts.with_overrides(timeouts.into()),
recording: Box::new(settings.recording),
style: Box::new(settings.style.clone()),
trace: settings.trace,
diagnostics: tui_test::DiagnosticRetentionOptions {
screen_history_limit: diagnostics
Expand Down
8 changes: 8 additions & 0 deletions crates/tui-test-cli/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ pub enum Request {
/// project-local config against.
#[serde(default)]
profile: tui_test::profile::Profile,
/// How this session's screenshots and recordings are drawn, resolved
/// from the config file by the client for the same reason.
#[serde(default)]
style: Box<tui_test::render::style::Style>,
cols: u16,
rows: u16,
cwd: Option<String>,
Expand Down Expand Up @@ -438,6 +442,7 @@ impl Request {
program,
backend,
profile,
style,
cols,
rows,
cwd,
Expand All @@ -457,6 +462,7 @@ impl Request {
Ok(Operation::Run(RunOptions {
backend,
profile,
style: *style,
program: executable,
args: parts.collect(),
cols,
Expand All @@ -472,6 +478,7 @@ impl Request {
Ok(Operation::Open(OpenOptions {
backend,
profile,
style: *style,
shell,
cols,
rows,
Expand Down Expand Up @@ -839,6 +846,7 @@ mod tests {
program: None,
backend: Backend::default(),
profile: Default::default(),
style: Box::default(),
cols: 80,
rows: 30,
cwd: None,
Expand Down
9 changes: 6 additions & 3 deletions crates/tui-test-cli/tests/session_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,8 @@ fn screenshots_dispatch_by_extension_without_changing_svg_output() {
sandbox.ok(&["screenshot", zoomed_svg.to_str().unwrap(), "--zoom", "2"]);
let zoomed_svg = std::fs::read_to_string(zoomed_svg).unwrap();
assert!(
zoomed_svg.contains(r#"width="236" height="326" viewBox="0 0 118 163""#),
// Follows the default style; update deliberately if a gap moves.
zoomed_svg.contains(r#"width="236" height="334" viewBox="0 0 118 167""#),
"unexpected zoomed SVG dimensions: {zoomed_svg}"
);

Expand All @@ -376,7 +377,7 @@ fn screenshots_dispatch_by_extension_without_changing_svg_output() {
let mut reader = decoder.read_info().unwrap();
let mut pixels = vec![0; reader.output_buffer_size()];
let info = reader.next_frame(&mut pixels).unwrap();
assert_eq!((info.width, info.height), (236, 326));
assert_eq!((info.width, info.height), (236, 334));
let pixels = &pixels[..info.buffer_size()];
assert!(
contains_rgba(pixels, [128, 0, 0, 255]),
Expand Down Expand Up @@ -2698,7 +2699,9 @@ fn a_window_title_is_tracked_asserted_and_drawn() {
backend.as_str()
);
assert!(
image.contains(r#"width="239" height="365" viewBox="0 0 478 730""#),
// The default style at zoom 0.5; update deliberately if a
// default gap moves.
image.contains(r#"width="239" height="367" viewBox="0 0 478 734""#),
"{} changed the SVG dimensions at zoom 0.5: {image}",
backend.as_str()
);
Expand Down
7 changes: 7 additions & 0 deletions crates/tui-test/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ pub struct OpenOptions {
pub restart: bool,
pub timeouts: Timeouts,
pub recording: AutomaticRecording,
/// How this session's screenshots and recordings are drawn, resolved
/// from the config file by the client alongside `profile`.
pub style: crate::render::style::Style,
Comment thread
aymanbagabas marked this conversation as resolved.
}

impl Default for OpenOptions {
Expand All @@ -107,6 +110,7 @@ impl Default for OpenOptions {
restart: false,
timeouts: Timeouts::default(),
recording: AutomaticRecording::default(),
style: crate::render::style::Style::default(),
}
}
}
Expand All @@ -129,6 +133,9 @@ pub struct RunOptions {
pub restart: bool,
pub timeouts: Timeouts,
pub recording: AutomaticRecording,
/// How this session's screenshots and recordings are drawn, resolved
/// from the config file by the client alongside `profile`.
pub style: crate::render::style::Style,
}

/// Clipboard text or regex.
Expand Down
3 changes: 2 additions & 1 deletion crates/tui-test/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -858,12 +858,13 @@ impl FailureObservation {
}

pub(crate) fn svg(&self) -> String {
crate::render::svg::render_svg_with_zoom(
crate::render::svg::render_svg(
&self.rows,
self.cols,
&self.render_state,
self.cursor,
self.title.as_deref(),
&crate::render::style::Style::default(),
1.0,
None,
)
Expand Down
20 changes: 13 additions & 7 deletions crates/tui-test/src/diagnostics/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ struct FrameCell {

impl FrameCell {
fn capture(cell: &EmuCell, width: u8, colors: &dyn RenderColors) -> Self {
let style = svg::style_of(cell, colors);
let style = svg::cell_paint(cell, colors);
Self {
char: cell.ch.to_string(),
link: cell.uri().map(str::to_string),
Expand Down Expand Up @@ -120,11 +120,16 @@ fn timeline_with_limit(
let mut timeline = Timeline {
schema_version: 1,
failure_screen_sequence: observation.screen_sequence,
geometry: Geometry {
grid_x: svg::CANVAS_PADDING as f32 + svg::MARGIN_X,
grid_y: svg::CANVAS_PADDING as f32 + svg::HEADER_H + svg::CONTENT_PADDING_TOP,
cell_width: svg::CELL_W,
cell_height: svg::CELL_H,
// The trace viewer draws its own grid rather than the styled SVG, so
// it is placed with the default geometry.
geometry: {
let style = crate::render::style::Style::default();
Geometry {
grid_x: style.canvas_left() as f32 + style.content_left(),
grid_y: style.canvas_top() as f32 + style.header_height() + style.content_top(),
cell_width: style.cell_width(),
cell_height: style.cell_height(),
}
},
frames: sources
.values()
Expand Down Expand Up @@ -188,14 +193,15 @@ fn timeline_with_limit(
} else {
frame.omission = None;
let cursor = &frame.screen.cursor;
frame.svg = Some(svg::render_svg_with_zoom(
frame.svg = Some(svg::render_svg(
&source.rows,
frame.screen.size.cols,
&source.render_state,
cursor
.visible
.then_some((cursor.column, usize::from(cursor.row))),
frame.screen.title.as_deref(),
&crate::render::style::Style::default(),
1.0,
None,
));
Expand Down
Loading
Loading