isCanvas under load: where the seam forces a backend to lie
Status: evidence, not a proposal. Last reviewed: August 23, 2026.
sparkles:ui draws through one seam — the Design-by-Introspection concept isCanvas!T in libs/ui/src/sparkles/ui/canvas.d. Three backends implement it: a terminal cell grid, raylib, and Skia. This is the list of places where the seam makes one of them say something it does not mean.
Each entry says what the seam does, not what should replace it. Ordered by how much it constrains the design, not by how annoying it is.
1. measure is denominated in cells, so the best measurer in the system must lie
Size measure(scope const(char)[] text) returns cells. SkiaCanvas.measure therefore ignores Skia entirely and returns cellsOf(text) — the Unicode width — because layout has already committed to a grid and an honest pixel answer would disagree with it.
This is the deepest one. The backend with real shaping, kerning and fallback is structurally forbidden from using any of it, and a proportional font is not merely unsupported but inexpressible: there is no unit in the seam that could carry the answer. RND6 records the monospace constraint as a rendering decision; it is in fact a vocabulary decision, and it is made here.
2. The concept describes five methods; the real contract is eight
isCanvas checks fillRect, textRun, glyph, line, measure. But OpKind has eight members, and rule, scrollbar, pushClip/popClip are discovered by __traits(compiles) at each interpreter call site instead.
So the concept does not describe the contract — the interpreter does, by introspection, in several places. A backend author cannot learn the real surface from isCanvas, and static assert(isCanvas!T) passing says much less than it appears to.
3. scrollbar is a widget concept living in the drawing seam
A canvas implementing the optional scrollbar primitive receives content extent, viewport extent, scroll offset, thumb colour, track colour and alpha, a lit-track flag, a rail-expansion percentage, an edge, and two fallback glyphs — and is expected to know what a scrollbar is and how to degrade one.
The reason is sound: a cell backend degrades a scrollbar differently from a pixel backend, so the semantics have to survive the seam. The consequence is that "draw" and "what a scrollbar is" are the same layer, and that trackGlyph and thumbGlyph — a cell backend's answer, carried past every backend that will never use them — ride in the drawing vocabulary.
4. The encoding is neither @safe nor variable-width
DrawOp is a SumType over eight per-kind payloads, and two of those payloads carry indirections: TextRun.text is a slice, FillRect.chrome a pointer. That is enough for std.sumtype to mark opAssign @system, which would put a plain DrawOp[] out of reach of the @safe pure nothrow @nogc display-list walk. The seam answers with two @trusted islands — the assignment itself, and a private launder helper that casts a slice through a size_t purely so dip1000 stops confining it to the operation's lifetime instead of the arena's.
Both are sound, and both are load-bearing rather than incidental: remove either and @safe consumers stop compiling. A seam whose safety rests on a cast that exists to defeat the compiler's lifetime analysis is a seam whose ownership model the type system cannot express.
Separately, every operation is as wide as the widest payload — static assert(DrawOp.sizeof <= 64), currently TextRun — so a popClip that carries nothing costs what a text run costs.
5. Sub-cell placement is spelled as a compass direction
The toolkit has no unit below one cell, so rule names an edge (RuleEdge.top, centerX, …) and each backend decides what a band along that edge means. SkiaCanvas draws one device pixel; GridCanvas fills a whole cell.
It works, and it is honest about the degradation. But it covers exactly six predefined positions, and anything else sub-cell — a two-pixel focus ring, a badge inset, an underline offset — has no spelling at all. The same pressure that produced RuleEdge will keep producing more enumerators.
6. Every drawing operation carries both a resolved appearance and a semantic role
Six of the eight payloads store a Slot — the semantic role — beside the resolved colour the primitive actually paints from. Pixel backends read the resolved half; the HTML interpreter re-resolves from the role to emit class names.
The seam hedges rather than deciding, and every drawing operation pays for both. That Visual is reconstructed on demand rather than stored makes the hedge cheaper; it does not make it a decision.
7. DrawOp.text is borrowed, and the borrow is not expressible
The bytes live in a frame arena, and the rule — an operation is valid while the buffer that built it is alive and unreset — is stated on the type, which is what makes it enforceable rather than advisory.
But it remains a borrow. The operation cannot cross a thread, cannot be retained past the frame, and needs the launder cast of §4 to escape dip1000 at all. A GPU backend that wants to record on one thread and submit on another meets this immediately. Tracked as UI-O4, which stays open on exactly this question.
8. A backend cannot ask the display list how big it is
skia-canvas-render.d derives the extent by scanning every operation's rect, because nothing carries it. A backend that allocates its own surface — which is every offscreen and every golden test — needs that number before it can paint.
The first version of that example guessed instead, and silently cropped its own text; the golden then pinned the crop. The scan works only because a TextRun's rect.width happens to be its advance in cells.
What did not cause friction
Worth recording, so the proposal does not "fix" things that are working:
- Structural typing over an interface. Attribute inference genuinely works: a
@systemGPU canvas and a@safe @nogcrecorder satisfy one seam with neither lying. Keep it. - The optional-primitive pattern itself. Probing for
pushClipand painting unclipped without it is a good bargain — the problem in §2 is that the concept does not say so, not that the mechanism is wrong. - Cell-space geometry for layout. For a terminal-first toolkit this is right; §1 is about
measure's return type, not about laying out in cells. RecordingCanvasas the reference implementation. Having a canonical conforming backend that is also the test seam caught real mistakes.- Reifying the operation stream at all. A painter walks values it can collect, replay and compare; the op-stream parity harness exists because of it. §4 is about how the values are encoded, not about whether to have them.