# 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).