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; WidgetKind.popup is the look only — the anchored-overlay behavior (anchor, placement, layering, dismissal) is owned by anchored overlays, whose POP6 proposes either re-defining the kind as an overlay record's emission or retiring it |
| 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 | full — one WidgetKind.scrollbar carries content units and semantic expansion into OpKind.scrollbar; pixel backends resolve a continuous rail and cell backends use the shared one/two-cell degradation | components/chrome.d ScrollbarSpec; STM2's one formula; RaylibCanvas.scrollbar and the cell fallback |
| 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 | full (8be026e6) — data + view in components/tree_widget.d (4e3ad035), the interaction layer in components/tree_view.d (VMD7), lazy children per the explorer's VMD5 split | components/tree_widget.d, components/tree_view.d |
| 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–VMD7)
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. | full (8be026e6) | tree_view.d TreeViewState!Key — the opened set is keyed by the adapter's identity (the explorer's paths); cursor/viewport live beside it |
| 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. | full (afd3a1d1) | treeView introspects label/icon+iconFg/slot/labelFg/badge+badgeFg/rowBg; tree_view.d adds expandable (activation) |
| VMD7 | Tree interaction must be a shared component, not a per-host pattern: cursor + viewport (with their coupling clamp — and its bounds-only counterpart, which is what a per-frame re-clamp needs: chasing the cursor while painting silently undoes every scrollbar grab and wheel notch), both scrollbars, the live filter, and the shared verbs (collapse-or-up, activate, cyclic jump, viewport slice) as one value + free functions; a mutation that invalidates the rows reports rebuild — the adapter owns rebuilding. | full (8be026e6) — TreeViewState + TreeStep; the explorer, hue's GUI host and the gallery's tree page all drive it (a503a912) | tree_view.d; consumers: apps/hue/src/explorer.d, apps/hue/src/gui.d, apps/ui-gallery/src/pages/tree_page.d |
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.
The inspector component (INS) composes this tree over a subject — header, details pane, and an adapter-defined selection/extent contract — with the widget-tree adapter as the toolkit's self-inspection.
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 | full (8be026e6) except VMD1's copy policy (UI-O1) | WGT12, VMD1–VMD7 |
| 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–VMD7 |
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–VMD7 |
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