282. A shaped run is a value, and the last leak is one method
Date: 2026-09-12
Status
Accepted, and the seal is one method short — stated here rather than left as
an absence. Closes most of docs/gaps.md G14.
Context
ADR-0280 sealed Yoga’s
three packages and recorded that -Xlint:exports under -Werror, with
requires transitive removed, names every remaining leak by file and line. It
named eleven sites in three APIs, all in the text stack, none with a consumer
outside :core.
Decision
GlyphRun and TextDirection are mirrored into :core as text.ShapedRun and
text.TextDirection, and HarfBuzz’s two packages are sealed:
exports io.github.digitalsmile.goldberry.natives.harfbuzz to
io.github.digitalsmile.goldberry.core;
exports io.github.digitalsmile.goldberry.natives.harfbuzz.enums to
io.github.digitalsmile.goldberry.core;
Checked by compiling a module that imports GlyphRun and watching javac refuse
it, the same way ADR-0280’s Yoga seal was checked.
ShapedRun is six int[] and nothing else — no foreign memory, no lifetime,
nothing to close. Font.shape copies out of the shaper’s run in one pass; the
copy is a doubling of something that happens once per text change rather than
once per frame, and the alternative is handing an application a :natives class.
TextDirection keeps only LTR and RTL. The shaper’s vertical directions are
deliberately absent: nothing in the toolkit lays out a vertical line, and an
enumerator that can be named and would then be dropped downstream is worse than
one that cannot be named.
What is left, and why it is a decision rather than a transcription
One method:
public void drawGlyphs(double x, double baseline, BlendFont font, BlendGlyphBuffer glyphs, int argb);
Font.draw is its only caller. It stages a run’s glyphs into a buffer it owns
and hands both to the frame.
The other two leaks were values, and a value can be mirrored. These are
handles, and the difficulty is not transcription but ownership:
rasterizing a glyph needs a context, a font and a staged buffer; paint owns the
first and text.font owns the other two. One of them has to cross, and within a
single module Java offers nothing between package-private and public — so
whichever crosses, crosses in a signature an application can read.
Three ways out, none of them free:
- Move the native font into
paint.paintgains a pen that owns theBlendFontand the buffer;text.fontbecomes shaping and metrics, andFrame.drawGlyphsgoes package-private. The cleanest end state, and it moves font creation — which today isFontFace’s, and which the fallback chain and the paragraph cache are both built on. - Wrap the handle in an exported opaque type. Does not work: a public method of an exported type may not name a type from a package that is not exported either, so the wrapper has to be exported and then its accessor cannot be package-private. It relocates the warning.
- Leave it. One documented method,
blend2dstays exported, and:corekeepsrequires transitive.
This ADR takes (3) for now and records (1) as the answer, because moving font ownership is a change to the text stack’s shape and deserves its own decision rather than being improvised at the end of a sealing pass.
Consequences
- Three of
:natives’ five families are sealed —yoga,yoga.style,yoga.measure,harfbuzz,harfbuzz.enums.blend2dandsdlare not:blend2dfor the method above, andsdlbecause nothing has looked at it yet. :corestill declaresrequires transitive, and the comment in its descriptor says which method is why. Removing the word is how the remaining work is enumerated; it costs one build.ShapedRunis not a record, deliberately: a record over six arrays hands them out through its accessors for anyone to write into, and a shaped run is a value a paragraph cache returns repeatedly.ShapedRun.ofcopies.Paragraph.glyphs()andFont.widthOfstill have no callers. They were public before and stay public; deleting unused API is a separate question from sealing a module, and answering both at once would have hidden one in the other.- No golden image moved. The shaping is identical; only the type the result is carried in changed.
Alternatives considered
- Mirror the handles as well. They are not values. A
BlendFontis a native allocation with a thread and a lifetime, and a “mirror” of it is a wrapper — which is option (2), which does not work. - Delete
Frame.drawGlyphsand inline it intoFont.Fontwould need theBlendContext, which is the same crossing in the other direction and a worse one: the context is the frame’s most dangerous object. - Keep
GlyphRunand seal nothing. The two mirrors cost one copy per text change and bought a whole family. Waiting for the third would have meant shipping neither.