sparkles:ui widgets — Feature Requirements (WGT, VMD)
Status: partial · Date: 2026-08-05 · Scope: the widget level — the tree representation, props and identity, and the component catalog, split into backend-independent view models (VMD) and views (WGT).
Design & rationale
View model + view
The toolkit's components are split in two, and the split is the point:
- A view model is presentation-free. It owns the component's data and interaction state and answers questions about it. It has no idea how it is drawn, contains no colors or glyphs, and is testable with no canvas at all.
- A view is a pure function from a view model to a widget subtree. It owns the visual decisions — slots, glyphs, spacing — and nothing else.
This is what makes "same model, different UIs" true rather than aspirational, and it is the pattern the tree-view case study singles out as the central design insight worth copying. That study also names the failure mode precisely: mixing expand state, git status and diagnostic severity onto the data node means the tree can only ever have one visual state, and the whole structure becomes uncopyable.
The tree component is therefore the exemplar every other component follows:
| Layer | Content |
|---|---|
| data | flat node arena with index links — an independent structural snapshot is one arena duplication |
| interaction | opened set, selection, scroll offset — keyed by identity, not stored on nodes |
| view | borrows both, owns glyphs and slots only |
with the flatten step — hierarchy to a linear list of visible rows — as a pure free function, not a method that also lazily loads children and applies filters.
Why the tree is not recursive
A recursive node type makes the structure an incidental one: ownership is unclear, copying is a deep traversal, and every consumer writes its own walk. A flat arena with index links can be duplicated in one pass, is cache-friendly and @nogc-able, and lets a single traversal serve every algorithm. The current D slices still alias under default copy; independent value semantics are tracked as UI-O1.
Widget tree (WGT1–WGT6)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| WGT1 | A widget tree must be a flat arena — one relocatable buffer of nodes, containers referencing children by explicit index list — not a class hierarchy or a recursive value. | full | widget.d Widget, WidgetTree |
| WGT2 | A view must be a pure function view(model, ctx) → WidgetTree, with no dependence on frame state, so it is re-entrant: any view may embed the output of another view at any depth. | partial | widget.d Builder |
| WGT3 | A widget's payload must be a sum type over the widget kinds, so only the fields meaningful for a kind exist, the compiler enforces exhaustive handling, and adding a kind cannot silently skip a backend. | not started | deliberately sequenced after W2–W5: freezing the payload sum before the component catalog exists would mean re-cutting it per component. Exhaustiveness is meanwhile enforced by final switch over WidgetKind. |
| WGT4 | Widget props must be Regular with total, substitutive structural equality and copy independence; handlers and other non-comparable payloads must be excluded from the compared value. | partial | handlers and element state are excluded (00b331ad), but mutable slice payloads still alias under default copy (UI-O1) |
| WGT5 | A widget may carry a key, and the renderer must maintain a store of per-element state addressed by key, so scroll offsets, focus and animation phase survive a rebuild. Element state lives in that store, never in the widget value. | full (00b331ad) | Widget.key; state.d ElementStore/elementKeys |
| WGT6 | Text must support styled runs within a single node — a sequence of (text, slot) spans — so syntax-highlighted content is expressible directly, without a backend overpainting the toolkit's own output to re-colour it. | partial | WidgetKind.rich + TextSpan end to end (a6c6c69f), wrapping included (wrapSpans, 407bce58 — the twoslash docs/tag paths now wrap as rich runs with inline pills); the GUI signature overpaint retires when the view emits highlighted rich signatures |
NOTE
WGT5 deliberately separates two different relationships. Equality decides "may I skip repainting"; identity decides "is this the same element, so its state carries over". Conflating them is the classic reconciliation bug, and keeping element state out of the widget value removes one source of dishonest equality. Totality and copy independence still depend on every payload meeting PRN6.
Component catalog (WGT7+)
Each row is a view model plus its view. Status reflects the toolkit, not any one consumer.
| ID | Component | Status | Notes |
|---|---|---|---|
| WGT7 | Containers — row, column, stack, panel, popup | full | shipped |
| WGT8 | Primitives — box, text, glyph, line | full | shipped |
| WGT9 | Scroll view — clipped viewport with an offset | full | components/chrome.d scrollView over LAY7 + ScrollState + Widget.key |
| WGT10 | Scrollbar — track and thumb, hover/drag affordance | partial | components/chrome.d scrollbar over STM2's one formula; hover/drag affordance wires up with the M9 chrome port |
| WGT11 | Table — columns with alignment and spans, header, optional borders | partial (e78f404c) — the markdown view renders tables over the track sizer with aligned fixed-width cells and source-anchored cell keys; a standalone table widget is still open | view model over LAY9's track sizer |
| WGT12 | Tree — the exemplar; flat arena, opened set, guides, lazy children | partial | components/tree_widget.d (4e3ad035); lazy children (VMD5) arrive with the explorer |
| WGT13 | List — selectable rows, optional virtualization | not started | degenerate tree; shares the selection machine |
| WGT14 | Text input — caret, editing, submission | not started | tier 1 |
| WGT15 | Button — label, press state, activation | partial (IXB9): PressState (STM10) + the actionBar segmented band; a standalone button view still to come | tier 1 |
| WGT16 | Toast / notification — transient, timed or event-scoped | not started | view over STM6 |
| WGT17 | Header / status bar — leading, centre and trailing segment groups | full | components/chrome.d headerBar (grow-spacer distribution, chrome slot band) |
| WGT18 | Gutter — line numbers, markers, fold indicators | partial | components/chrome.d gutter (numbers, LAY8-aligned); markers/fold indicators come with the document view |
| WGT19 | Meter / progress — determinate and indeterminate | not started | indeterminate is a mode, not a sentinel value |
| WGT20 | Divider / spacer | not started | spacer is a grow box, per LAY8 |
| WGT21 | Link — activatable reference; hyperlink escape on capable terminals | not started | needs a link concept in the visual vocabulary |
| WGT22 | Image / media — sized placeholder with per-target realisation | not started | degrades to alt text |
| WGT23 | Tabs — tab bar plus one visible panel | partial — chrome.d tabStrip: label-sized or growing segments, active distinguished from armed, hits from the laid-out frames; the panel is the caller's (hue's document set, markdown code groups) | tier 0 on HTML via checked-radio idiom |
| WGT24 | Disclosure — collapsible region with a placeholder | partial (9fc03551) — the markdown fold placeholder over STM5 (both interactive backends); a generic disclosure widget is still open | tier 0 on HTML; shares STM5 |
| WGT25 | Task list — ordered items with status marks and a running/blocked distinction | not started | view model is presentation-free; its driver is not — see below |
IMPORTANT
The live region is not a widget, and must not become one. It repaints the bottom of a scrolling terminal in place: it writes cursor-control escapes to a stream and owns output sequencing. That is a line-oriented incremental output sink, not a canvas — it has no rectangle, no clip and no frame. Putting it behind isCanvas would violate the canvas-first posture (UIA1) and force every backend to pretend it has a cursor.
The split: a task list's view model (items, statuses, ordering) is presentation-free and belongs here as WGT25; the live region and the reporter that drives it stay a terminal concern owned by the cell backend's package. Spinner and progress glyphs are theme data (THM); the meter/progress view is WGT19.
Tree component (VMD1–VMD6)
The exemplar of the view-model/view split.
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| VMD1 | Tree data must be a flat node arena with parent/child/sibling indices, independently snapshot-able as a value, holding no interaction state and no decoration. | partial | flat arena and separation shipped (4e3ad035); explicit copy/alias semantics for its slice and T remain UI-O1 |
| VMD2 | Tree interaction state — opened set, selection, scroll offset — must live in a separate value keyed by node identity (a path of identifiers), so one tree can back several independent views. | partial | DisclosureState + caller-owned selection (4e3ad035); path-keyed identity lands with the explorer's stable keys |
| VMD3 | Flatten must be a pure free function (data, state) → range of (depth, node, isLastChild), with no lazy loading and no filtering mixed in. It must be testable in isolation. | full (4e3ad035) | tree_widget.d flatten |
| VMD4 | Guide characters must follow the four-state model — space, continue, fork, end — accumulated per depth level, with the per-depth state precomputed during flatten rather than recomputed per render. | full (4e3ad035) | tree_widget.d Guide/FlatTreeRow.guides |
| VMD5 | Lazy children must separate user intent ("this should be open") from loaded state ("children have been read"), so the tree knows what should be expanded before it has read it. | full (d47a0d01) — the explorer's open (DisclosureState intent) / expanded (children read one level past open) split | proposed lazy provider |
| VMD6 | Node capabilities — has children, has an icon, has a status badge — must be detected by introspection, so a filesystem tree and a syntax-tree share one renderer without a type hierarchy. | partial | treeView introspects optional label/icon/slot (4e3ad035); status badges join with the explorer's git decoration |
NOTE
An alternative traversal mode, where the visible tree is rebuilt as a function of (source, filter, depth limit) with no persistent expand state, is the natural fit for live filtering and coexists with VMD2 rather than replacing it. Flat storage is what makes rebuilding per keystroke viable.
Milestones
| Milestone | Scope | Status | Requirements |
|---|---|---|---|
| W0 | Sum-typed payload, Regular props, keys and element state | partial (00b331ad; payload sum is UI-O2, copy policy is UI-O1) | WGT3–WGT5 |
| W1 | Styled-run text; hit identity through the pipeline | full (a6c6c69f, f166e099) | WGT6 |
| W2 | Chrome components — scroll view, scrollbar, header/status, gutter | partial (views shipped; hue consumes them in M9) | WGT9–WGT10, WGT17–WGT18 |
| W3 | Content components — table, list, rich text | not started | WGT11, WGT13 |
| W4 | Tree component per the case study | partial (4e3ad035; VMD5 with the explorer) | WGT12, VMD1–VMD6 |
| W5 | Interactive components — input, button, tabs, disclosure, toast | not started | WGT14–WGT16, WGT23–WGT24 |
| W6 | Media and links | not started | WGT21, WGT22 |
Module coverage
| Source file | Requirements |
|---|---|
libs/ui/src/sparkles/ui/widget.d | WGT1–WGT8 |
libs/ui/src/sparkles/ui/components/ | WGT9–WGT24, VMD1–VMD6 |
libs/ui/src/sparkles/ui/state.d | WGT5 (element-state store) |
Relationship to existing specs
| Piece | Role |
|---|---|
| Tree-view case study | the design record behind VMD1–VMD6 |
layout.md LAY | the sizing, clipping and track facilities components need |
state-machines.md STM | the behavior half of every interactive component |
theme.md THM2 | the widened slot vocabulary the catalog requires |
input.md INP5 | the tier a component declares |
principles.md PRN1, PRN5, PRN6, PRN12 | ownership, sum-payload and Regular-value rules |
open-issues.md UI-O1, UI-O2 | deferred ownership/copy and widget-sum implementation gaps |
→ Overview · Layout · State machines · Theme