Skip to content

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.

FieldValue
LanguageZig
LicenseMIT (LICENSE)
Repositoryghostty-org/ghostty
DocumentationDoc comments in src/font/*.zig; src/font/sprite/draw/README.md
Categoryterminal font stack
Layer(s) covereddiscover · match/fallback · shape · raster · (parse: partial, own opentype/ readers) · metrics
Pinned revision0c2a290d3a3e2a599be3a43435d778a5896667ee (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:

zig
    /// 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,

— src/font/backend.zig

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:

zig
//! 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.

— src/font/Collection.zig

How it works ​

  • Face wraps one backend face at one size. The FreeType face holds the FT_Face, a paired hb_font_t, load flags, a synthetic bit-pair (italic, bold), the DesiredSize { points, xdpi, ydpi }, and a heap ft_mutex that "MUST be held while doing anything with the glyph slot" (src/font/face/freetype.zig). Its duck-typed surface is initFile(lib, path, index, opts), setSize, setVariations, glyphIndex, hasColor, isColorGlyph, renderGlyph(alloc, atlas, glyph, opts), getMetrics, copyTable(alloc, tag), syntheticBold, syntheticItalic.
  • DeferredFace is a discovered-but-unloaded face: the fontconfig pattern with its charset/langset, a CoreText descriptor, or a Windows path plus face_index "for .ttc collections" and a cheap peek face (src/font/DeferredFace.zig). hasCodepoint answers coverage and presentation from that metadata without loading.
  • Collection is an EnumArray(Style, SegmentedList(Entry)) where Entry is deferred | loaded (or an alias to another style's entry). A face is addressed by Collection.Index, a 16-bit packed { style, idx } whose top idx value is reserved for Special.sprite (src/font/Collection.zig).
  • CodepointResolver owns the collection plus an optional Discover, a user CodepointMap, a descriptor→face cache, and the SpriteFace (src/font/CodepointResolver.zig).
  • SharedGrid owns the resolver, two Atlases (grayscale, bgra), a (style, codepoint, presentation) → ?Index cache, a (Index, glyph, RenderOptions) → Render cache, the grid Metrics, and one std.Io.RwLock (src/font/SharedGrid.zig).
  • SharedGridSet maps a Key — the ordered discovery descriptors per style, the codepoint map, metric modifiers and font size, all in an arena — to a refcounted SharedGrid (src/font/SharedGridSet.zig).
  • Shaping walks one terminal row through RunIterator, which splits runs and picks a Collection.Index per run, then calls the backend Shaper.shape (src/font/shaper/run.zig, src/font/shaper/harfbuzz.zig); results are memoized by run hash in shaper/Cache.zig (Cache.zig).

Analysis spine ​

1. Layering and ownership ​

LayerTypeOwnsConcurrency
libraryLibraryFT_Library + a mutex for face creationmutex in initFile/init
faceFacebackend face, hb_font_t, size, synthetic flagsper-face ft_mutex around glyph slot
unloaded faceDeferredFacediscovery record (pattern / descriptor / path + index)immutable until load
font listCollectionEntry per style × priority; metrics, load_optionsnone (guarded by grid lock)
resolutionCodepointResolvercollection, Discover, CodepointMap, SpriteFacenone (guarded by grid lock)
render cacheSharedGridresolver, two atlases, two hash maps, MetricsRwLock: shared read fast path, exclusive miss
config sharingSharedGridSetKey → refcounted SharedGridown mutex; documented thread-safe operations
shapingShaper + Cachehb_buffer_t, feature array, cell bufferone 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:

zig
    // 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 a FaceList, a Resolver with a documented resolution order, and a GlyphCache keyed 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 — drawBox should 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 RwLock and double-checked caches, shared across surfaces by SharedGridSet.
  • 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; copyTable copies.
  • 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 ​

DecisionRationaleTrade-off
Backend as comptime enumZero dispatch cost; dead code removed per platformNo runtime choice; combinatorial build matrix
DeferredFace in the collectionMany fallbacks searchable without loadingCoverage checks per backend; presentation is approximate
16-bit Collection.Index with special slotCompact cache keys; sprites flow through the same pipeline8192-ish faces per style ceiling
One RwLock per SharedGridReads dominate; simple reasoningCallers may take the lock directly; review burden
Immutable grid; config change = new gridFont size change in one surface does not disturb othersRebuild cost on every font config change
Shaping cache keyed by relative-cluster run hashShaping was the dominant frame costHash collisions render wrong glyphs silently
SizeAdjustment ladder for fallbacksFallback glyphs visually match primary x-height or ideograph widthRequires measurable metrics in every fallback face
Packed-struct Score for matchingPrecedence by field order, one integer compareNo weighted trade-offs between criteria

Sources ​

All paths verified at the pinned revision with git cat-file -e.