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 (LAY1–LAY3) ​

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 layout — naturalWidth/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 (LAY4–LAY5) ​

IDRequirementStatusTraces to
LAY4measure must be orientation-aware and parameterised by the other axis — measure(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.

The grapheme-correct measurement LAY5 requires is base's: a backend's measurer measures under a base/text width profile (cell text), which is the only source of grid-cell advances (WRAP-CELL4). LAY5's evidence stands for the delivered seam and is pending base's cutover of the toolkit's callers (base/text PLAN).

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-exact — q = 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
LAY14A plain text run must paint within the frame layout gave it. The display list cuts a text node's run, and each of its wrapped lines, to the frame's width in cells, so text that layout shrank in an overfull row, or an unbreakable word, never paints over a sibling, a border or a scrollbar: a canvas takes a run's advance from the text itself and cannot know the frame. rich spans are exempt where something contains them (LAY16).full (historical baseline; owned-text cutover verification pending)sparkles.base.text.grapheme.fitCells; the text case in display_list.emit; retained rich projection in Frame; ui.display_list.textIsCutToTheWidthLayoutGaveIt, ui_gallery.gallery.aSidebarAtItsFloorKeepsItsCaptionsOffTheBorder
LAY15A container that draws a border side must keep its content inside it: its children paint and hit-test under a clip of its padded box, on each axis where it has a bordered side, exactly as if it set clipX/clipY. A rich row may overflow the frame layout gave it (LAY14), and without this a tree row in a bordered panel ran through the panel's edge. The clip is the padded box, not the box inside the border, so a pane with no padding on its bordered side keeps what it deliberately puts in that column.fulllayout.clipsX/clipsY, used by childClipOf and display_list.emit; ui.display_list.decoratedBoxAndStyledText
LAY16A rich row may paint past its frame only where something contains it on x: an ancestor that clips on x (a viewport, LAY7, or a bordered box, LAY15), or one marked scrollsX, whose host scrolls and clips it and sizes a horizontal bar from the overflow (hue's document and diff views). Anywhere else its spans are cut to the frame, as a plain run is (LAY14), since nothing would stop the rest painting over the row's neighbours.fullWidget.scrollsX; the overflowX walk in display_list.emit; ui.display_list.aRichRowStaysInItsFrameUnlessSomethingContainsIt, viewer_model.aDiffWiderThanThePaneScrollsSideways

The current source-cell authority is owned Unicode 18.0.0 whole extended graphemes (visibleWidth/fitCells in base), not exported geometry.cellsOf or geometry.takeCells helpers. Plain runs fit only whole graphemes; rich runs consume retained projection spans and cell advances, then obey the frame/ancestor clip policy above. This updates live symbol traces, not the historical baseline delivery evidence or a claim that current cutover/GUI edits have been executed.

Cutting a run to a width (takeCells, LAY14) is whole-cluster fitting, which base owns (fitPrefix, under WRAP-BOUND1); the toolkit keeps the policy of cutting at the frame's width under the target's width profile. LAY14's evidence stands for the delivered code and is pending base's cutover of takeCells (base/text PLAN).

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

Base owns the pure line-breaking solvers and cell wrapping (WRAP-BOUND1); sparkles.ui.wrap keeps only the policy — whether to wrap or cut, the width, and which width profile — and calls base, whose grid-cell advances come from that width profile (WRAP-CELL4). LAY10's evidence stands for the delivered code and is pending base's cutover of the toolkit's wrapping (base/text PLAN; design-system D51).

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 (LAY11–LAY13) ​

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, LAY4–LAY8, 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): the toolkit's wrapping policy; the solvers and cell wrapping belong to base (WRAP-BOUND1)
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/LAY14
base/text wrappingowns the pure solvers and cell wrapping (WRAP-BOUND1) and whole-cluster fitting (fitPrefix); layout keeps the policy

→ Overview · Widgets · Backends · Principles