hue --gui — Feature Requirements (raylib GPU window)
Status: living inventory · Date: 2026-08-29 · Scope: what the raylib GPU window owns and a terminal cannot — the window itself, font loading and atlases, GPU-specific rendering, fullscreen, and procedural box-drawing. Everything a viewer does at a given interaction tier, on any canvas, moved to viewer.md; this file is the raylib backend — apps/hue/src/gui.d (window + interaction + paint), gui_preview.d (markdown model → wrapped-line layout), gui_ansi.d (```ansi fence decode), gui_text.d (pure metrics/search) — plus the shared libraries it drives: sparkles:raylib-text (fonts, glyph draw, procedural box-drawing), sparkles:ghostty (off-screen VT), sparkles:syntax (the markdown model), and sparkles:ui (the widget toolkit, including the table component).
Compiled into hue's default build (the GUI backend is included by default — BLD1); a raylib-/ghostty-free variant is the no-gui config (BLD2). The window opens automatically when a display is detected and can be forced or suppressed with --gui/--no-gui (general MOD6). Status scheme and ID conventions: see the overview. App-wide behaviour (source, engine, themes) is in feature-requirements.md; this doc covers only the GUI. The entry point is gui.runGui.
Design & scope (issue #121)
hue --gui is a third consumer of hue's identical (source, events, theme) triple — the same triple renderAnsi/renderHtml consume — folded into raylib draw calls instead of ANSI escapes or HTML markup. Nothing in hue's producer pipeline changes (file read, canonicalLanguage, highlightInjected, theme resolution, the plain-text fallback); only the sink is new. This is the "styled runs as data" GPU backend sparkles:syntax was designed around (StyledSpan/byStyledSpan/ResolvedTheme are its third-backend contract, and FontStyle stays backend-neutral), and building it forced the two seams the syntax spec reserved: toRgb(Color, palette, default) and byStyledLine.
It is a read-only, windowed, syntax-highlighted view with a live theme previewer and a render-markdown.nvim-style markdown preview. It is deliberately not a text editor, an incremental/LSP-backed surface, a terminal emulator, or the Vulkan engine (#47) — it is the smallest honest GPU consumer of the styled-run API, hosted in the app that already produces it. (The diff write wave later adds bounded editing surfaces — an editable diff pane, a comment composer — via the toolkit editor component (UIA9); the not-an-editor-app doctrine stands.)
NOTE
Most GUI areas below — SEL (selection), MDP (markdown constructs), NUM (line numbers), RND/VIW/WRP/NAV/SCB/FND/COD — are not GPU-specific; they apply best-effort to the other interactive backends. For a markdown file the MDP preview is the default rendering in the terminal and HTML backends too (general MOD8), not GPU-only — the same widget-tree views, a different canvas. The full terminal port and its GUI→TUI parity map are in tui.md; the HTML best-effort rows are HTM3–HTM5 in the general spec. This document remains the source of truth for the raylib backend itself.
Window & lifecycle (WIN)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| WIN1 | --gui must open a resizable raylib window titled hue — <file> and run a 60 fps event loop until closed. | full (e6063309) | runGui: InitWindow, FLAG_WINDOW_RESIZABLE, SetTargetFPS(60) |
| WIN2 | The window's initial size must follow --window-width/--window-height in cells, sized to the loaded cell metrics. | full (c2b49e99) | SetWindowSize(w*cellW, h*cellH) |
| WIN4 | The window title must always show the current theme name and index (file — theme (i/n)). | full (2febf905) | applyTheme → SetWindowTitle |
| WIN5 | The close button must exit; app keys must not be hijacked by raylib's default exit key. | full (e6063309) | SetExitKey(KEY_NULL); WindowShouldClose |
| WIN6 | With neither --gui nor --no-gui, the window must open by default when a display is available; --no-gui/--tui forces the terminal previewer instead (general MOD6/MOD7). | full (cdc813f6) | general MOD6/MOD7; displayAvailable/wantGui in app.main |
Font (FNT)
Fonts are owned by sparkles:raylib-text (FontSet); hue configures and drives it.
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| FNT1 | --font must accept a path, a family, or a fontconfig preference list (first installed family wins); default leads with Nerd-Font coding families. | full (c2b49e99) | FontSet.tryLoad(fontName); defaultGuiFont |
| FNT2 | --font-size is in points; it must be converted to pixels at 96-DPI, matching the terminal. | full (c2b49e99) | fontSizePx = fontSize*96/72 |
| FNT3 | Ctrl-= / Ctrl-- — and Ctrl+wheel (up grows, down shrinks) — must grow/shrink the font at runtime (reload faces + re-measure the cell), with a floor. A wheel frame takes ONE step whatever it accumulated: the sign is read, not the magnitude, because each step rebuilds the atlas. | full (2febf905) | fonts.reload(size ± 2); bumpFontSize |
| FNT4 | Glyphs outside the base atlas (icons, higher-plane, CJK) must load on demand: draw requests a codepoint, atlas grows after EndDrawing. | full (efa6cd7f) | fonts.flushPending() after EndDrawing |
| FNT6 | Monospace column width (v1) counts one column per codepoint — wide/CJK/tab characters count as 1 cell, so wide glyphs may overlap. | partial | gui_text.columnWidth (documented v1 limit) |
| FNT7 | Color-emoji / flag glyphs render as tofu (raylib/stb_truetype ignores CBDT/COLR); monochrome symbols render. | partial | raylib limitation (documented in raylib-text) |
| FNT9 | CJK renders only when the primary face covers it — on-demand atlas growth asks only the primary; fallback atlases are frozen at the base codepoint set (FNT14). The bundled default primary (Maple Mono NF CN, FNT13) covers CJK, so the gap is confined to hosts where that family is absent and no CJK --font is chosen. (--font-codepoint-map is exposed now — the unified ui-app CLI carries it, defaulting to the Uiua386 map — but a map is a per-range override, not a coverage fallback.) | partial | FontSet.resolveFace; gui_options.GuiCliFields |
| FNT10 | Underline is drawn as a single straight rule; the shaped TermStyle's curly/dotted/double/dashed styles and independent underline color are not rendered. | partial | rlTextStyle (underline bit only) |
| FNT11 | Per-style faces are explicitly configurable, ghostty-style: --font-bold, --font-italic, --font-bold-italic each take a family, fontconfig pattern, or font file; unset faces auto-detect from the --font family. hue never fakes an italic — a missing italic face renders upright (a synthetic slant/shift breaks grid alignment; tokens italic under one theme but not another appeared to move between themes). Bold without a bold face still double-strikes. | full | FontSet.FaceOverrides; loadFaceOverride; pickStyledFace |
| FNT12 | Variable fonts: selecting a named instance / axis values (wght/slnt/ital) for each styled face. fontconfig can resolve named instances; per-axis control needs FreeType variation APIs the raylib loader does not expose. | researched | FNT11; raylib LoadFontEx limitation |
| FNT13 | One bundled font set, resolvable identically on every platform. The fonts hue ships are defined once — nix/packages/fonts.nix (sparkles-fonts: Maple Mono NF CN in all four styled faces, FiraCode Nerd Font Mono Regular+Bold, DejaVu Sans Mono in four faces, Uiua386, each with an fc-query .charset sidecar) — and the Android asset bundle copies that derivation verbatim. A desktop install must be able to run font-deterministically from the same bundle: a hue-bundled nix package (BLD5) points the binary at the bundle as its default font directory (an env-var default the explicit --font-dir overrides — the SPARKLES_TS_GRAMMAR_PATH idiom), so nix run renders the same glyphs as the APK on a host with no fonts installed. Plain packages.hue stays the minimal-closure variant and resolves from the host font database. | partial | fonts.nix; FontSources; --font-dir; BLD5 (package pending) |
| FNT14 | Extended-Unicode symbol fallback (JuliaMono). The regular fallback (DejaVu Sans Mono) must be complemented by a high-coverage symbol fallback — JuliaMono (OFL, pkgs.julia-mono, ~3.2 MB Regular face; mathematical alphanumerics, operators/arrows supplements, APL) — bundled in sparkles-fonts and consulted for codepoints neither the primary nor DejaVu covers. Two routes: (v1) a default --font-codepoint-map entry over ranges the primary family lacks (mathematical alphanumerics U+1D400–U+1D7FF and friends) — exactly the shipped Uiua386 idiom, works with today's engine; (the honest fix) fallback faces gain coverage-driven on-demand atlas growth — today every fallback is loaded with the fixed base codepoint set and never grows, so resolveFace can only ever serve base-set glyphs from a fallback no matter what the font file covers, and the .charset sidecars the bundle already writes are read for the primary alone. | not started | loadFallbacksFromDirs; resolveFace; loadFaceCharset (primary-only); uiuaCodepointMap |
| FNT15 | Ligatures are a non-goal for the per-codepoint renderer. The raylib atlas is keyed by codepoint — there is no shaping pass, and calt/liga ligature glyphs are unencoded (no codepoint), so LoadFontEx cannot even load them, let alone select them from context; a ligature also spans cells, which the fixed cell advance, selection, and hit-testing (RND6, SEL*) are built against (the reason kitty refuses ligatures; ghostty ships HarfBuzz to render them). What is supported is baked in at build time: the bundled Maple Mono is rebuilt with its single-codepoint alternates frozen (cv03/cv08/cv61/cv64, ss07–ss10, zero — nix/packages/maple-mono), which is precisely the feature set freezing can deliver without a shaper. Real ligatures wait for a shaping-capable backend (the planned Skia/HarfBuzz peer backend) and a multi-cell glyph model. | non-goal (v1) / researched | nix/packages/maple-mono; raylib LoadFontEx limitation |
Render model (RND)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| RND3 | A styled run must draw per grid column via the shared per-run drawText (per-codepoint face routing). | full (b55be7aa) | sparkles.raylib_text.drawText |
| RND6 | The grid is monospace with a fixed cell advance (v1) — keeps column/gutter math and hit-testing trivial and matches apps/terminal. | full (b55be7aa) | cellW/cellH; gui_text.columnWidth |
CRT post-processing shader & cursor reactivity (CRT)
A GPU post-processing effect pipeline implemented in sparkles:ui-raylib (crt.d) and integrated into hue --gui (gui.d).
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| CRT1 | Curvature & vignette: The CRT shader must apply radial barrel distortion, corner rounding, and outer edge vignetting to simulate a convex cathode ray tube display. | full | crtTube in libs/ui/shaders/effects.d (crtCurve, the corner smoothstep, the vignette); run as the last pass of CrtEffect.effectRecord |
| CRT2 | Scanlines & shadow mask: The shader must render horizontal scanline modulation and RGB aperture phosphor shadow mask patterns. | full | crtTube (libs/ui/shaders/effects.d) |
| CRT3 | Dual-pass bloom & phosphor glow: high-luminance pixels must bleed into adjacent regions via bright-pass extraction and a separable gaussian blur, with configurable intensity, threshold and radius. The blurred result must be sampled at the same warped uv as the content, so the glow curves with the tube rather than floating flat above it. | full | bloomPasses (bloomExtract/bloomBlurH/bloomBlurV in libs/ui/shaders/effects.d) as the CRT record's first three passes; texture1 in crtTube; bloomIntensity/bloomThreshold/bloomRadius; appearance.crt.bloomIntensity |
| CRT4 | Chromatic aberration: Radial chromatic aberration must split RGB channels towards screen edges. | full | uChromaAberration in crtTube |
| CRT5 | Pointer reactivity: the curvature's apex must follow the pointer when tilt is enabled, so the surface is flat directly under it and the far side curves away; with the pointer in a corner that corner straightens out. (An earlier draft specified a flattenRadius/flattenStrength mechanism; the shipped model is the tilted apex, which has no such parameters.) | full | crtCurve; mouseTilt uniform; CrtProjection.tilt |
| CRT6 | Pointer mapping & in-shader rendering (appearance.pointer.mode = software, the default): mouse pointer motion is linear in texture space (UI coordinates), translating to non-linear curved motion in screenspace. When CRT is active, the host compositor cursor is hidden and the pointer is rendered in-shader at the corresponding curved surface coordinates. Input events operate directly in linear texture space. PTR1 inverts this. | full | renderCursor in libs/ui/shaders/effects.d; runGui cursor management |
| CRT10 | Screen fit: the projection must seat the midpoint of each texture edge exactly on the corresponding screen edge at $(B any) curvature — fit = 1 / (1 + k/4), since the bend displaces a point at radius r by (1 + k*r*r) and an edge midpoint sits at r = 1/2. A flat screen (k = 0) is therefore a 1:1 blit, and the only part of the window without an image is the rounded corners. Exact for a centred apex; an off-centre one (tilt) deforms the four edges unequally and no single scalar seats them all. | full | crtCurve, which CrtProjection.curveStep calls; ui_raylib.crt_projection.curve.seatsEachEdgeMidpointOnItsScreenEdge |
| CRT8 | Magnifier lens centred on the pointer: the magnification lens must be applied in texture space, after curvature, so the point it leaves fixed is the very one the in-shader cursor (CRT6) is drawn at. Applied before curvature the two centres diverge with distance from the middle of the screen. | full | crtLens, called by crtTube and CrtProjection.mapScreenToUi; ui_raylib.crt_projection.magnifierLensIsCentredOnTheCursor |
| CRT9 | UI-reactive boxes are harvested from what the frame painted (EFX23): every CrtUiContext rect must come from the frame's op stream by Slot — the focused group's painted extent, the selection, the split, the text under the pointer, the document's thumb — never from a second, independently re-derived centring (which is how the halo once sat three rows below the picker). | full | gui.d frameList; crtUiContextOf (uiRaylib.crt.uiContextIsHarvestedFromTheFrame) |
| CRT7 | Live toggle & uniform binding: F12 must toggle the CRT shader at runtime; uniform parameters update per-frame without pipeline re-creation. | full | runGui KEY_F12 (the root bracket is emitted only while enabled); CrtEffect.writeParams via EffectRegistry.setParam |
Pointer (PTR)
Which pointer the window shows, and therefore which coordinate space pointer input arrives in. The two modes are each internally consistent and pick opposite invariants; appearance.pointer.mode is the choice, not a tweak.
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| PTR1 | appearance.pointer.mode selects software (default — hue draws the cursor in-shader, CRT6) or system (the window system draws its own). Under system the compositor cursor must not be hidden and the shader must draw no cursor; the two are one condition, so switching mid-run restores the other on the next frame. | full | settings.PointerMode; CrtEffect.systemPointer/drawsOwnPointer; Window.pointerVisible; the negative cursorShape uniform |
| PTR2 | Under system, pointer input arrives in screen space and must be translated to the UI point rendered under it. Magnification is not applied — the lens is centred on the pointer and leaves that point fixed, so it cancels exactly; tilt's apex is that same point, so the map must be resolved to its own fixed point rather than run once against the previous frame's value. | full | CrtProjection.mapPointerToUi; ui_raylib.crt_projection.mapPointerToUi.resolvesTheDistortionsCentredOnIt |
| PTR3 | A warped image does not fill its window, so some screen positions have no UI point under them — the tube's rounded corners, CRT10 having seated the edges flush (1.2% of the window at the default curvature, 7.7% at the maximum). Uncaptured, a move there must arrive as leave — hover clears rather than sticking — and a press must be swallowed, and therefore must not start a capture. Captured, every event must be clamped onto the surface and delivered, and a release must be delivered whatever the capture state, or a drag dies at the bezel and the button never comes up. | full | sparkles.input.surface.PointerCapture; gui.admitEvent |
| PTR4 | The translation must happen once, at the event intake, not per consumer: rawPointerPos, the frame fold and the dock's own drain all read the same evBuf. HUE_GUI_POINTER stands in for a window-system event nobody sent, so it is synthesized as one and put through the same intake — routing included, not merely the coordinate map. A harness that exercised a second path would certify behaviour the product does not have, by construction rather than by drift. | full | gui.admitEvent; sparkles.input.surface.admitOntoSurface; input.surface.admitOntoSurface.mapsRewritesAndDrops |
Fullscreen (FSC)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| FSC1 | F11 must toggle borderless fullscreen and restore window decorations on the way back (managing the undecorated flag + geometry ourselves, not raylib's ToggleBorderlessWindowed). | full (e463ac95) | F11 handler in runGui (fixed 8a03bff1) |
| FSC2 | Fullscreen must target the window's current monitor on X11; on Wayland the app cannot set its position, so the window stays on its monitor rather than jumping to the primary. | partial | GetCurrentMonitor/SetWindowPosition (Wayland size may be wrong if monitors differ) |
Debug / CI hooks (DBG)
Environment-variable hooks that make the GUI deterministically capturable headless.
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| DBG1 | HUE_GUI_SCREENSHOT=<path> must render a few warm-up frames then write a PNG and exit (headless golden capture). The capture must also be reproducible: it waits out the async git-status worker, whose landing rebuilds the tree, so two runs of one binary cannot differ. A worker that never settles is capped rather than allowed to hang the capture. An animated effect's clock is pinned for the capture (pinEffectClock), so the CRT's jitter, roll and flicker cannot make two captures differ either. | full (a0b0f93a) | shotPath; window.screenshot; settledAt/shotSettleCap; GuiHost.pinEffectClock |
| DBG2 | HUE_GUI_TOP, HUE_GUI_FONTSIZE, HUE_GUI_PREVIEW, HUE_GUI_SEARCH must pin initial scroll / font px / view mode / a preselected search for deterministic captures. | full (1e218180) | env reads in runGui |
Box-drawing (BOX, shared library)
Implemented in sparkles:raylib-text (box.d), consumed by hue's tables, code boxes, and quote bars.
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| BOX1 | Box-drawing glyphs (─│┼╭╮╰╯ + heavy/header-rule forms) must render procedurally (arms drawn to the cell edges) so rules connect across cells instead of using gappy font glyphs. | full (1ab4e71d) | sparkles.raylib_text.box.drawBox/boxSpec |
| BOX2 | Uncovered forms (dashed/double/diagonal) must fall back to the font glyph. | full (1ab4e71d) | boxSpec returns valid == false |
Milestones (issue #121)
The GUI backend shipped as milestones M0–M5; M6 is optional/future. (There is no M7+ in the hue-GUI track; the M0–M8 / D1–D3 ladder belongs to the twoslash / dmd-lsp design — see twoslash.md.)
| Milestone | Scope | Status | Requirements |
|---|---|---|---|
| M0 | gui config + --gui gate + version(HueGui) seam | full (e6063309) | WIN1/5, general MOD1/2, NFR3 |
| M1 | The render fold (toRgb/byStyledLine seams; draw the triple on the GPU) | full (b55be7aa) | RND1/3/6/7, VIW1 |
| M2 | Viewport culling + line-number gutter + scrollbar | full (a0b0f93a) | RND1, NUM1, SCB1 |
| M3 | Font sizing + window resize + live theme cycling | full (2febf905) | FNT3, WIN3, THG1 |
| M4 | Incremental search + goto-line | full (1e218180) | FND*, NAV2 |
| M5 | Extract sparkles:raylib-text; refactor terminal + hue onto it | full (d1dd79d5) | FNT*, RND3, BOX* |
| M6 | (optional) Semantic refine via sparkles:dmd-lsp identifierTypes | not started | SEM1 |
| — | Markdown preview (render-markdown.nvim parity) — a later effort on top of M5 | full | WRP*, MDP*, COD*, SEL* |
| — | GUI-by-default: gui/tui autodetection + no-gui config/package | partial (cdc813f6, 29bf1a65) | WIN6/MOD6/7/BLD1/BLD2 done; BLD3 hue-no-gui nix package pending |
| — | CRT post-processing shader with cursor reactivity & unprojection | full | CRT1–CRT10 |
Module coverage (GUI spec)
| Source | Key symbols | Requirements |
|---|---|---|
apps/hue/src/gui.d | runGui (window/fonts/input/paint), scrollbar (Scrollbar/thumbGeometry), hitAt, text/table selection regimes, copy pass (--ansi-copy/--table-copy + toggle keys), the explorer pane, F11, F12, env hooks | WIN*, FNT2/3, RND*, VIW2, NUM2/3/5, NAV*, SCB*, THG*, FND*, COD3/4, SEL*, TBL1/2/4, FSC*, DBG*, CRT* |
libs/doc-view/src/sparkles/doc_view/viewer_model.d | ViewerModel (the viewer's Whole: document, theme-resolved colors, pipeline artifacts, folds, scroll, search), MdFence/MdCell, attrsToTextStyle | RND2, WRP3/4, NUM1, FND2/4, COD3, MDP12 |
libs/doc-view/src/sparkles/doc_view/preview_model.d | PreviewModel/CodeFence, buildPreviewModel, stripSgr, quoteBarColors | VIW1/3, MDP* (the model side) |
libs/doc-view/src/sparkles/doc_view/table_select.d | pure smart-drag→region + TSV/markdown/sub-cell serializers | TBL1/2/5 |
libs/doc-view/src/sparkles/doc_view/ansi_decode.d | decodeAnsi, AnsiLine/AnsiSpan, Attr | MDP12 |
apps/hue/src/gui_text.d | columnWidth, lineCount, buildLineStarts, findMatches, Match | FNT6, WRP* (metrics), FND2, NUM3 |
sparkles:raylib-text | FontSet, drawText, drawGrapheme/drawSolid, box.drawBox | FNT1/3/4/5, RND3, BOX* |
sparkles:ui-raylib | CrtEffect, CrtConfig, unprojectCrtCursor, RaylibCanvas | CRT*, TGT12 |
sparkles:ghostty | off-screen VT (ghostty_terminal_*) | MDP12 |
sparkles:core-cli | args, key_input, term_caps | CLI* (general) |
sparkles:ui (components) | components.table.widgets.buildTableWidgets (the span-capable table's widget view — one layout core with drawTable), keyedRects, selectionRects, sourceOffsetAt | MDP10, TBL2/3 |