Sparkles Text Sizing Baseline (D)
Sparkles carries a font-size hint, but does not yet have a shared contract for text whose painted footprint occupies multiple terminal cells or rows.
Last reviewed: September 14, 2026.
| Property | Baseline |
|---|---|
| Language | D, with Ghostty's Zig implementation behind ImportC |
| Category | Source-derived baseline for the research proposal |
| Geometry | Integer UI cells; backend conversion to pixels or terminal cells |
| Existing size hint | TextStyle.fontScale, percentage, default 100 |
| Evidence | Focused source inspection; no runtime probes or benchmarks run for this page |
| Status | Research, not an accepted specification or delivered sizing feature |
Overview
What the stack solves
The shared pipeline already separates widgets, layout, display-list construction, and painting. Source identity is independent of presentation in rich spans. Terminal rendering additionally has a retained grid and a byte-stream diff. These are useful foundations, but none makes per-run scaling a paint-only change.
The existing layout implementation states:
Every extent is an integer cell
That is the important constraint: larger ink needs a measured footprint, not just a larger texture rectangle. See concepts for the distinction between glyph size, advance, occupied cells, line height, and protocol packing.
Design philosophy
Keep base text processing independent of UI policy; let shared layout own geometry; let backends realize resolved geometry. A terminal emulator receiving arbitrary VT bytes is a different consumer from a widget renderer producing those bytes. The comparison and validation matrix keep these claims separate from visual demonstrations.
How It Works Today
Base escape scanning and visible text
escapeLength and byAnsiToken treat an OSC sequence as one escape span, through BEL or ST, including its payload. An unterminated string consumes the remaining input. The scanner intentionally answers framing, not OSC semantics.
byGraphemeCluster and visibleWidth use that framing: escape spans have width zero. Consequently, a well-framed OSC 66 packet's printable payload currently contributes zero visible width. This is source-derived behavior, not a terminal experiment. OSC 8 works differently because its visible label is outside the escape; that existing test does not establish OSC 66 support.
writeWrappedText measures graphemes and preserves SGR and OSC 8 continuity across inserted newlines. truncateField also walks grapheme clusters. unstyle copies only non-escape tokens, so it drops an OSC 66 payload rather than extracting its text. Changing only visibleWidth would leave wrapping, truncation, and plain-text export inconsistent.
The required delta is a semantic text layer over escape framing: recognized sized payloads must expose visible text, metadata, and original source-byte positions. Unknown OSC commands must remain opaque zero-width controls. Source spans must distinguish packet framing from payload bytes and synthetic output characters.
The OSC 66 specification supplies the wire rules; wcwidth supplies the adjacent width-processing comparison. Neither licenses executing controls found inside an untrusted text payload.
UI style transport is ahead of measurement
TextStyle and Visual already contain fontScale = 100. TextInk and the canvas conversions carry it onward, and display-list tests assert that it survives style resolution. This is transport evidence, not evidence that any particular painter honors it.
CellMeasure exposes only width(text). There is no style argument, resolved fallback, line-height result, or source map in that measurer contract. Frame records rect, lines, and spanLines; wrapped slices are shared with painting, but their individual advances and vertical footprints are not recorded.
buildDisplayList stacks wrapped text with height 1 and row offset li. Rich spans advance by cellsOf(span.text) and also receive height 1. That independently reconstructs geometry after layout.
Its style fallback is especially relevant to headings: a span inherits the node's whole TextStyle only when span.textStyle == TextStyle.init; otherwise the span's whole style wins. An italic or code span therefore cannot be assumed to inherit an eventual parent scale merely because it changes only one apparent property.
Theme defaults, node overrides, span inheritance, and an explicit reset to normal size need distinguishable semantics. The theme source and theme spec are integration points, not proof of an existing per-property cascade.
Codepoints are not graphemes
cellsOf counts UTF-8 lead bytes, one per codepoint. It does not perform terminal grapheme measurement. Its comments describe a single width authority, but base grapheme measurement and the grid adapter have different mechanics. A CJK character, combining sequence, or ZWJ emoji is enough to make this distinction material even at scale 100.
The UI wrapping engine, display-list offsets, and selection code must agree on one resolved run layout. Replacing the helper alone would not fix callers which still step through bytes/codepoints to invert a cell position.
Source identity and shared frames
documentRows builds per-visual-row text and source ranges from Frame. It excludes synthetic spans from sourceText, which is already the right split for heading icons, bullets, and gutters. Its wrapped-row walk still uses one row per line, and sourceOffsetAt reconstructs column-to-byte mapping by codepoint strides. Selection geometry separately computes widths with cellsOf.
Sized text needs the resolved run positions in the shared frame result, including cluster boundaries, source-byte mappings, row coverage, and effective clips. Otherwise painting, source selection, hover, and copy can disagree even when the outer widget rectangle is correct. Covered rows must not duplicate copied text.
This affects scroll anchors and gutters too. Hue's viewer_model.d derives document rows, fold markers, gutter channels, and source-based anchors from layout. It has a preliminary content-layout pass before gutter construction. Both passes must use the same resolved sizing and width policy.
Host measurement is not automatically injected
presentApp currently calls layout(snap.tree, Constraints(sz.width, sz.height)) with the default measurer. The existence of canvas measure methods does not wire them into this path. A host capability must reach layout before frame construction, including recording and headless hosts, without requiring applications to name a backend.
Backend Analysis
Retained TUI
GridCanvas paints into GridT, then Screen diffs that grid against the previous picture. CellT stores inline grapheme bytes, width 0/1/2, style, and hyperlink identity. Its equality compares those values. It has neither a multi-row run owner nor an arbitrary rectangular footprint.
The inline byte bound also matters: setBytes truncates to MaxBytes. The source calls this acceptable for common scenes; sized payload storage cannot inherit that assumption and silently cut UTF-8 or a long grapheme cluster.
Screen skips ordinary wide-glyph continuations and uses hardware scroll detection through scrollOptimize. Those are one-row cell invariants, not generic guarantees for a tall OSC 66 owner. A change under an old tall glyph may require repainting its owner outside the initially dirty cell or row.
The proposed retained extension therefore includes a side arena for exceptional runs, lead/continuation ownership, content-based equality across frames, and damage closure over both old and new footprints. Reused arena indices are not identity. It must preclear stale covered cells, potentially with ECH under explicit style and cursor positioning, before re-emitting owners. It must emit no continuation text.
Hardware scrolling must initially be disabled when relevant old or new frames contain sized footprints. This is a proposed correctness restriction, not existing behavior. Compare libvaxis and OpenTUI; the retained-render benchmark baseline remains a performance guardrail.
Raylib
RaylibCanvas.measure returns Size(cellsOf(text), 1). The adapter carries fontScale but the current run drawing path does not use it to establish a larger layout footprint. drawText places codepoints at the global FontSet.cellW advance and draws at fonts.size().
The lower-level drawGrapheme already accepts a font size and computes a texture scale against font.baseSize. That is a useful rendering capability, not complete sized-run support: layout, baseline, routing, clipping, decorations, and background extent still belong to the caller.
FontSet distinguishes logical font size, atlas raster size, and base cell metrics; it reloads fonts and grows glyph sets through pending requests. A per-run scale must not call global reload to resize the whole application's grid. Atlas sizing/cache policy needs separate quality and memory evaluation, including DPI, fallback faces, real bold/italic, and on-demand glyph growth.
Secondary cells and HTML
ui/interp/cells.d is another cell renderer, not an alias of the retained TUI. Hue's static output paths use CellGrid, including markdown preview for --ansi. Updating only ui-tui would leave this consumer behind. Its measurement also advertises height 1 with cellsOf width.
The styled HTML emitter already emits a percentage font size from Visual.fontScale; the semantic HTML emitter is a separate path. Their cell-like dimensions and offsets use ch/lh (and some em spacing). Those units depend on CSS font context, so nested scaling can accidentally scale geometry twice. Browser font sizing is not evidence of shared layout parity.
Both emitters need an explicit choice between semantic browser reflow and exact base-cell placement, with tests for inheritance, clips, and source order. The Blessed comparison illustrates native OSC 66 formatting with plain-text fallback, not cell-art enlargement.
Consumer Analysis
Source-view headings and hue configuration
MdViewOptions and heading construction already provide base style, theme accents, rich inline content, icons, links, and source-anchored folds. Headings set bold on opt.baseStyle; themed headings create an icon span and a full-width background band. They do not select a scale per heading level.
The change belongs in source-view options, not a GUI-only heading draw shortcut. Rich icons, links, emphasis, inline code, hanging indents, folded heading faces, and appended fold chips all need a deliberate inheritance/reset policy. Gutter markers must follow visual coverage without becoming part of copied source.
Hue's configuration path includes settings.d, cli.d, viewer_model.d, app.d, gui.d, and tui.d. Persisted settings and live changes must invalidate layout, not merely request repaint. Static ANSI, interactive TUI, GUI, and HTML need consistent defaults and explicit unsupported-backend fallback.
Presenterm and mdfried are consumer comparisons; their presentation choices inform defaults but do not replace Sparkles source mapping.
TerminalView is a receiving terminal
TerminalView hosts a Ghostty VT, PTY, resize/reflow, selection, hover, and redraw decisions. core.d resolves cell appearance and paints the pixel terminal at base cellWidth/cellHeight positions, including selection/hover inversion and dirty-state clearing.
cell_paint.d is a second terminal painter for embedded canvases. It currently emits only rc.codepoints[0]; its own documentation identifies loss of combining marks and ZWJ sequences. Embedded selection and hover inputs are inert there. A sized-text implementation must address this path explicitly, not claim terminal-in-terminal support from the pixel renderer alone.
Ghostty parser support is a blocker, not delivery
The checked Sparkles flake.lock pins Ghostty to 4749c4e93731067049bfbf2e4572061cef2bdd17 (the old/pinned SHA). Its OSC 66 parser recognizes scale, width, fractions, alignment, and safe UTF-8 payloads, but says:
We don't currently support encoding this to C in any way.
More fundamentally, its stream dispatcher groups kitty_text_sizing under unimplemented OSC callback. The newer inspected local checkout, 0c2a290d3a3e2a599be3a43435d778a5896667ee, retains both the parser/C limitation and unimplemented dispatch. These are two inspected revisions, not a claim about every future Ghostty build.
Updating the pin alone therefore does not establish VT storage, cursor advance, erase/overwrite, scrollback, reflow, selection, dirty regions, or render-state C metadata. The Ghostty survey tracks that distinction; kitty and foot provide receiving-terminal comparisons.
ANSI fences and platform dependencies
gui_ansi.d feeds fences into an off-screen VT and converts its cells to AnsiSpan/AnsiLine. Dimensions come from raw byte counts and newline counts, capped at 4000; it also translates LF to CRLF. Newline counts do not reserve tall footprints. Escape byte length is not a layout oracle.
AnsiSpan currently carries text, colors, default-color flags, and attributes, not run dimensions or occupancy. Supporting OSC 66 fences requires a bounded geometry policy and preservation of sized metadata after decoding, in addition to a working Ghostty implementation. Protocol payloads must not be rewritten by blind newline translation.
Desktop nix/packages/libghostty-vt.nix and Android nix/packages/android/libghostty-vt.nix both consume the Ghostty input. Any upstream patch or pin update must build both dependency paths; the Android contract and TerminalView spec are existing constraints, not sizing specs.
Strengths and Weaknesses
- Existing escape framing, grapheme utilities, and source spans provide reusable foundations.
- Shared frames and a backend-neutral host offer a place to centralize resolved geometry.
- Scale metadata already travels through the UI style vocabulary.
- Independent measurement and hit-test reconstruction currently undermine parity.
- Retained grid ownership is one-row oriented; safe tall-footprint damage is new work.
- Receiving-terminal support is blocked below the Sparkles painter, in Ghostty itself.
Decisions and Remaining Evidence
| Decision to investigate | Rationale | Cost or unresolved question |
|---|---|---|
| Exact integer footprints first | Matches cell geometry and makes overlap testable | Fractions still need an explicit packing contract |
| Fallback before layout | Keeps painting and input in the same coordinate system | Capability changes invalidate cached layout |
| Shared source-mapped run layout | Avoids three independent width walks | Extra metadata and lifetime design |
| Exceptional side arena | Preserves ordinary-cell density | Equality and damage cannot depend on arena indices |
| Separate advanced shaping | Scaling is not bidi, ligatures, or proportional layout | Some typography remains deliberately unsupported |
No runtime result is claimed here. The proposal schedules contract tests, runnable demos, receiving-terminal probes, and performance gates rather than treating the presence of source identifiers as a passing integration test.
Sources
- Primary Sparkles sources: base framing, layout, state, retained rendering, Raylib drawing, and terminal core.
- Dependency evidence: pinned and newer Ghostty dispatchrevisions, inspected rather than executed.
- Context: concepts, comparison, validation, and the proposed milestones.