# Font Rendering Text in the TUI hosts is drawn through a **dynamically grown glyph cache**: a small startup atlas plus codepoint pages that rasterize on demand from a chain of fonts, so any character the installed fonts cover renders without rebuilding (or even restarting) the host. East Asian Wide scripts (CJK, kana, Hangul, fullwidth forms) occupy **two cells per glyph** — the same wcwidth-style layout terminals use. This entry explains the cell-grid model the cache serves, how the atlas and fallback chain are structured, the configuration surface, and the known limits — so questions like "why does a new symbol just work" or "how do I measure text with CJK in it" read as designed behaviour. This is a design-level doc. For the function-level story see [Prelude](prelude) (`:font`), [set-font](set-font) (live applies) and [TUI Overview](tui-overview); for pixel-space text on surfaces see [Graphics](graphics). ## The Cell Grid Model Every GL host (retro, retrs, hydra) renders text as a grid of monospace **cells** — one codepoint per cell, advanced by a fixed cell width derived from the primary font's metrics. There is no variable advance, no shaping, no per-glyph kerning: the grid IS the layout engine. That is what makes a chat log, an editor buffer and a spreadsheet share one renderer, and what the monospace probe protects (a proportional face would visibly misalign the grid; see [Prelude](prelude) for the probe rules). Two consequences follow: - **Wide scripts take two cells.** East Asian Wide/Fullwidth codepoints (CJK ideographs, kana, Hangul, fullwidth forms, wide emoji) occupy a CELL PAIR: the left cell carries the glyph flag and the right is a blank continuation sharing its colors. Writers (`draw/text`, the terminal emulator, `putString`) advance two columns per wide codepoint, and a pair that would straddle a boundary (line end, fit width, clip edge) is dropped whole rather than half-drawn. Measure display width with `[width $s]` — not `len` — when centering or truncating mixed-script text. - **Only the primary font drives metrics.** Fallback faces contribute glyphs, never cell size or line height — so mixing fonts (Latin mono primary + CJK fallback) never disturbs the grid geometry. ## Atlas Architecture Each rendering surface owns a private `FontAtlas`: retro's main grid, every popped panel window, every hydra window, and every SDL surface (one atlas per GL context — texture ids are per-context). An atlas is two layers: | Layer | Contents | Built | |---|---|---| | **Base page** | ASCII 32–126, box-drawing U+2500–U+257F | at startup, from the primary face | | **Extension pages** | everything else, hashed by codepoint | lazily, up to 8 square R8 pages (≈8 MB), grid-packed at the base cell size | The UI chrome symbols (✕ ▾ ↗ ● █ ▶ …) are seeded into the extension cache at startup so first-frame chrome never pays a rasterization hitch, but they are ordinary cache entries — not compiled in. A codepoint lookup checks the static ranges, then the cache hash map. On a miss the atlas rasterizes the glyph with FreeType, blits it into the next extension slot, uploads the slot's rectangle, and caches the result. East Asian Wide codepoints get a **double-width slot** (two adjacent cells in the page) so the glyph spans its cell pair at natural proportions — the cell-grid writers already reserved the pair (see above). Codepoints no face covers land in a **negative cache** so they aren't retried every frame (they render as blank cells). When all 8 pages fill, new glyphs log a warning once and render blank — the budget is roughly 16k glyphs at typical cell sizes, enough for CJK plus symbols in a session. Atlases are rebuilt, not resized: a font change or zoom creates a fresh atlas (and a fresh empty cache) and destroys the old one. ## The Fallback Chain Lookups try the **primary face** first, then up to four **fallback faces** in declaration order, using each face's character map to decide coverage. The first face that maps the codepoint rasterizes it. This is what makes a Latin monospace font plus one CJK collection cover essentially everything — and what makes a new interface symbol a configuration matter rather than a host rebuild. Fallback faces are not held to the monospace probe (only the primary drives the grid), which matters because CJK collections and symbol fonts are frequently variable fonts or undeclared-mono. Note the family matcher scores **file stems**, not font metadata: a collection shipped as `NotoSansMonoCJK-VF.ttc` won't match the query "Noto Sans Mono CJK SC" — reference such fonts by exact path. ## Configuration Surface Everything is declarative, per-project, in the prelude (see [Prelude](prelude)):
 :font %{
    :family "JetBrains Mono"          # or :path "/abs/x.ttf" (:path wins)
    :size 16                          # 6..128, default 18
    :fallback @{ "/usr/share/fonts/.../NotoSansMonoCJK-VF.ttc" }
    :ranges   @{ "0x0400-0x04FF" }     # pre-rasterize hot scripts
} 
- `:fallback` and `:ranges` are pure additions to the dynamic cache — `:ranges` exists only to move rasterization from first-use to startup (a hitch avoidance, not a coverage gate). Everything renders on demand regardless. - CLI `--font` / `--size` override the **primary** face per-field; fallbacks and ranges always come from the prelude. - Every resolved file (primary and fallbacks) feeds the landlock sandbox's read-only grants, so lazy rasterization keeps working under `DEFT_SANDBOX=1`. - Interactive zoom (Ctrl+= / - / 0) and `[tui/set-font ...]` (see [set-font](set-font)) rebuild the atlas live with the same fallback/ranges config — applies change the primary face and size only, and neither is persisted (that's what the prelude is for). **retra (ANSI) ignores all of this** — its renderer forwards UTF-8 to the terminal and the user's terminal font owns coverage. Wide-cell semantics are shared, though: the host writes the same cell pairs and its diff renderer emits the wide glyph once, skipping the continuation cell (emitting it would erase the glyph's right half — the terminal already advanced two columns). ## Rendering Discipline Glyph quads are batched, and a quad samples whatever texture is bound on unit 0 **at flush time** — not when it was queued. The renderer therefore tracks the active glyph page and flushes before switching (`bindGlyphPage`); every mid-frame texture upload elsewhere preserves the caller's binding so the tracking stays honest. On-demand rasterization (including its GL upload) happens inline on the host main thread with the GL context current — the same thread that owns the atlas, so no locking is involved anywhere in the cache. ## Limits and Roadmap - **No combining marks or shaping** — combining characters render as standalone width-1 glyphs; RTL and complex scripts are out of scope for the cell-grid model. - **Display width stays separate from indexing.** String indexing is codepoint-based (see above), but screen columns still need `[width]`/`[cells]` — the editor maps cursor char-indices to display columns through the line's cells. - **The cache is per-atlas.** Hydra instances and multi-window sessions rasterize the same codepoint once per window. Deliberate (contexts are isolated); the cost is bounded by the page budget. - **Emoji**: color fonts render as monochrome outlines (the atlas stores a single coverage channel).