Skip to content

sparkles:ui state machines — Feature Requirements (STM)

Status: partial · Date: 2026-08-05 · Scope: level 1 of the toolkit — presentation-free behavior: the logic of a widget with no idea how it is drawn.

Design & rationale

A state machine here is pure logic over abstract input, producing state and derived geometry in abstract units. It makes no draw calls, knows nothing of pixels or cells, and is testable in isolation.

The reason to insist on this is empirical. Where behavior has been written per backend, it has diverged — two scrollbars with different thumb-position formulas scroll the same document differently; one copy affordance flashes its confirmation on a timer while another holds it until the next event. Each is locally correct and the product is incoherent, which is the catalog's point that correctness does not compose, and the interface-honesty rule that a UI must not report the same state two different ways.

Two shapes recur and are required:

  • Transformations, not mutations. A machine advances by step(state, input) → state, a pure state transition; the caller assigns the result. Pure queries may produce a different result type, and the paint/I/O boundary remains an action. Timers are not bare counters decremented at the call site.
  • Modes, not sentinel values. "Indeterminate" is a state, not a magic number; "no selection" is a state, not -1.

Requirements

IDRequirementStatusTraces to
STM1A state machine must be fully presentation-independent: pure logic over abstract input producing state plus derived geometry in abstract units, with no draw calls and no device units. @safe, ideally @nogc, and testable with no canvas.full (49fa8e50)state.d
STM2Scrollbar(contentExtent, viewportExtent, offset, trackExtent) → (thumbStart, thumbExtent) plus hover and drag state. One definition, integer-exact, covered by property-based tests asserting the thumb stays within the track at every input.full (49fa8e50)state.d scrollbarThumb/ScrollState (incl. the inverse track-drag mapping)
STM3Selection — one Regular value with a normalized anchor/focus invariant, expressed so the standard selection algorithms apply to it. Every backend renders it; none owns it.full (49fa8e50)state.d Selection!T (any ordered position type)
STM4Hover — topmost hit wins; reports whether the hot element changed, so a caller can repaint only on change. Requires hit identity to reach the display list. Identity comes at two granularities: hitId groups (a whole popup, a whole row), key singles out one element — a row of markers shares a group, so only key can say which one a click meant.full (f166e099)state.d HoverState + hoverTargets (by hitId) / keyTargets + keyAt (by key); INP10
STM5Disclosure — a generic opened/collapsed set over a Regular key, serving both tree expand/collapse (keyed by node path) and content folding (keyed by source span). Written once, used by both.full (49fa8e50)state.d DisclosureState!Key (default polarity + exception set; zR/zM are O(1) resets)
STM6Timeline — a small mode machine for transient effects (idle / in / hold / out) advanced by step(state, dt), replacing hand-decremented counters. Backends with no frame clock may collapse it to an event-scoped mode without changing the caller.full (49fa8e50)state.d Timeline (holdUntilDismissed + dismissed() is the event-scoped collapse)
STM7Focus — which element has keyboard focus, with a deterministic traversal order, so keyboard navigation is defined once rather than per backend.full (49fa8e50)state.d FocusState
STM8Pane splitter — a draggable divider between two panes as a value: grab, grab-relative drag with [min, max] clamping, release, and a post-resize re-clamp. Unit-agnostic (cells or pixels), so every backend runs the same drag.fullstate.d SplitState; driven per divider by the dock container (DCK3)
STM9Scrollbar machine — the whole bar as one value over STM2: axis (vertical/horizontal), the grab-relative interaction (a thumb press grabs in place, a track press jumps, drags move relative to the grab and own the pointer until release), hover, and the wanted pointer shape by axis. Both panes and every backend run this machine; none re-implements a grab.fullstate.d ScrollbarState; both axes are owned by the ScrollView container (SCV1) that hue's models hold; the machine-driven scrollbar component overload (WGT10) and its px twin render it
STM10Press / activation — a press arms an addressable target, a release over the same target activates it, a release elsewhere cancels. Ids are hit ids, so a target cannot be armed by one geometry and activated by another; the activation is transient, consumed by the next press.fullstate.d PressState; the actionBar component (WGT15 in part)
STM11Pointer capture — press owns the drag: the affordance that took the press keeps every motion and the release wherever the pointer strays. available(id) asks may I act? (free, or already mine), so a new affordance participates by taking an id rather than by being added to every other affordance's negation chain.fullstate.d CaptureState; owned by the dock container (DCK8), which issues pane and divider ids — the TUI workspace consumes it, apps/hue gui.d keeps its own ids until the GUI adopts the container
STM12Machines must be Regular values — comparable with logically independent copies — so a view's behavior can be snapshotted, replayed and diffed in tests.partialscalar and immutable-payload machines comply; DisclosureState.exceptions still exposes slice aliasing (UI-O1)
STM13Line editor — one buffer-plus-capturing value behind every query, filter and goto input: typed appends a printable codepoint, erased drops a whole codepoint (never a torn multibyte tail), accepted keeps the text and stops capturing, cancelled clears. Hosts render the text and their own caret.fullstate.d LineEditState; apps/hue explorer.d's tree filter (the viewer and GUI inputs adopt it with the C-2a GUI half)

NOTE

The machines are shipped and the document/explorer paths consume the shared scrollbar, selection, disclosure, timeline and capture definitions. Remaining application-owned composition and paint are not second state machines; they are tracked as HUE-O1 and HUE-O2.

NOTE

STM5 is one machine serving two features that look unrelated. A tree's "which nodes are expanded" and a document's "which regions are folded" are the same question over different keys, and implementing them separately would reintroduce the divergence PRN8 forbids.

Milestones

MilestoneScopeStatusRequirements
S0Hover wired to real hit identityfull (f166e099)STM4
S1Scrollbar and selection lifted from per-backend implementationsfull (23fab77e) — one thumb/drag formula and one selection model across TUI+GUISTM2, STM3
S2Disclosure, shared by tree and foldingfull (9fc03551) — consumed by the explorer (tree) and folding (both backends)STM5
S3Timeline, replacing ad-hoc countersfull — copied-flash, toast and hover-fade all TimelineSTM6
S4Focus and keyboard traversal orderpartial (49fa8e50; keyboard nav wires up in M9)STM7

Module coverage

Source fileRequirements
libs/ui/src/sparkles/ui/state.dSTM1STM12

Relationship to existing specs

PieceRole
input.md INPthe events these machines consume; INP10 unblocks STM4
widgets.md WGTthe views that render each machine's state
layout.md LAY7consumes the scroll offset STM2 owns
principles.md PRN7PRN9the state-transition, shared-semantics and model rules these embody

Overview · Widgets · Input