hue --gui — Feature Requirements (raylib GPU window)
Status: living inventory · Date: 2026-07-23 · Scope: the hue 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) |
| WIN3 | Resizing must reflow content: any change in the available column count re-runs layout. | full (2febf905) | widthCols() != lastWidthCols → relayout |
| 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 |
| FNT5 | Bold/italic/strike/underline attributes must map onto the shared TextStyle; real bold/italic faces are used when present. | full (d1dd79d5) | mapStyle/mapAttrs; sparkles:raylib-text |
| 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) |
| FNT8 | Preview decoration glyphs (heading/callout/link icons, checkboxes, box-drawing) are Nerd-Font codepoints; a non-Nerd --font degrades them to tofu. | partial | defaultGuiFont doc; icon glyph sites |
| FNT9 | CJK renders only when the primary face covers it — on-demand loading asks only the primary, and hue does not expose the terminal's --font-codepoint-map, so CJK is tofu unless a CJK --font is chosen. | partial | FontSet.resolveFace; tryLoad (no codepoint map) |
| 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 | mapStyle (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 |
Render model (RND)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| RND1 | Each frame must clear to the page background and paint only the viewport-visible rows (index-culled by topLine). | full (a0b0f93a) | the op cull + base clip in runGui |
| RND2 | Every view must be one laid-out widget tree painted by a single painter; content scrolls by visual row. | full (b4b837f4) | ViewerModel.rebuild; the one paint pass |
| 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 |
| RND4 | Content must have a 1-cell background-filled left padding and a scrollbar gutter on the right; the left padding is page background. | full (5ca88625) | padX, rightPad, gutterPx in runGui |
| RND5 | Totality is the law: an unknown language, missing grammar, oversized file, or parse failure must degrade to plain uncolored text in the window — never a crash, never half-colored output. | full (74d8f6a3) | ENG4 fallback (general spec); 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 |
| RND7 | The render fold must go through the two sparkles:syntax seams this backend motivated: byStyledLine (spans clipped at \n, stable per-row y) and toRgb(Color, palette, default) (the unset/default_/palette/rgb sum type → RgbColor). | full (b55be7aa) | byStyledLine; toRgb (sparkles:syntax) |
Views & toggle (VIW)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| VIW1 | A raw highlighted-source view must render the file's styled runs (wrapped). | full (b55be7aa) | viewCodeDocument |
| VIW2 | Tab must cycle the view modes in the order of progressive enhancement — plain text → syntax-highlighted → rendered md-preview — so each press demonstrates the next fidelity tier the document's content earns: what the bytes say, what the grammar adds, what the renderer builds from the structure. A markdown file opens at the top tier (the preview) and the cycle wraps back down to plain; a non-markdown file has no preview tier and toggles plain ↔ syntax. The status chrome names the mode (preview / raw / plain). | full | ViewerModel.cycleView/plainSyntax; Command.toggleView |
| VIW4 | The plain view is the raw view with an empty event stream — same wrapping, folding, gutter, and selection; only the styling is absent. | full | viewCodeDocument(source, null, …) |
| VIW3 | The preview model must be built once at load from the markdown structural model + per-fence highlight/ANSI decode. | full (9b0a4b50) | buildPreviewModel |
Wrapping & visual-line model (WRP)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| WRP1 | Prose must soft-wrap (word-wrap) to the available width, with hang-indent on continuation lines. | full (d871c47c) | Layouter.emitFlow |
| WRP2 | Code and ```ansi fence lines must hard-wrap (character-level, styled runs split at the column) to the panel width; no horizontal overflow/clipping. | superseded | fence rows render unwrapped and clip within the panel (the canvas clip pair) |
| WRP3 | The raw (non-markdown) view must wrap long physical lines too (greedy word wrap; indentation survives as a no-break prefix; an overwide token overflows and clips). | full (f0148ef8) | viewCodeDocument TextWrap.greedy |
| WRP4 | All views must reflow on window/font resize (rebuild the wrapped-line list on width change). | full (8a03bff1) | ViewerModel.relayout on widthCols change |
Line numbers (NUM)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| NUM1 | A file line-number gutter must number by physical (source) line, shown only on the first visual row of a wrapped line (continuations blank). | full (85dcc4cec) | a GUT3 channel composed after layout (lineNumberCells), no longer painted in runGui — so the TUI and the markdown preview number too |
| NUM2 | The file gutter is on by default; --line-numbers=false disables it and l toggles it at runtime (reflow follows). | full (eb4df89c0) | Generalized to GUT5: --gutter all|none|<names> selects any channel by name, --line-numbers=false is a shorthand over it, l and <leader>vg toggle at runtime in both backends. The stream backends leave the numbers out unless named — their output is the text a reader pipes and copies |
| NUM3 | The gutter width must be stable (from the source line count) so toggling never oscillates the wrap width. | full (85dcc4cec) | GUT4 reservation: digitCount(srcTotal), never the data |
| NUM4 | Each code block must have an in-panel code-relative line-number gutter (1..N), first-wrapped-row only, dimmed. | full (5b862346) | codeGutterStr; codeLineNumbers |
| NUM5 | Code line numbers are on by default; --code-line-numbers=false disables and c toggles at runtime. | full (5b862346) | codeLineNumbers; KEY_C |
Navigation & scroll (NAV)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| NAV1 | Mouse wheel must scroll (3 lines/notch) — except with Ctrl held, which zooms instead (FNT3); ↑/↓ and j/k scroll one line; PageUp/PageDown a page; Home/End to ends. | full (e813f99a) | normal-mode key block in runGui |
| NAV2 | gl must enter goto-line mode; entering a number jumps to that source line's visual row. (g became a prefix when the keymap was unified — gg/Shift-G are the vim motions, lantern LMP3.) | full (1e218180) | Mode.gotoLine; visualOfSrc |
| NAV3 | The per-frame clamp must keep the scroll offset addressing a real row — and nothing more. It must not re-impose maxTop: that is the pane-filling rule, which belongs to the scroll operations (NAV6), and enforcing it every frame is what moved the first line when the window grew (issue #299). | full (511d82d5) | ViewerModel.clampView |
| NAV4 | The scroll offset must be defined in source terms, not as a visual row index: a visual row is a function of the laid-out width, so a resize, a Ctrl-± font step or a pane toggle changes what a given index means. | full (67623407) | ScrollAnchor; ViewerModel.top |
| NAV5 | Every re-layout must keep the first visible line in place — capture the source byte showing at the top of the pane before the rebuild, re-find it after. Two modes, --scroll-anchor: segment (default) keeps the exact wrapped segment; line snaps to the wrapped line's first segment. Blank/synthetic rows above the anchored content are preserved with it, and a new document is exempt (its outgoing rows describe the outgoing buffer). | full (67623407) | captureAnchor/rowForAnchor/restoreAnchor |
| NAV6 | A resize must only change how much of the file follows the first line. A pane that grew must not scroll the content up to keep the tail flush with the bottom; the offset may legally sit past the last full screen, downward travel is then refused rather than snapped, and scrolling back inside the clamp restores ordinary behaviour. Every scroll container the offset round-trips through must be published the same extent (scrollExtent). | full (52610621) | scrollVertical/scrollTo/scrollExtent |
| NAV7 | The rule must hold for every scroller keyed to content: the document view in both backends, the explorer and inspector trees (TreeViewState.resize — row lists, so only the growth case applies), and the DSV browser (which scrolls through the document pipeline). Fence and table inner viewports are anchored by construction — their offsets are keyed by source span and their bodies do not re-wrap. | full (d658bada) | tree_view.resize; fenceScrollAt |
Scrollbar (SCB)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| SCB1 | A scrollbar must appear only when content exceeds the viewport; its thumb size/position reflect the visible fraction and scroll progress. | full | OpKind.scrollbar; scrollbarRail; STM2 |
| SCB2 | On hover/drag the handle must expand to a font-proportional width (1.5 cells) from a thin proportional idle rail, eased over time. | full | semantic ScrollbarAnim.percent; RaylibCanvas.scrollbar |
| SCB3 | The reserved right gutter must equal the expanded handle width so the handle fills the gutter without overlapping text. | full | scrollbarGutter(); railExpandedPx |
| SCB4 | Dragging the thumb must track the cursor; clicking the track must center the viewport on the click. | full | ScrollView.stepV/stepH; STM9 |
| SCB5 | The hover track + thumb must use a distinct (link-tinted) color, so they read against the grayscale page/code bands. | full | DrawOp.barTrackColor; visual.fg |
Live theme cycling (THG)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| THG1 | ←/→ must cycle the theme (wrapping), re-resolve colors, rebuild the derived palette, and repaint live. | full (2febf905) | applyTheme; KEY_LEFT/KEY_RIGHT |
| THG2 | Decoration colors (heading accents, quote-bar cycle, callout accents, scrollbar) must be theme-derived, not fixed. | full (17b33d09) | resolvePalette; quoteBarColors |
| THG3 | The cycle order must group themes by brightness by default (--group-themes; disable with =false): the alphabetical list is stably partitioned dark-first (a theme is light when its document background's relative luminance exceeds the midpoint), so each brightness forms one contiguous run and rapidly cycling dark themes never flashes a light background — the boundary is crossed only deliberately. | full | sortedThemes(name, grouped); isLightTheme |
Search & goto (FND)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| FND1 | / (raw view) must start an incremental search; a bottom input line shows the query + match count. | full (1e218180) | Mode.search; input-line paint |
| FND2 | All matches must be found in the source and overlaid as a translucent tint, the current match brighter. | full (1e218180) | findMatches; match overlay |
| FND3 | Enter jumps to the first match at/after the view; n/Shift-n cycle matches (centered). | full (1e218180) | jumpToMatch; KEY_N |
| FND4 | Because lines wrap, matches (source coords) must be remapped to visual rows via each line's srcLine+wrapColOffset. | full (f0148ef8) | ViewerModel.visualOfMatch; per-match rects from selectionRects |
Markdown preview constructs (MDP)
The composable widget view — sparkles:syntax's viewMarkdown over the structural model (extractMarkdown), one view for every backend (RND2); gui_preview.d builds the model. Each block/inline construct is a requirement. Full-width chrome (heading bands, fence boxes, rules) really is full-width: the block columns grow to the width bound, and glyph-composed chrome knows its exact width because the view threads every ancestor's indentation (indentCols).
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| MDP1 | Headings must render a per-level Nerd icon leader + per-level accent color + a subtle full-width band. | full (21da63cf) | Layouter.heading; headingAccents |
| MDP2 | Paragraphs must soft-wrap; inline styling (bold/italic/strikethrough) must map to attributes. | full (17b33d09) | inlineRuns; emitFlow |
| MDP3 | Inline code spans must render with a distinct background. | full (17b33d09) | inlineRuns codeSpan; inlineCodeBg |
| MDP4 | Bullet lists must use ●○◆◇ glyphs cycled by nesting depth; ordered lists number. A nested child block indents by exactly its parent item's leader width (2 cells under ● , 3 under 1. ), so depth accumulates as the sum of ancestor leaders and nested content starts where the item's own text does. | full | the list case's leader + padded child wrappers |
| MDP5 | Task-list checkboxes must render Nerd glyphs ( unchecked, checked in green). | full (76405ab1) | Layouter.list checkbox |
| MDP6 | Block quotes must render as cards: a heavy ┃ accent bar (2-wide border; color cycling by depth) over a bar-tinted background whose tint deepens with each nesting level (0.06 + 0.04·depth toward the level's bar color) — nesting reads at a glance. | full | the blockQuote panel; quoteBars; the cell backends' ┃ |
| MDP7 | GitHub callouts (> [!NOTE/TIP/IMPORTANT/WARNING/CAUTION]) must render the same card treatment in the callout's accent: heavy ┃ bar, accent-tinted background (nesting deepens it), icon + bold accent title sitting tight on the first body block; the marker is stripped from the body. Detection is source-based (the marker parses as a shortcut-link). | full (bb296176) | detectCallout/calloutPanel |
| MDP8 | Links must prepend a per-destination icon (github/gitlab/mail/web/file), then the underlined label. | full (39c16ce5) | inlineRuns link; linkIcon |
| MDP9 | Images must render a monochrome Nerd glyph + alt text + destination (not the tofu-prone 🖼 emoji). | full (39c16ce5) | inlineRuns image |
| MDP10 | Tables must render the full grid as glyph runs: rounded outer corners, │ column separators, a heavy ┝━┿━┥ rule under the bold header row, and — by default — light ├─┼─┤ inner rules between body rows (MdViewOptions.tableRowRules); one padding cell inside each separator, per-column alignment from the delimiter row, cell text edge-trimmed before track measurement. The whole-table copy button sits in a top-border cutout (TBL6). | full | the table adapter over ui.components.table buildTableWidgets (the shared layout core; MDP10's glyph grid is the component's) |
| MDP11 | Thematic breaks must render a full-width rule; HTML blocks render their raw lines (muted italic). | full (d871c47c) | Layouter.rule/htmlBlock |
| MDP12 | ```ansi fences must be decoded to styled lines by an off-screen libghostty-vt terminal (no PTY/window) and rendered with the live theme's default colors substituted for default-colored cells. | full (ffd36f7c) | gui_ansi.decodeAnsi; AnsiLine/AnsiSpan |
| MDP13 | Definition lists and footnotes are not rendered (the bundled grammar collapses them); they degrade to plain paragraphs / nothing. | partial | md/model.d (documented grammar limits) |
| MDP14 | LaTeX math, wiki-links ([[…]]), and inline highlight (==text==) are not rendered — deferred from the render-markdown.nvim shortlist. | not started | render-markdown gap-analysis (deferred tier) |
| MDP15 | Custom checkbox states beyond [ ]/[x] (e.g. [-]) are intentionally not recognized (a [-] is a genuine markdown shortcut-link — ambiguous). | non-goal | dropped in 3d3ad89f |
| MDP16 | Footnote superscripts ([^1]) and bare-URL autolinks (GFM) are not rendered — not modeled by the bundled grammar / inline pass. | not started | md/model.d (not modeled) |
| MDP17 | YAML/TOML front-matter is not rendered as a distinct block. | not started | render-markdown parity gap |
| MDP18 | Only the 5 GitHub callout types are recognized; the wider Obsidian set (abstract/todo/question/failure/danger/bug/example/…) is not. | not started | matchCalloutType (5 types) |
| MDP19 | Table border presets (round/double/none) and explicit alignment-indicator glyphs are not exposed (box borders + alignment ship via core-cli). | not started | the markdown view's table panel (fixed preset) |
| MDP20 | Further render-markdown cosmetic parity — heading border glyphs / min-width / margins, code-block diff backgrounds & insets, inline-code icon affixes, per-level org indent, sign-column gutter — is intentionally not matched (hue's preview is lighter-weight). | non-goal | design (lighter-weight preview) |
| MDP21 | Preview-diff decorations: the preview widgets must accept per-block / per-cell change decorations (added/removed/changed tint slots) so a markdown diff renders in the decorated preview (DVN6 --diff-preview) — changed table cells tinted in the box-drawn table, word-level tinting inside paragraphs, struck/collapsed deleted blocks. | not started | DVN6; viewMarkdown decoration channel |
| MDP22 | VitePress code groups (::: code-group … :::) must be one block in the document model, with the fenced blocks as its children, showing one at a time behind a tab strip (WGT23) that rides the showing fence’s TOP BORDER, BOXED into the border: the strip carries its own top border, the tabs’ row IS the box’s top border line, and the strip’s ╭ is the box’s top-left corner (╭──┬──╮ over │ tab │ tab ├──); line-drawn — only the active tab fills inside its walls. A tab's title is the fence's [label] with its devicon in front, falling back to the language (which a specific devicon replaces). Selection is source-anchored (codeBody.start), clickable in both hosts. A malformed container degrades to the blocks it was made of rather than swallowing them. | full | md/model.d foldCodeGroups; viewCodeGroup → fenceTopBorder; activeCodeTabs |
Code blocks (COD)
The fence chrome is the box itself — every affordance lives on a border, glyph-composed so all backends render it identically (the GPU draws box glyphs procedurally, so they connect):
╭─ filename.py ─────────────────────────────── ⧉ ─╮ label (icon-only when
│ 1 import sys │ the devicon is specific)
│ 2 print("a long line that scrolls, not wraps…" │ + the ⧉ copy cutout
╰━━━━━━━━━━━━━━━━━━━━━──────────────────────────────╯ bottom border = h scrollbarA grouped fence puts the code group's tab strip on the same line; a fence taller than --code-max-lines scrolls behind its RIGHT border — the vertical scrollbar, mirror of the bottom one — its number gutter scrolling with the code but pinned sideways:
╭─────────┬─────────╮
│ app.d │ build ├──────────── ⧉ ─╮ ╭─ ────────────────╮
│ 1 void main() {} │ │ 1 int line01; ┃ │
╰─────────────────────────────────────╯ │ 2 int line02; ┃ │
│ 3 int line03; │ │
tabs boxed into the border (MDP22) ╰────────────────────╯| ID | Requirement | Status | Traces to |
|---|---|---|---|
| COD1 | A code block must render as a rounded box on all four sides (top ╭─ lang ─╮, side │…│, bottom ╰──╯), connected by procedural box-drawing. | full (88c5df57) | codeFence; box runs |
| COD2 | The TOP BORDER must embed the language: ╭─ python ──╮. A specific devicon replaces the language name outright (the glyph is the recognizable form); the generic glyph keeps the name beside it. A grouped fence embeds the code group's tab strip on the border line instead (MDP22) — tabs carrying their own icons. | full | langTitle/langIcon; fenceTopBorder (the groupTabs arm); tabTitle |
| COD3 | A copy-to-clipboard button must sit in a cutout of the top border just before the corner (── ⧉ ─╮); clicking copies the block body and flips to a green checkmark for ~1.2s. | full (f1d468ac) | fenceTopBorder's cutout glyph (the fenceHitBase hit); ViewerModel.markCopied |
| COD4 | The copy button must anchor to the border cutout column (not screenW) so it stays aligned across widths. | full (88c5df57) | the header band's copy glyph (widget view) |
| COD5 | Highlighted code must keep its per-token syntax colors inside the panel; the panel background is distinct from the page. | full (f1a53bd3) | codeFence highlighted branch |
| COD6 | Body overflow must be handled by a user-selectable --code-overflow mode. scroll (default): the body is a per-fence viewport (clipX/clipY + childOffset, number gutter pinned in x, scrolling in y), the BOTTOM BORDER is the horizontal scrollbar (╰━━───╯ — wheel sideways / Shift+wheel, press, drag, and track-click through the shared ScrollView machine), a fence taller than --code-max-lines (default auto — the whole box, borders included, fits the document pane; 0 disables) shows a fixed-height viewport whose RIGHT BORDER is the vertical scrollbar (│ track, ┃ thumb — the bottom bar's mirror; a vertical wheel scrolls the fence to its edge before the document), and clipped content never engages the document-wide bar. Both bars run the pane scrollbars' ScrollView machine and emit the same semantic scrollbar operation as pane bars; the GUI resolves a sub-cell rail around the border line and cell hosts degrade it to glyphs. The pointer shape follows through the same shape() chain (SCV6). wrap: greedy in-panel wrapping, continuations hanging past the number gutter. wrap-at:N: the body pre-wraps to an N-cell box (frame included; continuations blank their gutter number) and rides the scroll structure — the box caps at min(N, panel), the bottom-border bar appears only when N exceeds the panel, and --code-max-lines counts visual lines. An empty fenced body renders as an empty panel, never the raw fence markers. | full | OverflowPolicy (ScrollOverflow/WrapOverflow/WrapAtOverflow — shared with --table-overflow)/FenceScroll; OpKind.scrollbar; ViewerModel.setFenceScroll/fenceSv + clip-aware contentCols; wheelCellsX (sparkles:input) |
| COD7 | A fence inside a block quote / callout must treat its body as code, not as quoted lines: the > continuation prefixes (blanked to spaces in the working copy for the inline re-parse) are dropped everywhere the body is consumed — rendering, syntax-highlight anchoring, the copy button, group height-counting, and the scroll clamps — with the identity channel mapped exactly across the dropped ranges. An empty fenced body renders as an empty panel, never the raw fence markers. | full | MdBlock.codeOmit; fenceBody/FenceBody.srcAt; MdFence.text |
Mouse selection & clipboard (SEL)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| SEL1 | A left-drag must select content; the selection is a half-open byte range into the original source file (const(char)[]). | full (88c5df57) | sourceOffsetAt (the identity channel); TextSpan.srcStart |
| SEL2 | Selection must include content only — gutters, line numbers, box borders, bullets, and other decorations are excluded (they have no srcStart). Tables are not "excluded" but handled by the separate TBL grid regime. | full (88c5df57) | srcStart == size_t.max skip; TBL |
| SEL3 | srcStart must survive wrapping and word-splitting (each split piece carries its byte offset). | full (88c5df57) | wrapSpans identity propagation |
| SEL4 | The selection must be highlighted with a translucent tint; Ctrl-C must copy source[a..b] to the clipboard. | full (88c5df57) | selection highlight pass; SetClipboardText |
| SEL5 | A click consumed by the copy button or over the scrollbar must not start a selection. | full (88c5df57) | copyClicked/overSb guards |
| SEL6 | A ```ansi fence body must be cell-granular file-offset selectable, like a terminal emulator: each decoded cell maps to its source byte (the off-screen VT is 1:1 with source lines and a span's text is the contiguous source between two escapes), so a drag selects character-precise and the │/gutter/padding stay excluded (SEL2). Copying a range yields the raw source between the endpoints (escapes included). | full (c018f39a) | ansiColToSrc; span srcStart in codeFence decoded branch |
| SEL7 | A copy mode must govern how a text-regime selection is emitted: --ansi-copy=raw|strip (default raw), toggleable at runtime with a status-bar indicator. strip runs stripSgr over source[a..b] (drops SGR escapes); it also applies to the ANSI code-fence copy button (COD3). | full (debfd92d) | CliParams.ansiCopy (CLI10); stripSgr; copy pass |
| SEL8 | Selecting the whole document (drag from start to end) must reproduce the input source file on the clipboard: every selectable region maps to source offsets, so source[selMin..selMax] over the full range is the file (ANSI raw + table markdown included). | full (c018f39a) | contiguous source-offset discipline across SEL/TBL; text-regime table crossing |
| SEL9 | Diff panes are selection domains: in a split diff a drag is confined to the side it starts in — the interactive mirror of the HTML rule (HTM8 / DVL4); copying honors --diff-copy=text|patch (DVL8) — plain text of the selected side, or a valid unified sub-patch. | not started | proposed diff selection domain; DVL8 |
| SEL10 | Selection and scrolling must compose: while the primary button remains held, wheel/key/jump scrolling extends the selection at the stationary pointer, and holding a drag in either edge band autoscrolls that axis continuously (both axes for a diagonal drag) while extending the selection. It stops at the content end and on release, on GUI and TUI alike. | full | container-owned SCV8; synthetic pane-local drag from DockContainer.tickScroll |
Table grid selection (TBL)
Tables break the SEL file-offset rule (cells are reflowed/stripped and the core-cli renderer wraps cell content across lines, so there is no clean byte-range). Instead a markdown table gets spreadsheet-style 2D selection, a regime distinct from SEL. A DSV document (dsv-preview.md) consumes this regime whole; its deltas — the source dialect copy format, chrome exclusion, whole-file byte reproduction — are DSC1–DSC5 there.
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| TBL1 | A drag inside a table must select via smart drag: within one cell → a sub-cell character range; across cells → a rectangle of whole cells (rows×cols spanned); Shift snaps the rectangle to full rows, Alt to full columns. | full (debfd92d) | table_select.d; gui.d table-regime drag |
| TBL2 | The selection must be highlighted (per selected cell's on-screen rects; a sub-cell selection clipped to its character range). Ctrl-C must copy it: a rectangle per --table-copy=auto|tsv|markdown|source (CLI11; source re-emits a DSV document's own dialect, DSC2), a sub-cell → the cell substring. | full (debfd92d) | selectionRects over cell source spans; table_select serializers |
| TBL3 | Screen↔cell mapping must be wrap- and alignment-aware — mapping a click (output line + x) to (gridRow, gridCol, charInCell) and back to a cell's screen rects — via a new core-cli table layout API. | full (634dff4b) | keyedRects cell rects + sourceOffsetAt (gui.d hitAt); MdCell |
| TBL4 | A drag that starts on a table cell uses the 2D grid regime (confined to that table); a text-regime drag that merely crosses a table selects it character-precise (each cell maps to its source byte via cellSrc[row][col] + charInCell; borders fall back to the block span), so whole-document selection reproduces the table markdown (SEL8). The regime is fixed at mouse-down. | full (bc3ef6cf) | gui.d mouse-down regime pick; MdCell.span source slices; hitAt |
| TBL5 | The smart-drag→region rule and the TSV/markdown/sub-cell serialization must be presentation-free and testable (no raylib), consumed by the GUI painter/copy. | full (4d1e2da2) | table_select.d (pure); unit tests |
| TBL6 | A whole-table copy button must sit in a right-side cutout of the table's top border (the same idiom as the code button COD3 — slightly left of the corner); clicking copies the whole grid serialized per --table-copy (TSV/markdown/source — a DSV document's full grid under source reproduces the file byte-for-byte, DSC4) and flashes a checkmark. Rendered + click-copied in the GUI and the TUI (tui.md TSL). | full | the tableCopyHitBase cutout hit (source-anchored at the table's span.start); serializeTable over the full grid; ViewerModel.markTableCopied |
| TBL7 | A table wider than its panel must be handled by a user-selectable --table-overflow mode sharing the fence's OverflowPolicy (COD6). scroll (default): natural column widths behind a per-table framed viewport — pinned frame corners and side caps, everything junction-bearing (cells, inner rules, the top/bottom junction runs) scrolling as one offset, the bottom border doubling as the horizontal scrollbar (junctions dropped while it shows), the TBL6 cutout pinned; the right border never overflows and clipped content never engages the document-wide bar (IXB2). Both bars emit the semantic scrollbar op the pane bars use. wrap: cells wrap so the table fits the panel via the shared table core, never a bar. wrap-at:N: wrap to a total width of N (frame included), then scroll when N exceeds the panel. The available box is maxWidth - indentCols, so nested tables respect their ancestors' indentation. | full | MdViewOptions.tableOverflow/TableScroll; ui.components.table TableViewportSpec/buildTableWidgets; ViewerModel.setTableScroll/tableExtent/tableAtRow; the shared barPress/barDrag + wheel routing in both hosts |
| TBL8 | A table whose interior exceeds --table-max-lines (scroll/wrap-at modes) must show a fixed-height vertical viewport whose right border is the track (┃ thumb, the bottom bar's mirror); a vertical wheel scrolls the table to its edge before the document. | full | MdViewOptions.tableMaxLines; TableViewportSpec.maxLines; resolvedTableMaxLines; scrollTableV wheel-to-edge in both hosts |
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. | full (a0b0f93a) | shotPath; window.screenshot; settledAt/shotSettleCap |
| 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 |
| DBG3 | The markdown view must be guarded by a golden fixture suite: every construct exercised individually plus a composed document, rendered through the production pipeline (extractMarkdown → viewMarkdown → layout → CellGrid) at width 80 and compared as plain glyph grids — the layout oracle every rendering change lands through as a reviewable diff. Regenerate intentionally with SPARKLES_UPDATE_GOLDENS=1 dub test :syntax -- -i md.goldens. | full | sparkles.syntax.md.goldens; libs/syntax/test/data/md/goldens/ (*.md + *.txt / *.interactive.txt) |
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 |
Semantic refinement (SEM)
| ID | Requirement | Status | Traces to |
|---|---|---|---|
| SEM1 | The tree-sitter base highlighting may be refined with semantic kinds (member vs local, @safe call, …) from sparkles:dmd-lsp's identifierTypes — the semantic-tokens overlay the syntax spec reserves. | researched/not-started | issue #121 M6; issue #120 §5 (bonus) |
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 |
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, env hooks | WIN*, FNT2/3, RND*, VIW2, NUM2/3/5, NAV*, SCB*, THG*, FND*, COD3/4, SEL*, TBL1/2/4, FSC*, DBG* |
apps/hue/src/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 |
apps/hue/src/gui_preview.d | PreviewModel/CodeFence, buildPreviewModel, stripSgr, quoteBarColors | VIW1/3, MDP* (the model side) |
apps/hue/src/table_select.d | pure smart-drag→region + TSV/markdown/sub-cell serializers | TBL1/2/5 |
apps/hue/src/gui_ansi.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: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 |