284. A picture with no window under it
Date: 2026-09-12
Status
Accepted. Closes docs/gaps.md G5, whose other half — Image.encodePng() —
landed with ADR-0283.
Context
G5: “The pieces exist and none of them is a supported entry point:
render.PixelBuffer.allocate, Frame.over(PixelBuffer, DisplayScale), a
render.backend.headless backend — and the golden-image harness lives in
:core’s testFixtures, which is not published to applications.”
That is exactly right, and it understates the problem. The pieces are public; the sequence is not, and the sequence is the part that is hard. Painting a painter into a buffer is four lines. Rendering a widget tree is:
prepare → flush → render → update → capture the regions → advance the clock
→ prepare → flush → render → update → capture the regions
→ prepare → flush → render → update → paint
and every one of those steps is there because leaving it out produced a picture
that was wrong in a way nobody would notice for weeks. The only two places that
knew it were Launcher, where it is private and interleaved with damage
tracking, frame statistics and a window; and GalleryGoldenTest, where it was
copied, with thirty lines of comments explaining what each step was protecting
against.
A third copy was going to be written by the first application that wanted a server-side preview, and it was going to leave steps out.
Decision
var png = Offscreen.of(1200, 900)
.stylesheets(Controls.stylesheets(Theme.NORD_DARK))
.render(new BoardPreview(document))
.encodePng();
io.github.digitalsmile.goldberry.offscreen.Offscreen, a builder with two
terminals: paint(Painter) runs a painter over the whole buffer, and
render(Widget) runs the window’s own sequence. Both return an
image.Image, so a PNG is one more call and a crop or a composite is
Frame.drawImage.
Its own package, not render.Offscreen as G5 proposed. render is the
backend SPI — what a platform implements, underneath everything else — and this
composes the layers above it: the element tree, the cascade, the render tree,
the paint pipeline. A widget renderer inside the backend package would point the
toolkit’s own layering at itself. image.Image had the same question and the
same answer (ADR-0283).
Three passes, and the third one is not a rounding of the second
render(Widget) lays the tree out twice, drawing nothing, and then builds,
lays out and paints it once:
- Pass one mounts and measures. A newly mounted element starts no
transition, and a clock-driven arrival has no beginning until something reads
the clock — so a tree painted here shows every arriving widget at the start
of its entrance, which for a
messageis a banner at zero opacity holding its space and drawing nothing. - The clock advances past the transition duration. It is virtual: a
preview that depended on when it was taken would differ between two requests
for the same document, and one
spinneris enough to make that happen. - Pass two measures again, and the regions from pass one have by now been
delivered —
Measured, through the router, from the rectangles a laid-out frame produced. That is how atext-arealearns how wide it really is and how amasonrylearns how tall its columns came out. - The third pass is the picture, and it is a third pass rather than the second one painted because a widget told its size may rebuild in response. Painting pass two photographs every self-arranging widget one move from settled.
The measuring passes rasterize nothing at all, so the cost is one paint and three layouts rather than three of each.
The golden harness is now a consumer of it
GoldenImage renders through Offscreen instead of opening its own frame, and
so does the scale sweep beside it. Every golden image in this repository — around
a hundred of them, at three scales — is therefore a test of the API an
application would use to take the same picture. If Offscreen and a window ever
disagree about how a scene is drawn, a golden moves.
GalleryGoldenTest lost its copy of the sequence and its thirty lines of
commentary along with it.
What the goldens said when they moved
Nine gallery images changed, and the reason is a bug this found.
The old harness never called ElementTree.flush(). Launcher.paint() calls it
on every frame, so a setState triggered by the region feedback — a
masonry rearranging its columns when it is told how wide they came out — was
applied in a window and never applied in a golden. The images that were committed
showed an arrangement the application does not draw.
The evidence is exact: removing only the flush() from Offscreen reproduces
all nine old goldens byte for byte, with every other difference between the two
implementations still in place. It is that one call.
So the nine images were re-blessed. Card distribution across masonry columns is what changed in them; no colour, no size, no text and no control state did.
Consequences
Image.of(PixelBuffer)is new: an image over pixels somebody else rasterized, handed over rather than copied. An offscreen render would otherwise copy a megabyte to say what it had just drawn. The buffer is the caller’s to stop writing to, which isPixelBuffer’s existing doctrine — andasReadOnly()is how it can be made impossible rather than agreed.- The teardown order is load-bearing. A frame’s Blend2D context is
asynchronous, so
end()is what joins the workers — and they are still holding the fonts, paths and layer rasters the tree lent them.Offscreentherefore ends the frame, then closes the render tree, then unmounts. Getting it wrong is not an exception: the first widget whoseStateowned aFontand closed it indispose(the showcase’s sticky, ADR-0285) turned a wrong order into a SIGSEGV in Blend2D’s command processor, in a worker thread, intermittently. - A font book is opened and closed per render unless one is given. A server
rendering many previews should hand over a
Fontsand keep it; the javadoc says so at the method that costs it. settle(int)is the one knob on the clock, and there is no way to ask for a system clock. A preview that is not reproducible is not a preview.- No
Offscreenreuse across calls. Each render builds a fresh element tree and unmounts it, so two renders cannot share state through one and aState’sdisposeruns. It also means the builder is not a cache: rendering the same document twice does the work twice. - The headless backend is still not involved. It never was:
Framepaints into memory, and a backend is about presenting. G5 named it because it is what a reader expects to need — seeGoldenImage’s own note, which has said “no window, no compositor and noxvfb” since M1. - An animation strip is not supported. One call, one picture: a caller wanting frame 3 of a transition would want to drive the clock between paints, which is a different object with a lifetime — and nothing has asked for it.
Alternatives considered
render.Offscreen, as G5 proposed. The layering above; andrenderis the package an application is least expected to reach into.- Extract
Launcher‘s loop and have both call it. The right answer on paper and a refactor of the hot path — damage, statistics, the HUD, the frame ring and the models’ refresh are all woven through those forty lines. The duplication is real and is now two copies rather than three, with the goldens holding them together: every one of them goes throughOffscreen, andOffscreendoes whatLauncherdoes. - Paint the second pass and skip the third. One less layout, and every self-arranging widget photographed mid-settle. This is what the first implementation did, and the gallery’s Basic screen is what caught it.
- A system clock with an option for a virtual one. Backwards: the default has to be the reproducible one, because the caller who does not think about it is the caller writing a cache key.
- Return a
PixelBufferrather than anImage. It is what the render produces, and it is the type with a borrowed-buffer doctrine attached.Imageis the value that can be drawn, encoded, cached and put in a field — which is what every caller of this does next.