Architecture
suit is one layer of a three-layer system, the same division of labour Flutter draws between its widgets, its elements, and its render objects. Two of those layers already exist…
suit is one layer of a three-layer system, the same division of labour Flutter draws between its widgets, its elements, and its render objects. Two of those layers already exist in vdom; suit builds the third.
Three trees
Widgets / VNodes ── what you write: col(...)(box(...), text(...)) ← vdom
Elements ── the reconciled, stateful instance tree ← vdom
RenderObjects ── layout, paint, hit-test ← suit
- The VNode tree is the declarative description your component returns. It is cheap and
rebuilt freely. vdom’s DSL produces it; suit’s DSL (
box,row,col, …) is just a vocabulary of VNodes whose tags name render-object kinds. - The element tree is vdom’s reconciler at work: it diffs successive VNode trees, holds
hook state (
useState), and decides the minimal set of host mutations. This is entirely vdom — suit adds nothing here. - The RenderObject tree is suit. Each node lays itself out, paints itself, and answers
hit-tests. The reconciler creates and mutates these nodes through a
HostConfig; every opaque host node it holds is really aRenderObject.
vdom is host-agnostic: the same reconciler and hooks drive the browser DOM in
riposte and the screen here. The only thing that
changes per host is the HostConfig binding and the leaf layer underneath it.
You never import vdom directly. suit re-exports its consumer-facing surface — view,
component, the hooks (useState, useEffect, …), and the VNode model — under
io.github.edadma.suit, so an application imports only io.github.edadma.suit.* (plus
suit.dsl.* / suit.widgets.*). vdom is an implementation detail, the way the browser’s
DOM engine is to a web page.
The host binding
SuitHostConfig is the single seam where vdom meets suit’s renderer — the analogue of
riposte’s DOM host config, but creating retained render objects instead of DOM nodes. The
reconciler calls it to create elements, edit the tree, set properties, and register
listeners:
def createElement(tag: String, namespace: String | Null): AnyRef = tag match
case "box" => new RenderBox
case "row" => new RenderFlex(Axis.Horizontal)
case "col" => new RenderFlex(Axis.Vertical)
case "padding" => new RenderPadding
case "sizedBox" => new RenderConstrained
case "stack" => new RenderStack
case "text" => new RenderText("")
case _ => new RenderBox
Properties travel as typed values, not strings. vdom’s PropValue channel carries a
Color, an EdgeInsets, an Alignment, or a layout enum with its real type straight to
the matching render-object field; nothing is stringified and re-parsed. DOM-shaped
HostConfig methods (setStyle with CSS, setInnerHtml, namespaces) have no meaning on a
pixel canvas and are no-ops.
What a RenderObject does
Every render object implements three jobs:
abstract class RenderObject:
def layout(constraints: Constraints): Unit // size self, position children
def paint(canvas: Canvas, origin: Offset): Unit // draw back-to-front
def hitTest(point: Offset, origin: Offset): RenderObject|Null // deepest object under a point
The kinds map one-to-one onto the DSL: RenderBox (styled container), RenderFlex (row /
column), RenderPadding, RenderConstrained (sized box), RenderStack (z-stack /
align / center), RenderText, plus a RenderRoot at the top and a RenderAnchor for
vdom’s fragment/portal/empty placeholders.
The paint seam
Painting goes through a Canvas trait — the boundary between what to draw and how:
trait Canvas:
def fillRect(rect: Rect, color: Color): Unit
def strokeRect(rect: Rect, color: Color, width: Double): Unit
def fillCircle(center: Offset, radius: Double, color: Color): Unit
def line(a: Offset, b: Offset, width: Double, color: Color): Unit
def strokePath(path: Path, paint: Paint, width: Double, ...): Unit // vector outline
def fillPath(path: Path, paint: Paint): Unit
def drawText(origin: Offset, text: String, style: TextStyle): Unit
def measureText(text: String, style: TextStyle): Size // size of a line, undrawn
A Path is a plain value — a list of move/line/arc/close segments built with Path.builder() (or
Path.polyline(points) for an outline) — so it records and asserts on RecordingCanvas like any
other call. The Cairo backend replays it into the engine’s own path, so a stroked shape gets real
line joins; this is the same move/line/arc model HTML canvas and PDF share, and exactly what Cairo
already exposes underneath.
On Native, CairoCanvas draws through Cairo — a real 2D
vector engine, so every fill, stroke, and glyph is anti-aliased by its coverage rasteriser.
SDL3 only creates the window, reads input, and presents the
finished frame. In tests, RecordingCanvas captures the same calls so paint output can be
asserted on. The same trait split lets TextMeasurer size text off-device (a deterministic
fake in tests, a Cairo-backed measurer at runtime), which keeps the whole layout pass
JVM-testable. measureText exposes that measurer through the canvas, so a live-surface
painter (a useFrame-driven canvas) can centre or right-align its own text exactly where
drawText will place it — the two consult the same measurer, so they agree by construction.
Why JVM-testable matters
The layout engine, the render tree, and the geometry are pure Scala with no SDL
dependency — they live in shared/ and cross to a JVM target whose only purpose is
tests. So sbt suitJVM/test exercises real layout, paint (against RecordingCanvas), and
input routing with no window and no native toolchain, the same way vdom’s reconciler is
tested headlessly. This is the whole reason the layout engine was kept FFI-free: no Yoga,
no C solver, just a recursive constraint negotiation you can step through in a debugger.
The runtime
Suit.run is the Native entry point. It owns the SDL window and renderer, installs the
host binding and the two scheduler seams (a microtask queue for re-renders, a macrotask
queue for passive effects), mounts the app, and runs the frame loop. The loop is the bridge
between vdom’s React-style batched updates and a game-style render loop: vdom never paints
on its own — it enqueues work, the loop drains it, the tree is marked dirty, and the loop
repaints only when something changed.
One rendering detail is worth knowing: Cairo draws the frame into an in-memory ARGB32 image
surface, which is uploaded to an SDL streaming texture and blitted to the window each
iteration (re-drawn only when the tree is dirty). Cairo’s ARGB32 layout is byte-identical to
SDL’s ARGB8888 on a little-endian host, so the upload is a straight copy with no
conversion. SDL never draws a shape and Cairo never touches the OS — the clean split between
the graphics engine and the platform layer.
HiDPI
On a high-density (“Retina”) display the window’s logical size and its pixel size differ
— an 800×600 window may have a 1600×1200 backbuffer at a 2× scale. suit handles this without
the application or the widgets ever seeing it: everything you write — layout, sizes, hit-test
coordinates, the positions in pointer events — stays in logical units. At startup the
runtime asks SDL for the window’s size in pixels, sizes the Cairo surface and the SDL texture
to those real pixels, and scales the Cairo context by the pixel-to-logical ratio. So a tree
laid out in logical coordinates rasterises at the display’s true resolution, and every edge
and glyph lands on physical pixels rather than being stretched up after the fact. On an
ordinary 1× display the ratio is 1 and this path is a no-op. The small ratio computation is
the one piece that crosses into shared/ (DeviceSurface) so it stays unit-tested.
Repaint boundaries
Repainting only when something changed is the first half of efficient redraw; the second
is repainting only what changed. A typical app — a control panel of widgets next to an
animating canvas — would otherwise re-rasterise the entire window on every animation frame,
throwing away the static UI’s pixels just to draw them again. suit avoids this with repaint
boundaries, the same idea as Flutter’s RepaintBoundary.
A boundary is a render object that caches and repaints independently of the rest of the tree.
The root is a boundary — the whole window — and a canvas is a nested one. When a change
occurs, markDirty walks to the root and marks the nearest enclosing boundary: a change
in the static UI reaches the root (so the whole scene re-rasterises), while a change inside a
canvas stops at the canvas (so only its region does). The drawing surface is persistent —
on a partial frame the runtime never clears it wholesale — so the static UI’s pixels from the
previous frame simply remain while the canvas’s region is repainted in place over them.
Canvases are also live surfaces: a canvas stepped by useFrame reads mutable application
state the reconciler never sees, so there is no markDirty to locate the change. A frame
request (the Repaint seam) instead marks every live surface for repaint and leaves the rest
cached. Either way, the per-frame cost while a canvas animates is one canvas region, not the
whole window. The decision logic (which boundary a change marks, which regions a partial
frame repaints) and the in-place boundary repaint (Compositor.repaintBoundary, written
against the Canvas seam) both live in shared/, so they are unit-tested headlessly.
Boundary content is assumed to cover its bounds opaquely — the floor under a partial repaint is a fill to the window background, not whatever happened to be behind the boundary. That holds for a drawing surface that paints its own background, which is the case a canvas serves. A fully general layer tree — cached offscreen surfaces a boundary can re-composite without re-rasterising, group-opacity layers, partial texture upload of just the damaged rect — is a further step beyond this region-level model.