Skip to content

sparkles:ui layout — Feature Requirements (LAY)

Status: decided · Date: 2026-07-29 · Scope: the layout level of sparkles:ui — the model, its units, the measure protocol, the sizing vocabulary, clipping and scroll, alignment, track sizing and text wrapping. This page is the decision record the UI-layout research catalog was gathered to settle.

IMPORTANT

The catalog deliberately states no verdict — its index notes that a comparison page "may be added here as the catalog matures". This page is that verdict. Requirements below cite the catalog as evidence; the choices are made here.

Design & rationale

The verdict

Keep the box-flow engine; add an orientation-aware measure protocol and a clip primitive; keep every unit an integer.

The existing two-pass engine (bottom-up intrinsic measure, top-down place) is already the family the survey recommends. What it lacks is not a different algorithm but three specific capabilities: a measure pass that knows the available width, flexible sizing that distributes leftover space, and clipping.

The surveyed families

FamilyRepresentativesVerdict
Box-flow, two-pass per axisClayAdopted. Already our shape; O(n); allocation-free; its fit/grow/fixed/percent + min/max vocabulary is the catalog's first taxonomy row verbatim.
FlexboxYoga, Taffy, Stretch, CSSVocabulary adopted, algorithm rejected. A faithful implementation is 5–10 KLoC, and float coordinates are a documented terminal bug class (off-by-one cells on resize).
Constraints down, sizes upFlutter, Compose, SwiftUIMeasure protocol adopted, engine rejected. Its intrinsics idea is what we need; adopting it wholesale would delete the bottom-up pass that grow sizing requires.
Constraint solversCassowary, Kiwi, Auto LayoutRejected. Thousands of lines of meticulous code, notoriously poor debuggability; the catalog's own conclusion is that for tree-shaped terminal UIs box-flow is right.
Classic retained two-passWPF, Qt, GTK, Swing/MiG, TkOne idea adopted: GTK's orientation-aware measure(orientation, for_size), which the catalog calls rare among widget toolkits. Star sizing informs the track sizer.
Immediate-modeDear ImGui, eguiRejected as a model (cursor-based layout fights responsive design), but egui's orthogonal alignment toggles inform LAY8.
Tiling window managersi3/sway, xmonadTwo ideas adopted: proportional sizing as the default for content areas, and an introspectable serialized tree as a debugging tool (LAY12).
Line breakingKnuth–PlassOptional strategy, not architecture — see LAY10.

Why the measure signature is the crux

Today's measure takes no width, so a node cannot size against the space it will actually get. That single gap is why text wrapping was hoisted out of layout and into the view, against a hardcoded column limit. The width↔layout cycle ("wrapping needs a width; the width comes from layout") must be broken by ordering, not iteration: measure the main axis, allocate a width, then measure the cross axis parameterised by that width, at which point a text node runs its line breaker. That is GTK's protocol, and Flutter's minIntrinsicHeight(width) is the same idea under another name.

Why integers, everywhere

Two independent catalog entries report float-derived off-by-one cell errors as a recurring, shipped bug class in terminal backends. Because we hand-write the engine, the whole class is avoidable by never admitting a float: leftover space is distributed with divmod plus explicit remainder assignment, so the parts sum exactly to the whole at every width. Sub-pixel positioning remains available to pixel backends, which own the cells→device mapping (LAY3).

Layout model & units (LAY1LAY3)

IDRequirementStatusTraces to
LAY1Layout must turn a widget tree into one Frame per node — an absolute rectangle — via alternating bottom-up measure and top-down allocate passes (per axis, so four in total under LAY4), all @safe pure nothrow, in O(n) over the node count.full (aa35bc26)layout.d layoutnaturalWidth/allocWidth/naturalHeight/place
LAY2The layout model must be box-flow (the Clay family), selected from the UI-layout catalog; the flexbox vocabulary is adopted without the flexbox algorithm, and no constraint solver is used.full (this page)catalog verdict above; layout.d header
LAY3Every coordinate and extent in the engine must be an integer in abstract cells. No floating-point value may enter layout. Mapping cells to device units (pixels, CSS lengths) is the canvas's responsibility, so a pixel backend may still position sub-pixel.full (aa35bc26)geometry.d Point/Size; canvas cellW/cellH

Measure protocol (LAY4LAY5)

IDRequirementStatusTraces to
LAY4measure must be orientation-aware and parameterised by the other axismeasure(axis, forSize) — so a node's extent on one axis may depend on its allocated extent on the other. The root receives the viewport. This is what resolves content-dependent sizing (text wrapping, height-for-width) by ordering rather than iteration.full (aa35bc26)layout.d — four passes: natural width → width allocation → height-for-width → height allocation + place
LAY5Text measurement must be injected through the canvas, not hardcoded in the engine, and must be grapheme-correct (wide and combining characters counted properly). The measurer receives a candidate width so it can report wrapped extent; it must be assumed hot and cached.partiallayout.d isTextMeasure/CellMeasure (the seam, aa35bc26); wiring each backend's grapheme-aware measurer through it is the M7 backend work

NOTE

The engine now measures through an injected isTextMeasure value (defaulting to CellMeasure, one column per codepoint), so the seam exists — but no backend passes its grapheme-aware measurer yet, and canvas.measure still has no caller. Until M7 wires it, wide characters overflow their boxes on the TUI.

Sizing (LAY6)

IDRequirementStatusTraces to
LAY6The sizing vocabulary is fit / grow / fixed / percent with min/max clamps. percent is a fraction of the parent's content size. grow siblings share leftover space by weight; distribution must be integer-exactq = leftover / weight; r = leftover % weight, with the r extra cells handed to the first r growers in order — so the parts sum exactly to the whole. On overflow, children shrink toward min.full (aa35bc26)geometry.d SizeSpec; layout.d distributeMain

NOTE

Overflow reclaim is proportional to each child's slack above min (integer divmod again) and is skipped on a clipped axis — a viewport's content is meant to overflow (LAY7), so it scrolls instead of being crushed.

Clipping, scroll & viewport (LAY7)

IDRequirementStatusTraces to
LAY7A container must be able to clip and scroll its subtree: a per-node clip (per axis) plus a child offset, which place subtracts from child origins and which emits scissor push/pop operations around the subtree in the display list. Scroll offset is interaction state and lives in a state machine, not in the widget arena. It must map to overflow on the HTML target.full (9952b61c)Widget.clipX/clipY/childOffset; display_list pushClip/popClip + culling; CellGrid/hue GridCanvas clip stacks; interp/html overflow

Alignment (LAY8)

IDRequirementStatusTraces to
LAY8Containers must support one alignment value per axis, offsetting each child within its allocated band. Distribution effects (space-between and friends) are produced by inserting grow spacer children, not by a separate property. Cross-axis stretch stays a per-child flag.full (cf0131e4)Widget.alignX/alignY (Alignment); interp/html align-items/justify-content

Track sizing (LAY9)

IDRequirementStatusTraces to
LAY9Tabular layout must be served by a one-dimensional track sizer — per track auto / fixed(n) / fr(w) / minmax(a,b), resolved as "measure auto tracks, subtract fixed and gaps, divide the remainder among fr by weight with remainder distribution" — with cell spans. It must emit grid-template-columns on the HTML target. Row extents come from LAY4's cross-axis measure.partialthe sizer + spans + grid-template-columns emission shipped (a9f7698e, tracks.d); the table view adopts it in M3c

Text wrapping (LAY10)

IDRequirementStatusTraces to
LAY10Line breaking must sit behind a strategy seam — greedy by default, with a balanced (rigid-glue Knuth–Plass) variant selectable — invoked from the cross-axis measure with the allocated width. Hang indent is expressed as a first-line width delta. No wrap width may be hardcoded in a view.full (407bce58)sparkles.ui.wrap (TextWrap greedy/balanced + wrapSpans for styled runs, hang indent), invoked from the height-for-width pass. The twoslash docs packer is deleted — its 56 columns survive only as a Widget.width.max style metric the engine wraps against

NOTE

Knuth–Plass is deliberately not architecture. The catalog notes it degenerates to greedy at table-cell widths and that a monospace grid has no stretchable glue, so ragged-right greedy is the natural default; the balanced variant is a quality upgrade for full-width prose, selectable per call.

Ergonomics (LAY11LAY13)

IDRequirementStatusTraces to
LAY11Nodes must support tri-state visibility — visible / hidden (occupies space) / collapsed (removed from flow) — so chrome can toggle without a layout jump. It maps to visibility and display:none on HTML.full (cf0131e4)Widget.visibility (Visibility); layout.d collapsed-skip; display_list hidden-skip
LAY12The laid-out tree must be introspectable — serializable to a readable form (nodes, kinds, slots, resolved rects) for debugging, via the existing pretty-printing machinery.full (cf0131e4)layout.d dumpTree
LAY13Measured extents should be cached keyed on (node, axis, forSize), before any dirty-tracking or incremental-relayout mechanism is considered.not startedproposed measure cache

Explicitly out of scope

Each exclusion is a decision with evidence, recorded so it is not silently revisited:

Not implementedWhy
marginClay has shipped for years without it; Flutter deliberately replaced it with gutter widgets; CSS margin collapsing is the most surprising part of normal flow. gap + padding cover our cases. The currently-inert Widget.margin field is deleted, not left unread.
Constraint solversImplementation cost and debuggability; the catalog concludes box-flow is the right tool where layout maps onto a tree, which ours does.
Full CSS GridThe auto-placement and staged track-resolution algorithms run to pages of normative prose; essentially only browser-grade engines implement it. LAY9 ships the useful subset and emits grid CSS.
flex-wrap / box wrappingAbsent from Clay, Tk and ConstraintLayout alike. Our text wraps; our boxes do not need to. Text wrapping is not flex wrapping.
The full flexbox alignment matrixjustify-content × align-items × align-content × flex-wrap produces surprising combinations; one value per axis plus spacers covers the same ground.
Baseline alignment on cell targetsIrrelevant on a fixed grid — all cell text shares a baseline. Revisit only if a pixel backend grows mixed font sizes.
Eager intrinsic-width queriesDocumented as expensive by the toolkits that offer them, and catastrophic inside large columns. LAY4's forSize obtains the same answers within the normal pass.
Floating-point coordinatesTwo independent catalog entries report float-derived off-by-one cell drift as a shipped bug class in terminal backends.
Lazy off-screen materialisationGenuinely valuable at very large item counts, but a second-order optimisation on top of LAY7. Measure before building it.

Milestones

MilestoneScopeStatusRequirements
L0Decision record (this page) — model, units, exclusionsdecidedLAY2, LAY3
L1Orientation-aware measure + injected grapheme-correct text measurementpartial (aa35bc26; M7 wires the backend measurers)LAY4, LAY5
L2Integer-exact grow/percent distributionfull (aa35bc26)LAY6
L3Clip, child offset and scissor emissionfull (9952b61c)LAY7
L4Alignment, tri-state visibility, tree dump; delete marginfull (cf0131e4; margin deleted in aa35bc26)LAY8, LAY11, LAY12
L5Track sizer + wrapping strategy seampartial (fcd2464a, a9f7698e; view adoption in M3c/M6)LAY9, LAY10
L6Measure caching (and dirty relayout only if measurement demands it)not startedLAY13

Module coverage

Source fileRequirements
libs/ui/src/sparkles/ui/layout.dLAY1, LAY2, LAY4LAY8, LAY11, LAY12, LAY13
libs/ui/src/sparkles/ui/geometry.dLAY3, LAY6 (SizeSpec); Rect.intersection behind LAY7
libs/ui/src/sparkles/ui/wrap.dLAY10 (TextWrap, greedy + balanced; landed here rather than the once-proposed base.text.wrap — the measurer is caller-supplied, so the breaker is the toolkit's)
libs/ui/src/sparkles/ui/tracks.dLAY9 (TrackSpec, resolveTracks, applySpans, writeGridTemplate)
libs/ui/src/sparkles/ui/canvas.dLAY5 (the measure primitive); pushClip/popClip ops (LAY7)
libs/ui/src/sparkles/ui/display_list.dLAY7 (scissor emission + culling)

Relationship to existing specs

PieceRole in layout
UI-layout catalogthe evidence base this page decides from (LAY2)
backends.md TGTthe cells→device mapping (LAY3) and the measure primitive (LAY5)
widgets.md WGTthe tree layout consumes; the table/scroll widgets driving LAY7/LAY9
state-machines.md STMscroll offset, which LAY7 reads but does not own
sparkles.base.text (wrap, grapheme)the wrapping and width primitives behind LAY5/LAY10

Overview · Widgets · Backends · Principles