Ghostty font stack (Zig / terminal)
A terminal's font subsystem built as a ladder of plain Zig structs — Face → Collection (styles × priority list, deferred faces) → CodepointResolver (overrides, sprites, discovery fallback) → SharedGrid (atlases, glyph cache, one RwLock) → SharedGridSet (one grid per font configuration) — with the face, shaper and discovery backends chosen at compile time.
| Field | Value |
|---|---|
| Language | Zig |
| License | MIT (LICENSE) |
| Repository | ghostty-org/ghostty |
| Documentation | Doc comments in src/font/*.zig; src/font/sprite/draw/README.md |
| Category | terminal font stack |
| Layer(s) covered | discover · match/fallback · shape · raster · (parse: partial, own opentype/ readers) · metrics |
| Pinned revision | 0c2a290d3a3e2a599be3a43435d778a5896667ee (2026-09-14) |
Overview
What it solves
Ghostty renders a cell grid on several surfaces (windows, tabs, splits) that usually share one font configuration. Its font stack must answer, per cell: which face renders this grapheme in this style and presentation, what glyph ids does shaping produce for the run, and where in a GPU atlas is each rasterized glyph — while multiple renderer threads read concurrently and fallback faces are discovered lazily. It covers Linux (fontconfig + FreeType + HarfBuzz), macOS (CoreText for discovery, rendering and shaping), Windows (a FreeType directory scanner) and the browser (web_canvas).
It is the closest existing design to what sparkles:font must deliver for the terminal and hue, and the current sparkles:raylib-text stack already mirrors several of its ideas (--font-codepoint-map, synthetic bold/italic, emoji presentation; see the contrast at the end of "What it teaches").
Design philosophy
Backends are a compile-time enum, not a runtime vtable. Each combination names exactly which subsystem does discovery, rasterization and shaping:
/// Fontconfig for font discovery and FreeType for font rendering.
fontconfig_freetype,
/// CoreText for font discovery, rendering, and shaping (macOS).
coretext,
/// CoreText for font discovery, FreeType for rendering, and
/// HarfBuzz for shaping (macOS).
coretext_freetype,Face, Shaper and Discover are then switch (options.backend) type aliases (src/font/face.zig, src/font/shape.zig, src/font/discovery.zig); Discover is void for backends with no discovery, and callers test font.Discover != void at comptime. The second principle is separation of storage from search:
//! The purpose of a collection is to store a list of fonts by style
//! and priority order. A collection does not handle searching for font
//! callbacks, rasterization, etc. For this, see CodepointResolver.How it works
Facewraps one backend face at one size. The FreeType face holds theFT_Face, a pairedhb_font_t, load flags, asyntheticbit-pair (italic,bold), theDesiredSize { points, xdpi, ydpi }, and a heapft_mutexthat "MUST be held while doing anything with the glyph slot" (src/font/face/freetype.zig). Its duck-typed surface isinitFile(lib, path, index, opts),setSize,setVariations,glyphIndex,hasColor,isColorGlyph,renderGlyph(alloc, atlas, glyph, opts),getMetrics,copyTable(alloc, tag),syntheticBold,syntheticItalic.DeferredFaceis a discovered-but-unloaded face: the fontconfig pattern with itscharset/langset, a CoreText descriptor, or a Windows path plusface_index"for .ttc collections" and a cheap peek face (src/font/DeferredFace.zig).hasCodepointanswers coverage and presentation from that metadata without loading.Collectionis anEnumArray(Style, SegmentedList(Entry))whereEntryisdeferred | loaded(or analiasto another style's entry). A face is addressed byCollection.Index, a 16-bit packed{ style, idx }whose topidxvalue is reserved forSpecial.sprite(src/font/Collection.zig).CodepointResolverowns the collection plus an optionalDiscover, a userCodepointMap, a descriptor→face cache, and theSpriteFace(src/font/CodepointResolver.zig).SharedGridowns the resolver, twoAtlases (grayscale,bgra), a(style, codepoint, presentation) → ?Indexcache, a(Index, glyph, RenderOptions) → Rendercache, the gridMetrics, and onestd.Io.RwLock(src/font/SharedGrid.zig).SharedGridSetmaps aKey— the ordered discovery descriptors per style, the codepoint map, metric modifiers and font size, all in an arena — to a refcountedSharedGrid(src/font/SharedGridSet.zig).- Shaping walks one terminal row through
RunIterator, which splits runs and picks aCollection.Indexper run, then calls the backendShaper.shape(src/font/shaper/run.zig,src/font/shaper/harfbuzz.zig); results are memoized by run hash inshaper/Cache.zig(Cache.zig).
Analysis spine
1. Layering and ownership
| Layer | Type | Owns | Concurrency |
|---|---|---|---|
| library | Library | FT_Library + a mutex for face creation | mutex in initFile/init |
| face | Face | backend face, hb_font_t, size, synthetic flags | per-face ft_mutex around glyph slot |
| unloaded face | DeferredFace | discovery record (pattern / descriptor / path + index) | immutable until load |
| font list | Collection | Entry per style × priority; metrics, load_options | none (guarded by grid lock) |
| resolution | CodepointResolver | collection, Discover, CodepointMap, SpriteFace | none (guarded by grid lock) |
| render cache | SharedGrid | resolver, two atlases, two hash maps, Metrics | RwLock: shared read fast path, exclusive miss |
| config sharing | SharedGridSet | Key → refcounted SharedGrid | own mutex; documented thread-safe operations |
| shaping | Shaper + Cache | hb_buffer_t, feature array, cell buffer | one per renderer thread |
Ownership is explicit: Collection.add "takes ownership of the face" on success, every init takes an Allocator, and errors are Zig error unions (AddError = Allocator.Error || error{ CollectionFull, SetSizeFailed }). Lookups follow a double-checked pattern: read lock, probe the hash map, drop it, take the write lock, getOrPut, then resolve or render (SharedGrid.zig renderGlyph). SharedGrid deliberately cannot resize or change families; a config change builds a new grid and surfaces switch over. The CoreText shaper relies on Apple's documented split (font objects thread-safe, layout objects per thread) and holds only the read lock while setting up a face (shaper/coretext.zig).
2. Face loading and table access
Faces load from a path plus collection index (Face.initFile(lib, path, index, opts)) or from memory (Face.init, index 0) (face/freetype.zig). Discovery supplies the index: fontconfig's FC_INDEX via fc.pattern.get(.index, 0), the Windows scanner's face_index (DeferredFace.zig). Loading is lazy at the face level — a fallback lives as a DeferredFace until Collection.getFace promotes it.
Raw tables: Face.copyTable(alloc, tag) returns an owned copy on both backends. Ghostty also carries its own minimal readers under src/font/opentype/ (head, hhea, os2, post, sfnt, svg, glyf); Glyf keeps "a pointer (slice) to the underlying data" and decodes on demand (opentype/glyf.zig) — the borrowed-buffer shape sparkles:font plans.
3. Shaping
The RunIterator is the terminal-specific half. A run never crosses a row and breaks at: style changes other than background (so >= is not one ligature in two colors), the cursor (before, at, after), selection bounds, and known bad ligatures (fl, fi, st). Per cell it resolves a font for the entire grapheme, so combining marks pick a face that covers all of them, else the replacement character (shaper/run.zig). Presentation comes from the first grapheme codepoint after the base: U+FE0E forces text, U+FE0F emoji.
The run's hash is position-independent (relative clusters), so identical runs anywhere in the viewport share one shaper.Cache entry; the cache comment records that shaping was once "96% of frame time" (Cache.zig). The HarfBuzz shaper prepends default_features (liga = 1) to user font-feature strings parsed by Feature.fromString (CSS-like +kern, kern on, "kern" = 1), all global-range (shaper/feature.zig, shaper/harfbuzz.zig). Output is shape.Cell { x, x_offset, y_offset, glyph_index } — positions in cells, not font units, with cluster→cell mapping done in the shaper's cluster loop (shape.zig). The CoreText shaper forces embedding level 0 via kCTTypesetterOptionForcedEmbeddingLevel: no BiDi. The noop shaper maps codepoint→glyph one to one (shaper/noop.zig).
4. Variation and instances
font.face.Variation { id: Id (packed 4-byte tag), value: f64 } in design (user) units, modeled on CSS font-variation-settings (face.zig). Variations sit on the discovery Descriptor (they "impact searching … fonts with the ability to set these variations will be preferred") and are applied after load with Face.setVariations. FreeType needs all axes at once, so it reads current design coordinates into a fixed 32-slot stack array, patches the matching tags, and calls setVarDesignCoordinates; CoreText rebuilds the descriptor with createCopyWithVariation and copyWithAttributes (face/freetype.zig, face/coretext.zig). Because the HarfBuzz font is created from the FT_Face, shaping sees the same coordinates. Named instances, avar and STAT are not surfaced; axes are only logged in debug builds.
5. Rasterization and outlines
Rasterization goes through the backend (FT_Load_Glyph + render, or a CoreGraphics bitmap context with setAllowsAntialiasing/setShouldSmoothFonts for thicken) into an Atlas — a square skyline bin-packer after Jylänki and freetype-gl (Atlas.zig). Two atlases split by presentation; AtlasFull doubles the atlas and retries. RenderOptions carries the grid Metrics, cell_width, constraint_width and a Constraint (size cover, alignment, padding) used to fit emoji and Nerd Font icons, whose per-codepoint constraints are generated from the Nerd Fonts patcher into nerd_font_attributes.zig (Glyph.zig).
Color: FreeType loads with color and sets NO_SVG ("we don't currently support rendering SVG glyphs under FreeType"), so CBDT/sbix/COLR go through FreeType and SVG does not; CoreText treats any sbix table as color (face/coretext.zig). Synthetic bold is FT_Outline_Embolden scaled by font height; synthetic italic is a skew (CoreText italic_skew matrix).
Procedural glyphs: the SpriteFace draws box drawing, blocks, braille, Powerline, legacy-computing symbols and underline/cursor sprites with z2d, dispatching by draw<CODEPOINT>/draw<MIN>_<MAX> function names discovered at comptime (sprite/Face.zig, sprite/draw/README.md) — the same idea as sparkles:raylib-text's drawBox, but fed through the same atlas and Index (Special.sprite) as real fonts.
From-scratch evidence (RQ2): glyf_rasterize.zig rasterizes a decoded glyf outline to a full-cell alpha8 bitmap via z2d, reusing the Constraint machinery; at the pin it is exported from font/main.zig but has no caller (its "exact glyph protocol constraints" consumer was reverted).
Outlines are not exposed as an API; they exist only inside the backends and the Glyf.Outline { contours, points } decoder.
6. Metrics and measurement
Face.getMetrics returns FaceMetrics in pixels (+Y up): px_per_em, cell_width, ascent, descent, line_gap, optional underline, strikethrough, cap_height, ex_height, ic_width (Metrics.zig). The FreeType path picks vertical metrics as: OS/2 sTypo* if fsSelection bit 7 is set; else hhea if non-zero; else sTypo* if non-zero; else usWin*. Underline comes from post, strikeout from OS/2, each treated as missing when thickness is 0 (face/freetype.zig). cell_width is measured as the max advance over printable ASCII, falling back to max_advance.
Metrics.calc rounds width and height to integers and splits the line gap half above, half below; a user ModifierSet (adjust-cell-height, …) applies afterwards. Fallback faces are resized to match the primary through SizeAdjustment (ic_width → ex_height → cap_height → line_height fallthrough), described as working "very much like the font-size-adjust CSS property" (Collection.zig).
7. Discovery, matching and fallback
discovery.Descriptor is the query: family (on fontconfig a full pattern such as "Fira Code-14:bold"), style string, codepoint, size, bold, italic, monospace, variations; it hashes for cache keys (discovery.zig). Backends: fontconfig fontSort; CoreText enumerates descriptors and sorts by a packed Score whose field order is the precedence — codepoint > monospace > exact_style > italic > bold > fuzzy_style > glyph_count. CoreText fallback special-cases CJK Unified Ideographs (U+4E00–U+9FFF) and the empty-result case through CTFontCreateForString, since that respects system locale.
The resolution order in CodepointResolver.getIndex is the cleanest statement of fallback policy in this catalog:
// Codepoint overrides.
if (self.getIndexCodepointOverride(alloc, cp)) |idx_| {
if (idx_) |idx| return idx;
} ...
// If we have sprite drawing enabled, check if our sprite face can
// handle this.
if (self.sprite) |sprite| {
if (sprite.hasCodepoint(cp, p)) {
return .initSpecial(.sprite);
}
}— src/font/CodepointResolver.zig
Then: default presentation from the UCD is_emoji_presentation property; the style's own list; for non-regular styles, the regular list (not a styled fallback — "ugly rendering"); for regular, discoverFallback with the codepoint, adding the first deferred face whose coverage and presentation match; last, any-presentation regular. CodepointMap is a linear-scanned MultiArrayList of { range: [2]u21, descriptor } (CodepointMap.zig). Missing bold/italic styles are synthesized by Collection.completeStyles, gated per style by config.
What it teaches sparkles:font
- Separate storage (
Collection), policy (CodepointResolver) and cache (SharedGrid). Each is a plain struct with one owner; the D port maps to aFaceList, aResolverwith a documented resolution order, and aGlyphCachekeyed by(FaceIndex, glyph, RenderOptions). - A 16-bit face handle with a reserved "sprite" slot lets procedural box drawing share shaping, cache and atlas paths with real fonts —
drawBoxshould become a face, not a renderer special case. - Deferred faces are the fallback scaling trick. Coverage and presentation answered from discovery metadata (fontconfig charset/langset, CoreText descriptor) keeps the fallback list long and loading rare.
- Grid metrics are measured and rounded once, then passed into rendering as
RenderOptions; fallback faces are size-adjusted to the primary by an explicit metric ladder. - Shape per run with a position-independent hash cache; break runs at style, cursor and selection boundaries, and resolve fonts per grapheme.
- Encode match precedence as a packed integer (
Score) rather than weighted sums: comparison is one integer compare and the priority is readable from the field order.
Contrast with the current sparkles:raylib-text. font_set.d already copies Ghostty's vocabulary (a primary face, real bold/italic variants, a regular and a Nerd-Font fallback, up to 8 --font-codepoint-map faces), but as fixed named fields over raylib LoadFontEx atlases plus a separate FreeType/HarfBuzz ClusterCache for clusters and fallbacks, so there are two raster paths. shaping_c.c pairs one FT_Face with one hb_font_t and hb_buffer_t per face behind an ImportC seam. Ghostty's model replaces both with one Collection, one Index, one glyph cache and one atlas pair, with fallback discovered lazily instead of preconfigured.
Strengths
- A clear, documented fallback order including presentation and style demotion; per-codepoint overrides are first-class.
- Read-mostly concurrency with one
RwLockand double-checked caches, shared across surfaces bySharedGridSet. - Terminal-grade metrics: measured cell width, defensive table selection, rounding policy written down with its rationale.
- Procedural sprites and Nerd Font constraints integrated into the face model.
Weaknesses
- Compile-time backend choice: one binary cannot mix CoreText discovery with FreeType rendering at runtime.
- No outline, inspector or table-enumeration API;
copyTablecopies. - Variations are design-coordinate overrides only; no named instances, no
avar/STAT, axis count capped at 32 on FreeType. - Features are global; per-face or per-range features are a noted TODO in
shape.Options. - No BiDi on CoreText (forced level 0), and run splitting embeds terminal-specific policy (
fi/fl/st).
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Backend as comptime enum | Zero dispatch cost; dead code removed per platform | No runtime choice; combinatorial build matrix |
DeferredFace in the collection | Many fallbacks searchable without loading | Coverage checks per backend; presentation is approximate |
16-bit Collection.Index with special slot | Compact cache keys; sprites flow through the same pipeline | 8192-ish faces per style ceiling |
One RwLock per SharedGrid | Reads dominate; simple reasoning | Callers may take the lock directly; review burden |
| Immutable grid; config change = new grid | Font size change in one surface does not disturb others | Rebuild cost on every font config change |
| Shaping cache keyed by relative-cluster run hash | Shaping was the dominant frame cost | Hash collisions render wrong glyphs silently |
SizeAdjustment ladder for fallbacks | Fallback glyphs visually match primary x-height or ideograph width | Requires measurable metrics in every fallback face |
Packed-struct Score for matching | Precedence by field order, one integer compare | No weighted trade-offs between criteria |
Sources
All paths verified at the pinned revision with git cat-file -e.
- Core ladder —
backend.zig,face.zig,Collection.zig,DeferredFace.zig,CodepointResolver.zig,CodepointMap.zig,SharedGrid.zig,SharedGridSet.zig,discovery.zig. - Faces and raster —
face/freetype.zig,face/coretext.zig,Atlas.zig,Glyph.zig,Metrics.zig,glyf_rasterize.zig,opentype/glyf.zig,nerd_font_attributes.zig. - Shaping —
shape.zig,shaper/run.zig,shaper/harfbuzz.zig,shaper/coretext.zig,shaper/noop.zig,shaper/feature.zig,shaper/Cache.zig. - Sprites —
sprite/Face.zig,sprite/draw/README.md,sprite/draw/box.zig,sprite/draw/powerline.zig. - sparkles today —
font_set.d,shaping_c.c. - Siblings:
./crossfont.md,./harfbuzz.md,./freetype.md,./fontconfig.md,./coretext.md.