Skip to content

feat: add offline SVG/PNG snapshot rendering - #280

Draft
Alexey Zhokhov (donbeave) wants to merge 13 commits into
microsoft:mainfrom
tailrocks:goal/u03-offline-rendering
Draft

Alexey Zhokhov (donbeave) wants to merge 13 commits into
microsoft:mainfrom
tailrocks:goal/u03-offline-rendering

Conversation

@donbeave

@donbeave Alexey Zhokhov (donbeave) commented Oct 25, 2026 •

Copy link
Copy Markdown

Depends-On: #278
Incremental diff vs #278 head:
cb40f25...d7c25f1

Summary

Renders a captured snapshot to SVG or PNG without a live session, sharing
one resolved layout between the live and offline renderers so screenshots
and offline output agree.

New since the original PR head:

  • Recorded column widths are preserved end to end (all backends record
    occupancy widths); wide spans paint fully, trailing clips and zero-width
    leads behave, text runs stay span-atomic, and decorated blanks emit their
    decoration. Geometry is asserted in tests (text lengths, rect widths,
    decoded pixel extents).
  • Oversized live-screenshot zooms now return a usage error instead of
    panicking in the renderer.
  • Raster (PNG) underline and strike-through bands are clamped inside their
    owning cell, so tiny cells no longer paint decorations onto neighboring
    rows; block-cursor bands repaint in the contrasting color, clamped the
    same way, matching the SVG renderer.
  • Inherits the live-capture width/continuation reconciliation fixes from
    feat: add owned terminal snapshot with atomic live capture #278, and fractional sizing is rounded once at the pixel boundary.

Head: d7c25f14c41d8ae7420d2bfffd70c9332c287915

Testing

Focused suites at head d7c25f1, run locally:

  • cargo fmt --all -- --check: clean, no diff
  • cargo clippy -p tui-test-rs --all-targets --features recording-font-jetbrains-mono-styles -- -D warnings: no warnings
  • cargo test -p tui-test-rs --features recording-font-jetbrains-mono-styles --test offline_render:
    29 tests run, 29 passed, 0 failed (includes new decorations_stay_inside_small_cells plus same_paint_spans_keep_cell_geometry and thin_cursor_stays_inside_small_cell)
  • Same features, --test public_emu_cell_compat: 1 passed, 0 failed

Known limitations

Live capture reads grid, cursor, dimensions, title, and resolved
palette under one terminal-state lock. Default capture records raw
visual data; highlighted capture needs an explicit option.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Add ResolvedLayout in render/svg.rs: live screenshots and recordings
resolve their Style to it, offline snapshots resolve SnapshotRenderOptions
to it, and both draw through render_svg_with_layout and
GridRenderer::with_layout. RenderState gains from_parts so snapshots can
supply resolved colors without a live emulator; encode gains
encode_png_bytes shared by file and memory PNG output. render_svg,
GridRenderer, and encode_png keep their previous behavior.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Public render_snapshot_svg/render_snapshot_png (+ path wrappers) draw a
TerminalSnapshot through the same renderer as live screenshots, with
SnapshotRenderOptions carrying explicit cell metrics, scale, padding, and
chrome switches. Hidden and clipped cursors stay hidden and are recorded
in RenderEvidence with dimensions and title. Tests cover U03-A through
U03-E and U03-G through U03-I.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
@donbeave

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

R14: snapshot_colors passed default_cursor() into RenderState, so a captured red block cursor rendered green. Pass snapshot.cursor().color() instead, matching live RenderState::capture (emu.color(Cursor)). No construction change: the two agree on fresh live captures and may legitimately differ in hand-built snapshots.

R15: 0.25px cells at 40x scale produced viewBox 0 0 0 0 against 40x40 PNG (SVG evidence said 10x10): ceil-then-scale vs scale-then-ceil vs unscaled viewBox rounding disagreed. Like the live Style (too small to draw a cell), offline validate now rejects logical cells under 1px at any scale via RenderError::CellTooSmall, before allocation. SVG/PNG regression tests with distinct cursor colors; fractional-metrics rejection tests at 1x and 40x.
A 3x2 grid with 10.1x20.1 cells at 2x produced three sizes (SVG doc
60.6x80.4 over a rounded 30x40 viewBox, evidence 61x81, PNG 62x82):
PNG sizing ceiled before scaling while SVG/evidence used different
arithmetic. Round once at the pixel-allocation boundary
(ResolvedLayout::output_pixel_size) and use it for PNG pixels, SVG
width/height, evidence dims, and an exact unscaled viewBox. Integer
layouts format byte-identically to before.
Reject orphan continuations and leading cells inside unfinished spans
in both try_new and deserialization, using an explicit remaining-span
counter. Ratatui adapter rejects uncovered zero-width cells with a
coordinate-bearing error.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Live capture recomputed each cell width with display_width, but the
backend can lay a glyph out narrower than it measures: heart+VS16 and
the keycap sequence measure two columns yet occupy one with no spacer.
The recomputed width opened a two-column span the next real cell could
not cover, so valid live output failed with BadContinuation.

Capture now reconciles the recomputed width against the backend's own
occupancy record (the run of continuation spacers behind the cell):
collapse to the occupied columns when the backend is narrower, cover
the attested spacers when it is wider, and keep the measured width for
the clipped-edge form at a row end. Zero-width graphemes still record
width zero. Offline construction and deserialization keep the strict
relational validator unchanged.

Every capture that previously validated emits byte-identical cells; only
previously-failing captures change.
P1: gate ResolvedLayout::panel_pixel_size behind recording-raster so
--no-default-features clippy with -D warnings passes again.

P2 (recorded spans): EmuCell gains the recorded column width; the
offline conversion preserves SnapshotCell.width and all four
backends record occupancy widths. The shared renderers size spans
from the recorded width capped by trailing continuations and the
row end: glyph fitting, clipping, decorations, and cursors cover
width-3 spans fully, clip width-2 leads at the last column, and
skip zero-width leads. Text runs are span-atomic and decorated
blank spans emit their text-decoration. Regression tests assert
painted geometry (textLength/rect widths, decoded pixel extents).

P2 (live SVG zoom): screenshot rejects scaled dimensions past u32
as a usage error instead of panicking in the renderer; an oversized
finite zoom test pins the intentional error.

Docs: PR body no longer claims live behavior unchanged; fractional
live sizing now ceils once at the pixel boundary (1x1 SVG at zoom
0.5: 44x62.5 -> 44x63).

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
@donbeave Alexey Zhokhov (donbeave) changed the title Add public rendering for offline terminal snapshots feat: add offline SVG/PNG snapshot rendering Oct 26, 2026
F1 (breaking change): drop the required public EmuCell.width field and
restore the old seven-field shape, so old struct literals and full
destructures compile again. Recorded widths travel beside the grid as
private render data instead: the offline conversion passes
SnapshotCell.width() alongside the rows (render_svg_with_layout and
Frame.widths), while live grids derive spans from continuations and
zero-width graphemes. All public signatures stay compatible.
Adds tests/public_emu_cell_compat.rs.

F7: emit each leading span as its own SVG text with its own x and
recorded span width instead of joining same-paint neighbors into one
run; continuations are still skipped and decorations preserved.

F8: clamp the thin SVG cursor shapes the way the PNG path does
(underline to cell_h, bar to the span width) so they stay inside 1px
cells.

Tests: same_paint_spans_keep_cell_geometry and
thin_cursor_stays_inside_small_cell in tests/offline_render.rs;
updated run-merging expectations (clipped lead, basic offline SVG,
svg unit tests, default-style golden).

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
The raster underline sat three scaled pixels above the cell bottom, so a
1px cell painted its band two rows above its owner (row 2 underlined row
0); strike-through escaped the same way. Clamp every decoration band into
its owning cell and repaint a block cursor's bands in the contrasting
color, clamped the same way, matching the SVG renderer.

Add decorations_stay_inside_small_cells to the offline render suite.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Alexey Zhokhov (donbeave) added a commit to tailrocks/tuiscotti that referenced this pull request Oct 27, 2026
Migrate tuiscotti onto the tui-test engine as its terminal runtime.

Upstream (`tui-test`) owns the terminal engine: child processes,
emulation, sessions, input, waiting, rendering, fonts, and pixel
comparison. Tuiscotti keeps the application testing layer: pure-view
capture, versioned samples, frozen baseline checks, explicit approval,
static reports, and the offline CLI (`check` / `accept` / `render` /
`report`, exit codes 0/1/2/3). The previous built-in live engine,
renderer, recorder, and second approval engine are removed, leaving the
two shipping crates (`tuiscotti`, `tuiscotti-cli`).

## Provisional fork dependency

The new `tui-test` APIs this PR needs are still unmerged upstream, so
this branch temporarily depends on a pinned fork via `[patch]`:

- `donbeave/tui-test`, branch `goal/qual-stack-r5`, pinned at
`81da26fa9dc29664a897dc802be816a4432c0618`

The pin is provisional. Once the upstream PRs below merge and a registry
release contains the new APIs, the `[patch]` is removed in favor of the
registry version.

## Upstream PRs (all 11, all open)

| PR | Change | Head SHA | Status |
|----|--------|----------|--------|
|
[microsoft/tui-test#276](microsoft/tui-test#276)
| Explicit read-only snapshot policy |
`fc0150160c25ea83532b18f9507f9f1a155b91e4` | Reworked + verified |
|
[microsoft/tui-test#277](microsoft/tui-test#277)
| RGBA image comparison for pixel equality |
`4fb316aa56c65cc659b4a43ee4921c45e33cecc0` | Approved as-is;
dependency-only, no rework |
|
[microsoft/tui-test#278](microsoft/tui-test#278)
| Owned terminal snapshot API |
`334c474d1c621c373333e9dc24e549623a133570` | Reworked + verified |
|
[microsoft/tui-test#279](microsoft/tui-test#279)
| Raw byte input for Rust sessions |
`3b1439c313e88d0a8867dfbcdadabb28624dc5b5` | Reworked + verified |
|
[microsoft/tui-test#280](microsoft/tui-test#280)
| Public rendering for offline snapshots |
`378036846a4e85174104e0f9c153b0a0f8f84161` | Reworked + verified |
|
[microsoft/tui-test#281](microsoft/tui-test#281)
| Configurable quiet waits and request cancellation |
`103d296494f251921f8cd61d3f6ce2f7e152690d` | Reworked + verified |
|
[microsoft/tui-test#282](microsoft/tui-test#282)
| Mode-aware programmatic input |
`212ba1db26987a45537708e98c82db77c90bb734` | Reworked + verified |
|
[microsoft/tui-test#283](microsoft/tui-test#283)
| Explicit underline colors in rendering |
`fff55c122ae210efbe9a68d1ac8683b1a8856d` | Reworked + verified |
|
[microsoft/tui-test#284](microsoft/tui-test#284)
| Explicit pinned-font rendering |
`2bdaa3f8d3692514a6cb9c1ab844e880ca8d0f23` | Reworked + verified |
|
[microsoft/tui-test#285](microsoft/tui-test#285)
| Combined and wide cell text rendering |
`edf2b525d6f8a5710cd56440545437ff4f146727` | Reworked + verified |
|
[microsoft/tui-test#286](microsoft/tui-test#286)
| Explicit child launch settings |
`d5d9bbab9ac4e8a5e0562175497661b7b1a4b158` | Reworked + verified |

"Reworked + verified" means the PR went through a rework round after
independent review, and its per-task suites plus the integrated stack
re-ran green. #277 needed no changes — its head is unchanged since
review — so it is listed as a pure dependency.

Upstream merge order: #278 before #280 and #281; #280 before #283 and
#284; #284 before #285. #276, #277, #279, #282, and #286 are independent
and can merge in any order.

## Verification (at PR head `a056f8f60bae1bf81429b575076c86aed3e6717b`)

- Tests: 209/209 passing in both the default-features and
no-default-features lanes.
- `cargo fmt --check` clean; `cargo clippy --all-targets -D warnings`
clean (default and no-default features).
- Downstream CI: [run
40408516574](https://github.com/tailrocks/tuiscotti/actions/runs/40408516574)
(in progress, at `a056f8f`); prior run
[40390626472](https://github.com/tailrocks/tuiscotti/actions/runs/40390626472)
success at `9ae97ff`.

## Known limitations

- Windows/macOS runner coverage is still pending; platform-specific
behavior (for example raw-byte PTY paths) is unverified there.
- The upstream PRs currently report only CLA checks; Rust CI on the
upstream side still needs maintainer workflow approval, so upstream-side
proof rests on the recorded per-task suites plus this downstream
integration.
- No crates.io release contains the new APIs yet, so registry adoption
is blocked until the upstream PRs merge and release. The provisional
fork pin above supports merging this PR only; it is not a release path.

## Depends-on / merge order

This PR depends on all eleven upstream PRs merging (in the order noted
above) plus a registry release; only then does the provisional `[patch]`
come out. Nothing in this PR can ship to crates.io before that. Upstream
maintainers: the stack is additive-only, and the pinned fork branch
tracks the exact heads listed above.

---------

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Signed-off-by: Muse Code <muse-code@meta.com>
Signed-off-by: Muse Code <muse@example.local>
Co-authored-by: Muse Code <muse-code@meta.com>
Co-authored-by: Muse Code <muse@example.local>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant