Skip to content

sparkles:ui-app — Feature Requirements

Status: living inventory · Date: 2026-08-07 · Scope: libs/ui-app — the application host: backend selection (BKD), the shared window/font CLI (CLI), the frame/event loop (HST), the package graph (APP) and the testability obligations (TST).

Architecture (APP)

IDRequirementStatusTraces to
APP1The host must be a sibling package, not a layer inside sparkles:ui. The toolkit gains no dependency and no knowledge of window systems, terminals or the host itself (PKG1).fulllibs/ui-app/dub.sdl
APP2An application depending on the host must be able to build without naming any backend: no sparkles:ui-tui, sparkles:ui-raylib, raylib or sparkles.tui import, and no such dependency in its dub.sdl.fulllibs/ui-app/dub.sdl; consumer manifests
APP3The host must ship three configurationstui (default), gui, full — where gui keeps sparkles:tui out of the dependency closure entirely, because an Android build has no terminal and must not link one.fulllibs/ui-app/dub.sdl
APP4Each backend arm must additionally be conditionally compiled: the terminal arm behind both version (UiAppTui) (the configuration brought sparkles:ui-tui — the gui configuration deliberately did not) and version (Posix) (its session type exists only there); the GPU arm behind version (UiAppGui). Neither gate implies the other, and every configuration type-checks on every platform.fulltui_loop.d; gui_loop.d
APP5Where the host's public API is a template (run, Host), the version-gated arms must be proven to resolve in the consumer's compilation, since a version identifier set by a dependency's configuration is what makes the arm visible there.decided — proven by the P1.0 spike; a dependency's configuration versions do reach the dependent, so APP3's configurations stand (UIAPP-O2)run.d

IMPORTANT

APP4 is not defensive, and both gates were each proven load-bearing by a failing build. sparkles.ui_tui.session imports Terminal and PosixEvents unconditionally, and both live behind version (Posix): in libs/tui/src/sparkles/tui/terminal.d and input.d — without that gate, the tui and full configurations fail to type-check on Windows and Android (exactly the platform hue's APK build targets). And version (Posix) alone is not enough: a gui-configured build on Linux has Posix but no sparkles:ui-tui in its closure, so the terminal arm's imports cannot even resolve there — which is what version (UiAppTui) (set by the tui/full/ unittest configurations' versions) gates.

Backend selection (BKD)

IDRequirementStatusTraces to
BKD1The host owns one backend vocabulary: Backend { gui, tui, html, ansi }. html and ansi are members because the toolkit already has an HTML target (TGT4) and a non-interactive ANSI sink; an application must not re-derive them.fullbackend.d
BKD2The decision must be a pure function over an injected policy — CLI flags, whether the GPU backend was compiled in, stdin/stdout tty-ness, display presence — so the full matrix is testable with no tty, no display and no window.fullbackend.d BackendPolicy, pickBackend
BKD3Environment probing ($DISPLAY / $WAYLAND_DISPLAY, $SSH_CONNECTION on macOS/Windows) must be a separate, callable function, never folded into the decision — a probe is impure, a decision is not.fullbackend.d displayAvailable
BKD4The Android fact belongs here: on Android the surface is the application, so the answer is gui unconditionally. It is a statement about the process model, not a display heuristic — isTerminal and displayAvailable are both false there and would answer ansi.fullbackend.d, version (Android)
BKD5run must accept auto and fall back in both directions: to the terminal when the GPU arm is not compiled in, and to the GPU when the platform has no terminal arm. With neither available it must report a typed failure rather than open nothing.fullrun.d

Behavioral rules BKD2 preserves verbatim from today's apps/hue:

  • an explicit --gui wins even without GPU support — the sink reports the problem itself rather than the picker silently choosing something else;
  • --html selects html; --no-gui/--tui force the terminal;
  • otherwise: GUI when compiled in and stdout is a tty and a display is present; else the interactive TUI when stdin and stdout are both ttys; else ansi.

Window and font CLI (CLI)

IDRequirementStatusTraces to
CLI1The window/font flags must be declared once: a mixin template supplies the fields, and the standalone options struct is defined by that mixin. Two parallel declarations of the same vocabulary are the defect being removed.fullgui_options.d GuiCliFields, GuiOptions
CLI2An application must be able to either embed the options struct or mix the fields into its own parameter struct, so an app whose flags are flat stays flat.fullgui_options.d
CLI3There is one set of defaults across every application — including the default point size and the default font preference list. A per-application default is not offered.decidedgui_options.d; UIAPP-O1
CLI4Font resolution must be one implementation covering both routes: a fontconfig preference list, or a directory scan when --font-dir is given (which also disables fontconfig, so a build's font selection is deterministic).fullgui_setup.d resolveFontPath
CLI5The setup order is part of the contract, not the caller's problem: open the window, load the font set, resolve the point size against the real display, then size the window to the loaded cell metrics.fullgui_setup.d openGuiSession
CLI6Deterministic-capture and platform hooks (a pixel-size override that suppresses DPI scaling, extra font sources, a trace-log sink) must be parameters supplied by the caller, never environment reads inside the library.fullgui_setup.d GuiRequest

IMPORTANT

CLI5 is a genuine ordering constraint, not a style preference: cell metrics do not exist until the font set loads, the font set cannot load until a GL context exists, and the requested window size is expressed in cells. Any host that reorders these silently produces a window of the wrong size.

CLI6 matters because the pixel-size override is what makes hue's golden-frame screenshot captures reproducible. A capture whose font size quietly follows the panel's DPI is a broken oracle, not a cosmetic difference.

The host contract (HST)

IDRequirementStatusTraces to
HST1The loop is entered through one call taking a configuration, a present callback and a handle callback; both callbacks receive ref Host. The application never names a canvas, a window, a terminal or an event source.fullrun.d; host.d
HST2Host must be a per-backend template instantiation, not an interface — no vtable in the frame path, and @safe/@nogc/nothrow inferred from the concrete backend, matching the isCanvas discipline.fullhost.d
HST3The host must offer all three render levels: a widget tree it lays out and paints, an appendable display-list buffer, and direct access to the concrete canvas for an application with its own renderer.fullhost.d
HST4The per-frame display-list buffer is owned and reused by the host. An application never sizes, allocates or clears one.fullhost.d ops()
HST5The application must be able to end the loop (quit()) and to request another frame (requestFrame()) — the latter is what an animation or an eased transition needs on a target that otherwise blocks on input.fullhost.d
HST6The application must be able to decline to draw a frame (skipFrame()), and the host must honour it by presenting nothing: no cell diff on the terminal, and no buffer swap on the GPU target.fullhost.d; gui_loop.d; tui_loop.d
HST7Resize must be normalized: the event handed to the application always carries the real surface size, on every backend, regardless of what the underlying producer reports.fullrun.d
HST8The platform errands an interactive application actually performs must be on the host: pointer shape, clipboard, window title, out-of-band terminal writes, fullscreen toggle and its capability. An application must not reach past the host for them.fullhost.d
HST9The loop must have a declared repaint policy per backend: the terminal blocks on input unless a frame was requested or an idle interval is configured; the GPU target paces to its frame rate. Background work must be expressible without dropping input.fullrun.d; tui_loop.d
HST10Above run sits one component-level entry point: an application is a value with a view (state → WidgetTree) and a handle (event → state), checked by a structural concept (isAppFor), and runApp owns everything else — layout against the live surface, theme resolution, the display-list build into the host's reused buffer, and a themed page fill leading every frame. view keeps the whole host contract (it may skip, request, quit), honoured before any layout runs.fullrun_app.d isAppFor, runApp, presentApp
HST11The backend decision's live inputs are gathered by one probing function (probedPolicy): CLI force flags, stream tty-ness, display presence. The decision itself stays pure (BKD2); this is the single place the environment is read, so a widget-level main is parse-then-runApp.fullrun_app.d probedPolicy
HST12The component level must have a headless twin (runAppRecorded): the same theme and frame pipeline a live run gets, driven over a scripted event list against the recording target — so a component's frames are assertable with no window and no tty.fullrun_app.d runAppRecorded
HST13The loop has an optional draw phase: a third callback run inside the frame bracket, after the display list painted — the only place a canvas call is valid on the GPU arm, and where an application with a renderer of its own (HST3) paints. A no-op default costs nothing; a skipped frame skips it with the rest of the bracket. At the component level it is the paint(ref host, in WidgetTree, in Frame[]) member, detected by introspection, receiving the same frame's tree and layout so the component finds its keyed pane via keyedRects. Both live hosts expose canvas so a draw-phase component needs no per-target branch.fullrun.d; gui_loop.d; tui_loop.d; run_app.d hasPaintPhase
HST14Font sizing is a host errand. fontSize(px) reloads the GPU host's face set and re-measures the cell metrics (clamped to the minimum; the window's pixel size does not change — more or fewer cells, same window), so size changes on its next read; fontSizePx reads the current value. Call it from handle — outside the frame bracket, where the reload's texture upload is safe. The terminal host accepts and drops it (the emulator owns the glyphs); the recorder captures each ask and reports back the set value.fullgui_setup.d setFontSize; gui_loop.d; tui_loop.d; record.d
HST15Background work rides the loop's scope. An async-arm host offers spawnDaemon(dg) — park a fiber on the loop's own scope (a pty pump, a pipe-readiness watcher, a spawned child) — and wake() — make the loop run another frame pass soon, callable from a daemon fiber (the terminal arm posts a no-op event into its channel; the ticker-paced GPU arm's next tick is already coming, so it is free; the recorder records the ask). Both are optional and detected by presence, the capability ladder's shape: a component keeps its polled fallback for hosts without them — the blocking arms, the recorder, a foreign embedding. Daemons are reaped by the loop's scope exit, before the session closes.full — spawnDaemon/wake on both live hosts (installed by the async arms, refused with false on the fallbacks), canSpawnDaemon/canWake presence probes, the recorder counting asks and refusing; the GPU ticker loop gained the scope that reaps daemonstui_loop.d; gui_loop.d; record.d; first consumers: terminal-view.md TVW8, hue P2.B5
HST16The wake deadline is per-frame data, not startup config. RunConfig.idleTimeoutMs is fixed at startup, but a real application's next self-imposed deadline moves every frame (hue: the lantern panel's remainder; a polled refresh's cap). A host offers wakeIn(Duration) — an ask, re-armed each frame it is wanted (the frame bracket clears it, like requestFrame): the terminal arm's park deadline becomes min(idleTimeoutMs, ask); the GPU arm at a fixed cadence subsumes it; the recorder records it. The expiry is observable as the same idle wake idleTimeoutMs produces (the component sees a frame pass with no event).full — wakeIn/wakeAsk on HostState (soonest ask wins, cleared by the frame bracket); both terminal arms take min(idleTimeoutMs, ask) as the park/poll deadline; the recorder captures the ask on each RecordedFramehost.d HostState; tui_loop.d; record.d; first consumer: hue P2.B5
HST17The modifier level is a host reading, not a folded event. modifiers() answers "which modifiers are held right now". It cannot be folded from the event stream alone: an application dragging a stationary mouse gets no event while Shift goes down or up, so a folded answer latches — hue reads a live poll for exactly this, and a migration that folded instead would silently change its Shift-drag. Each arm refreshes it once per frame from the best its target has: the GPU arm re-polls the synthesizer after the drain; the terminal arm and the recorder take modsOf(e, last) — the four event kinds that carry a level report it, and focus/resize/end-of-input leave the last one standing rather than clearing it (none of them is evidence a key was let go).full — modifiers/noteModifiers on HostState (so every host satisfies it), modsOf as the fold, all three arms refreshinghost.d HostState, modsOf; gui_loop.d; tui_loop.d; record.d; first consumer: hue P2.B4
HST18Pointer positions have a unit, and the application picks it. RunConfig.pointerUnit is cells by default — the toolkit's unit, so a hit test against a laid-out tree needs no conversion. An HST3 application painting its own pixels sets pixels instead: a text selection lands mid-glyph and a popup's close box is 14 px across, so quantising to a cell would move both. Only pointer and wheel positions change; sizes and the resize event stay in cells (they describe the surface, not the pointer). The GPU arm implements it by handing the synthesizer a 1×1 cell — the divisor it already applies, set to the identity — so nothing on the path learns a second coordinate system. The terminal arm ignores it: a cell is the only unit a tty reports.full — PointerUnit on RunConfig, honoured by the GPU armhost.d PointerUnit; gui_loop.d; first consumer: hue P2.B4

| HST19 | There is a phase between opening and the first frame. run's optional setup alias runs once, after the arm opens and before the first frame, with the same host every other phase gets. An application whose first frame is its first contact with the surface needs none — it lays out against size and draws. One that computes a layout before the loop does: the surface's real size and the cell metrics the loaded font settled on are not knowable until the arm has opened, so nothing before that point can be measured against them, and deferring the work into the first frame means branching every frame on "have I started yet". The terminal arm seeds size from the terminal before calling it (a setup phase that saw a zero surface would lay out against nothing); the recorder's existing setup delegate is this phase. Config that must be in place earlier still — the trace sink, which has to be installed before the window opens or it misses the creation lines that matter — rides RunConfig, not this. | full — noSetup + the setup alias on run/runGui/runTui, the recorder's delegate; RunConfig carries traceSink/fontSizePxOverride/extraFontSources | host.d noSetup, RunConfig; run.d; gui_loop.d; tui_loop.d; record.d; first consumer: hue P2.B4 |

NOTE

HST17/HST18 were both forced by hue's migration, and both are the same kind of finding: a real application's input needs are not expressible through handle(Event) alone. hue also scaled the gesture recogniser's thresholds (slopPx, cellH) to the rendered cell size every frame, which is the host's job — GestureRecognizer.cfg is public precisely so the side that knows the cell size sets it, and the recogniser's own defaults (12 px slop, a 16 px row) describe no font we ship. The GPU arm now does it for every application, so touch targets on Android stop being sized for a font nobody uses.

IMPORTANT

HST6 exists because of a measured behavior in apps/terminal: when nothing is dirty it polls input and paces the frame without swapping buffers, keeping the last frame on screen and idle CPU near zero. A host that unconditionally begins and ends a frame would erase that, so the ability to decline is part of the contract rather than an optimization a backend may or may not honour.

HST7 exists because the GPU event synthesizer emits a resize event with a zero size by design (the caller is expected to re-query). That is a reasonable producer contract and a trap for every consumer; the host absorbs it once.

Testability (TST)

IDRequirementStatusTraces to
TST1The host must ship a recording target as a supported third backend: a scripted event list in, and the frames, draw operations and platform calls the application asked for out. It requires no window and no tty.fullrecord.d RecordingHost
TST2Every element of the host contract must be assertable through that target — quit, requested frames, skipped frames, all three render levels, resize normalization and end-of-input.fullrecord.d
TST3The same scripted session driven through the recording target and through a live backend must produce the same draw-operation stream, so target parity is a test rather than a claim (TGT10, at session scope).not started — and P2.B5 sharpened what it can mean. hue was expected to close it and cannot: it emits no draw operations (its frame is cells, painted through HST13), so there is no stream on either side to compare. What a recorded hue session proves instead is what view decided — drew or declined, asked to wake when, which errand it asked for — and three scripted sessions now assert exactly that. The op-stream half needs a component that produces one, driven through runRecorded and through runTui inside a pty, with the live side dumping its ops for comparisonrecord.d; tui_loop.d

| TST4 | Consumers of the host must move application decision logic out of modules excluded from their unittest builds. The excluded surface is tracked as a number, per application, and must fall. | in progress | PLAN |

Non-goals

Not this package's jobWhere it belongs
Widget composition, layout, themingsparkles:ui
Drawing primitives, atlases, cell gridssparkles:ui-raylib, sparkles:ui-tui
The event vocabularysparkles:input (INP)
Argument parsing machinerysparkles:core-cli — the host contributes a vocabulary, not a parser
Document/content decisions (what to render)the application

Module coverage

Every planned source file is covered by at least one requirement; see the traceability table on the overview page.