Skip to content

feat: add PNG screenshot output - #199

Merged
cpendery (cpendery) merged 2 commits into
cpendery/named-restartfrom
cpendery/png-screenshots
Sep 11, 2026
Merged

cpendery (cpendery) merged 2 commits into
cpendery/named-restartfrom
cpendery/png-screenshots

Conversation

@cpendery

Copy link
Copy Markdown
Member

No description provided.

@cpendery
cpendery (cpendery) changed the base branch from main to cpendery/named-restart September 8, 2026 23:05
@cpendery
cpendery (cpendery) removed this pull request from stack #205 September 10, 2026 17:17
@cpendery
cpendery (cpendery) added this pull request to stack #218 September 10, 2026 17:17
@cpendery
cpendery (cpendery) marked this pull request as ready for review September 10, 2026 22:22
@cpendery cpendery (cpendery) changed the title Add PNG screenshot output feat: add PNG screenshot output Sep 11, 2026
@cpendery
cpendery (cpendery) merged commit fedacc0 into main Sep 11, 2026
13 checks passed
An error occurred while trying to automatically change base from cpendery/named-restart to main September 11, 2026 20:55
cpendery (cpendery) pushed a commit that referenced this pull request Oct 16, 2026
…rofile (#220)

Screenshots and recordings are drawn from constants, so the only way to
change how output looks is to edit the renderer. This makes appearance
configurable from `tui-test.toml`, under `[recording.style]`, and lets
each profile override it.

## What it looks like

Every option on its own, each a single change from the default. Tiles
are scaled by one factor, not fitted to a column, so the sizes here are
the sizes rendered:

<img alt="every tui-test style option shown one change at a time"
src="https://raw.githubusercontent.com/microsoft/tui-test/db6edabc21c2f3eb7b032434275ee1729469610d/static/style-options.png"
width="860">

`font_size` drives the cell size, so the whole image grows with it:

<img alt="the same session at four font sizes"
src="https://raw.githubusercontent.com/microsoft/tui-test/db6edabc21c2f3eb7b032434275ee1729469610d/static/style-font-sizes.png"
width="860">

There are two gaps, and each side of either can differ. `canvas_padding`
is 24 on every side; `content_padding` is 8 top, 15 at the sides, 14 at
the bottom. Either takes `0`, and one number insets the grid evenly once
the title bar is gone:

<img alt="the same session with padding removed and with even padding"
src="https://raw.githubusercontent.com/microsoft/tui-test/db6edabc21c2f3eb7b032434275ee1729469610d/static/style-padding.png"
width="700">

A recording is drawn from the same style as a screenshot, so a `.gif`
matches its `.svg`:

| Default | Midnight | Bordered |
| :---: | :---: | :---: |
| <img alt="a tui-test recording in the default style"
src="https://raw.githubusercontent.com/microsoft/tui-test/db6edabc21c2f3eb7b032434275ee1729469610d/static/style-anim-default.gif"
width="270"> | <img alt="a tui-test recording in the midnight style"
src="https://raw.githubusercontent.com/microsoft/tui-test/db6edabc21c2f3eb7b032434275ee1729469610d/static/style-anim-midnight.gif"
width="270"> | <img alt="a tui-test recording in the bordered style"
src="https://raw.githubusercontent.com/microsoft/tui-test/db6edabc21c2f3eb7b032434275ee1729469610d/static/style-anim-bordered.gif"
width="270"> |
| no `[recording.style]` | dark chrome **and** palette | a stroke
instead of a shadow |

Every image here and in the README was generated by the CLI from the
config shown beside it.

## The knobs

```toml
[recording.style]
font_size = 20
title_font_size = 14
canvas_background = "#101014"
canvas_padding = 32              # around the window; per side below
content_padding = 15             # between the window and the grid

[recording.style.font]
family = "Berkeley Mono, monospace"
bold = "Berkeley Mono Bold"
files = ["fonts/BerkeleyMono.ttf"]

[recording.style.window]
title_bar = true
traffic_lights = false
background = "#18181c"
foreground = "#eeeeee"
divider = "#303038"

[recording.style.border]
width = 1
color = "#454550"
radius = 8

[recording.style.shadow]
enabled = true
color = "#080812"
offset = 5
spread = 7

# Either gap can be set per side; a side left unnamed keeps its own default.
[recording.style.canvas_padding]
bottom = 48
```

## Per profile

A profile carries its own `[recording]` and overrides the file's key by
key, at every depth. What matters is what it *keeps*:

```toml
# What every profile gets unless it says otherwise.
[recording]
directory = "./artifacts"

[recording.style]
font_size = 17
canvas_background = "#101014"
canvas_padding = 30

[recording.style.window]
background = "#1a1a22"
foreground = "#d8d8e8"
divider = "#2a2a36"

# Docs: record somewhere else, in bigger type.
[profiles.docs.recording]
directory = "./docs/media"

[profiles.docs.recording.style]
font_size = 24

# CI: keep the file's directory, strip the chrome so artifacts stay small.
[profiles.ci.recording.style]
canvas_padding = 8

[profiles.ci.recording.style.window]
title_bar = false

[profiles.ci.recording.style.shadow]
enabled = false

# Demo: a light look for slides.
[profiles.demo.recording.style]
canvas_background = "#f6f6f8"
font_size = 20

[profiles.demo.recording.style.window]
background = "#e8e8ef"
foreground = "#33333a"

[profiles.demo.recording.style.border]
width = 2
color = "#8a8aa0"
```

<img alt="three profiles overriding the same file-level recording
settings"
src="https://raw.githubusercontent.com/microsoft/tui-test/db6edabc21c2f3eb7b032434275ee1729469610d/static/style-profiles.png"
width="860">

| Profile | directory | what it keeps from the file |
| --- | --- | --- |
| `docs` | `./docs/media` *(own)* | the canvas, its 30px padding, the
title bar colors |
| `ci` | `./artifacts` *(file)* | the font size, and the title bar
colors it did not name |
| `demo` | `./artifacts` *(file)* | the 30px padding and the divider it
did not name |

`ci` names no directory at all and still gets the file's; it names one
key inside `[window]` and keeps the siblings. That config is a file the
test reads, so the example here, the one in the reference, and the one
under test cannot drift apart.

Note that **whether** a recording is kept is not a `[recording]` key.
`main` moved retention to `[trace] mode`, and the engine derives the
recording mode from it, so `[recording]` carries the directory and the
style only.

## Worth a closer look

**Config that did not mean what it said.** `border.width` and
`border.color` were accepted and validated by a renderer that never drew
them, and the raster path resolved faces through a process-wide catalog
built before any config was read — so `font.family` changed a screenshot
and not a recording of the same session. Both are now drawn. The catalog
keeps the expensive system scan process-wide and computes only the
style-dependent ranking per style; building it under the lock rather
than beside it is the difference between 38s and 0.6s across the font
tests.

**Inheritance.** A profile naming one style key used to replace the
file's style whole, falling back to the *built-in* defaults rather than
the file's — so `font_size = 24` silently discarded the background and
padding the file had set. It now merges key by key. Serde has already
filled absent keys by the time the typed config exists, so which keys a
profile actually named is recovered from a second raw parse; the typed
parse still runs first and is the one that reports errors, with spans
intact.

**The gap above the grid** was 4px against 15px at the sides, so text
sat against the title bar — and against the panel edge with `title_bar =
false`. It is now 8: rendering 4, 8, 12 and 15 side by side, 8 is where
the text reads level with the side inset, because a row already carries
leading above its glyph while a character sits tight in its cell
horizontally. That is a deliberate change to the default look, so the
golden was regenerated and the tests that pinned a dimension now derive
it.

**Reading font files.** A config file is found in the working directory,
so checking out an untrusted repository hands one to the tool.
`font.files` went straight to fontdb, which reads a path to the end:
`files = ["/dev/zero"]` climbed to 5 GB of RSS in about a second, and a
fifo blocked forever — both while holding the process-wide catalog lock,
which made the wedge permanent. Font files are now read here, only when
regular and only up to a cap, and lock poisoning is recovered rather
than fatal.

**Three backgrounds.** A recording has the area around the panel, the
title bar, and the terminal's own under `[colors]`. Two were called
`background`, so the outermost pair is now `canvas_background` and
`canvas_padding`.

**`--profile default`** errored on any config without a
`[profiles.default]` table, although the flag's own help says it
defaults to `default`. Pre-existing, found while checking the
inheritance above; naming the default is now the same as omitting the
flag, and every other unknown profile is still an error.

## Merged with what landed on main

Four PRs landed while this was open and all touched the same surface.
Each was resolved by integrating rather than picking a side:

- **`Customize capture backgrounds` (#197)** added `--background` /
`--transparent`. `render_svg` and `GridRenderer` now carry both the
style and the capture background; the flags override the configured
canvas for one capture, and naming neither leaves the style in charge —
where main previously fell back to a hardcoded constant.
- **`add named session restart` (#198)** replaced `spawn`'s positional
parameter list with a `SpawnSpec`. Took main's shape wholesale and let
`style` join the destructured options, which is strictly better: the
spec is stored for restart, so a restarted session keeps its style for
free.
- **`add PNG screenshot output` (#199)** added `exact_pixel_size` and an
`exact_size` flag for un-rounded screenshot dimensions. Both are now
style-aware, so a PNG screenshot honours the configured gaps exactly as
the SVG does.
- **`shorten terminal reference guidance` (#219)** merged cleanly.

## Testing

`cargo fmt`, both clippy invocations, the full workspace suite, the
feature-gated suite, and the Python and JS binding suites all pass. **CI
is green on all 13 checks.**

Generating the images is also the end-to-end exercise: config file to
daemon to session to `screenshot` and `record`, rather than a unit test
handing a `Style` to a renderer. Writing the reference documentation is
what caught four wrong defaults and the fact that a family is a CSS font
stack that face selection was treating as one unmatchable string; the
documented defaults and the per-profile example are pinned by tests
rather than trusted. The golden rewrites itself under
`TUI_TEST_UPDATE_GOLDEN` so a deliberate restyle lands in a reviewable
diff.

Because the default gap above the grid moved, every test that pinned an
exact pixel size had to follow it: the Rust suites, the Python and JS
binding suites, and the Windows-gated render snapshots. Those were
updated rather than loosened — the numbers are the point of those tests.
The snapshots were regenerated with `UPDATE_TUI_TEST_RENDER_SNAPSHOTS`,
after first checking that this machine reproduces the committed Windows
bytes exactly with the old gap, which shows the render does not depend
on the platform's fonts.

---------

Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
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