diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-24 10:12:10 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | e5f764bd201df6554272427fc8cedffece18066f (patch) | |
| tree | e90e186ac4f1d7c642232f4d97c5a2dcb547b3b3 /src/gui/font.c | |
| parent | d32452950262b4ec2b50b0e0af66f7870c8e5d5d (diff) | |
| download | pardes-e5f764bd201df6554272427fc8cedffece18066f.tar.gz pardes-e5f764bd201df6554272427fc8cedffece18066f.zip | |
Shape text with HarfBuzz, so a font's programming ligatures draw across their cells
The SDL shell rasterized every cell by its own codepoint, so a font's `->`,
`!=` or `<=` ligature never appeared. Text is now shaped with HarfBuzz 11.0.0,
built from source like FreeType: a lazy build.zig.zon dependency (the tarball
Ghostty pins) compiled only for the gui shell, its FreeType integration
linked against the freetype package, so the binary carries no system
HarfBuzz or FreeType and a tty build never compiles it.
Shaping turns on only the font's ligature features (liga, calt, clig, rlig,
rclt); composition, local forms and fractions stay off, so a cell draws either
its nominal glyph, as it always did, or part of a ligature. A glyph no lookup
of those features covers cannot change, so its cell is resolved alone, and
that resolution is cached by codepoint: in a font without ligatures (Adwaita
Mono) no cell is ever shaped, and the pipeline draws pixel for pixel as
before.
A cell whose glyph a ligature could replace is shaped with its word: the
cells between spaces that share a decoration and a role. A word ends either
side of the cursor, block or bar, so the cell being edited shows its own
character (Ghostty's rule), and inside f|i, f|l and s|t, whose typographic
ligatures do not belong on a grid (Ghostty again). Colours never end a word:
a ligature spans a syntax colour change or a selection edge. A codepoint the
primary font lacks shapes in the fallback face that has it. Taglines are not
shaped: they are drawn at two pitches, and a strip would not line up in one.
HarfBuzz only chooses glyphs; the grid places them. A substituted glyph whose
ink reaches over neighbouring cells that draw nothing of their own (the
spacer glyphs of Fira Code-style fonts such as Maple Mono, or the cells a
merged ligature cluster swallowed) claims them: it is rasterized once as a
strip that many cells wide, and each cell samples its own slice, so
selection, the cursor and per-cell colours work unchanged. Anything the grid
cannot place (several glyphs in one cell, a positioned mark) keeps its
nominal glyphs.
A frame must not pay for this. A shaped word's atlas slots are cached by its
text (checked against the stored text, not just its hash), so a frame costs
one lookup per word and a compare for the rest of the word being walked.
The atlas is keyed by (face, glyph, role, decoration, lead, span), packed into
64 bits so a lookup hashes one word; ASCII glyph ids are a table, and a
face's HarfBuzz state is made when it is first asked about a glyph. Measured
with the latency trace, whose F line now also carries the submit time (CPU =
submit - present entry), in 10 interleaved rounds against main with an A/A
pair, at 160x50 cells: with Adwaita Mono every scenario (idle, scrolling,
typing, terminal output) is as fast or 1-3% faster, and the first frame and
time to first paint are unchanged. With Maple Mono, ligatures cost the first
frame about 0.8 ms (HarfBuzz setup and shaping the screen) and scrolling new
text about 1-2%.
With it the codepoint raster path is gone: grips look up their glyph and
stay centered, since a grip is not text. ui_font_scale_for_height, an
identity kept as a "size token", is removed (px is the size everywhere),
ui_font_has_glyph becomes ui_font_glyph, and the space slot is simply the
first, cleared cell of the atlas rather than a rasterized space. CellStyle.ul
gets an explicit u3 tag so a decoration fits in the packed keys.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Diffstat (limited to 'src/gui/font.c')
| -rw-r--r-- | src/gui/font.c | 283 |
1 files changed, 251 insertions, 32 deletions
diff --git a/src/gui/font.c b/src/gui/font.c index 5ea595f9..5cb98e1a 100644 --- a/src/gui/font.c +++ b/src/gui/font.c @@ -1,9 +1,14 @@ -// FreeType wrapper for gui.zig. The policy intentionally follows Ghostty's -// FreeType backend: native hinting enabled, the auto-hinter available as a -// fallback, and the light target for shape-preserving grayscale text. +// FreeType and HarfBuzz wrapper for gui.zig. The policy intentionally follows +// Ghostty's FreeType backend: native hinting enabled, the auto-hinter available +// as a fallback, and the light target for shape-preserving grayscale text. +// HarfBuzz only picks glyphs (liga, calt); the grid, not the font's advances, +// places them. #include "font.h" #include <freetype-zig.h> #include <freetype/ftbitmap.h> +#include <hb-ft.h> +#include <hb-ot.h> +#include <hb.h> #include <stdlib.h> #include <string.h> @@ -12,6 +17,22 @@ struct UIFont { FT_Library library; FT_Face face; FT_F26Dot6 size; + // The charmap's answer for ASCII, which is most of every screen: a word the + // renderer has not seen before looks up each of its cells. + uint32_t ascii[128]; + // HarfBuzz state, made by the first ui_font_substitutes question (most + // fallback faces are never asked). + hb_font_t *hb; + hb_buffer_t *buffer; + FT_F26Dot6 hb_size; + // Every glyph a lookup of a ligature feature could replace. Nothing else + // can change a glyph, so a cell outside it is its nominal glyph without + // shaping: every cell, in a font without ligatures. + hb_set_t *substitutes; + // ui_font_shape's per-cell scratch, grown to the longest run seen + uint32_t *own; + uint8_t *state; + int32_t scratch; }; static FT_F26Dot6 size_26_6(float px) { @@ -58,9 +79,33 @@ UIFont *ui_font_new(const uint8_t *data, int32_t len) { // Some symbol fonts do not expose a Unicode charmap. FreeType already // selects a usable default for those, so failure here is non-fatal. (void)FT_Select_Charmap(f->face, FT_ENCODING_UNICODE); + for (uint32_t cp = 0; cp < 128; cp++) + f->ascii[cp] = FT_Get_Char_Index(f->face, cp); return f; } +// HarfBuzz reads this same face (and its charmap) through FreeType, so shaping +// and rasterizing agree on glyph ids. None of these calls returns NULL: a +// failure is an inert empty object, and shaping then yields nominal glyphs. +static void start_harfbuzz(UIFont *f) { + f->hb = hb_ft_font_create_referenced(f->face); + f->buffer = hb_buffer_create(); + f->substitutes = hb_set_create(); + static const hb_tag_t features[] = { + HB_TAG('l', 'i', 'g', 'a'), HB_TAG('c', 'a', 'l', 't'), + HB_TAG('c', 'l', 'i', 'g'), HB_TAG('r', 'l', 'i', 'g'), + HB_TAG('r', 'c', 'l', 't'), HB_TAG_NONE}; + hb_face_t *face = hb_font_get_face(f->hb); + hb_set_t *lookups = hb_set_create(); + hb_ot_layout_collect_lookups(face, HB_OT_TAG_GSUB, NULL, NULL, features, + lookups); + hb_codepoint_t lookup = HB_SET_VALUE_INVALID; + while (hb_set_next(lookups, &lookup)) + hb_ot_layout_lookup_collect_glyphs(face, HB_OT_TAG_GSUB, lookup, NULL, + f->substitutes, NULL, NULL); + hb_set_destroy(lookups); +} + const char *ui_font_name(UIFont *f) { if (!f || !f->face) return NULL; @@ -72,29 +117,31 @@ const char *ui_font_name(UIFont *f) { return NULL; } -int ui_font_has_glyph(UIFont *f, int32_t cp) { +uint32_t ui_font_glyph(UIFont *f, int32_t cp) { if (!f || cp < 0) return 0; - return FT_Get_Char_Index(f->face, (FT_ULong)(uint32_t)cp) != 0; + if (cp < 128) + return f->ascii[cp]; + return FT_Get_Char_Index(f->face, (FT_ULong)(uint32_t)cp); } void ui_font_free(UIFont *f) { if (!f) return; + // The HarfBuzz font holds a reference on the face: release it first. (All + // three accept NULL, a face that never shaped.) + hb_set_destroy(f->substitutes); + hb_buffer_destroy(f->buffer); + hb_font_destroy(f->hb); if (f->face) FT_Done_Face(f->face); if (f->library) FT_Done_FreeType(f->library); + free(f->own); + free(f->state); free(f); } -float ui_font_scale_for_height(UIFont *f, float px) { - (void)f; - // Kept as a size token so the Zig renderer can continue to distinguish - // measurement size from high-DPI raster size. - return px; -} - void ui_font_cell_metrics(UIFont *f, float px, int32_t *cell_w, int32_t *cell_h, int32_t *ascent) { if (!f || !cell_w || !cell_h || !ascent || !set_size(f, px)) { @@ -127,18 +174,17 @@ void ui_font_cell_metrics(UIFont *f, float px, int32_t *cell_w, int32_t *cell_h, *ascent = asc > 0 ? asc : 1; } -static int raster(UIFont *f, float px, int32_t cp, int bold, uint8_t *out, - int32_t stride, int32_t cell_w, int32_t cell_h, - int32_t ascent, int centered) { - if (!f || !out || stride < cell_w || cell_w <= 0 || cell_h <= 0) +static int raster(UIFont *f, float px, uint32_t glyph_index, int bold, + uint8_t *out, int32_t stride, int32_t width, int32_t height, + int32_t cell_x, int32_t cell_w, int32_t ascent, + int centered) { + if (!f || !out || stride < width || width <= 0 || height <= 0) return 0; - for (int y = 0; y < cell_h; y++) - memset(out + (size_t)y * (size_t)stride, 0, (size_t)cell_w); + for (int y = 0; y < height; y++) + memset(out + (size_t)y * (size_t)stride, 0, (size_t)width); if (!set_size(f, px)) return 0; - const FT_UInt glyph_index = - FT_Get_Char_Index(f->face, (FT_ULong)(uint32_t)cp); if (FT_Load_Glyph(f->face, glyph_index, load_flags()) != 0) return 0; if (bold) { @@ -168,23 +214,24 @@ static int raster(UIFont *f, float px, int32_t cp, int bold, uint8_t *out, const int advance = ceil_26_6(glyph->advance.x); // Fallbacks may be proportional. Center their advance in the primary face's // fixed cell, exactly as Ghostty centers a narrower fallback face. - const int gx = centered ? (cell_w - (int)bitmap->width) / 2 - : glyph->bitmap_left + (cell_w - advance) / 2; - const int gy = centered ? (cell_h - (int)bitmap->rows) / 2 - : ascent - glyph->bitmap_top; + const int gx = centered + ? (width - (int)bitmap->width) / 2 + : cell_x + glyph->bitmap_left + (cell_w - advance) / 2; + const int gy = + centered ? (height - (int)bitmap->rows) / 2 : ascent - glyph->bitmap_top; const int pitch = bitmap->pitch; const int row_bytes = pitch < 0 ? -pitch : pitch; for (unsigned int y = 0; y < bitmap->rows; y++) { const int dy = gy + (int)y; - if (dy < 0 || dy >= cell_h) + if (dy < 0 || dy >= height) continue; const unsigned int source_y = pitch < 0 ? bitmap->rows - 1 - y : y; const uint8_t *src = bitmap->buffer + (size_t)source_y * (size_t)row_bytes; for (unsigned int x = 0; x < bitmap->width; x++) { const int dx = gx + (int)x; - if (dx < 0 || dx >= cell_w) + if (dx < 0 || dx >= width) continue; uint8_t coverage; @@ -204,14 +251,186 @@ static int raster(UIFont *f, float px, int32_t cp, int bold, uint8_t *out, return 1; } -int ui_font_raster(UIFont *f, float px, int32_t cp, int bold, uint8_t *out, - int32_t stride, int32_t cell_w, int32_t cell_h, - int32_t ascent) { - return raster(f, px, cp, bold, out, stride, cell_w, cell_h, ascent, 0); +int ui_font_raster(UIFont *f, float px, uint32_t glyph, int bold, uint8_t *out, + int32_t stride, int32_t width, int32_t height, + int32_t cell_x, int32_t cell_w, int32_t ascent) { + return raster(f, px, glyph, bold, out, stride, width, height, cell_x, cell_w, + ascent, 0); } -int ui_font_raster_centered(UIFont *f, float px, int32_t cp, int bold, +int ui_font_raster_centered(UIFont *f, float px, uint32_t glyph, int bold, uint8_t *out, int32_t stride, int32_t width, int32_t height) { - return raster(f, px, cp, bold, out, stride, width, height, 0, 1); + return raster(f, px, glyph, bold, out, stride, width, height, 0, width, 0, 1); +} + +// The horizontal ink of `glyph` placed as ui_font_raster places it in a cell +// `cell` wide (26.6), relative to the cell's left edge. 0 when it has no ink. +static int ink(UIFont *f, uint32_t glyph, FT_Pos cell, FT_Pos *left, + FT_Pos *right) { + if (FT_Load_Glyph(f->face, glyph, load_flags()) != 0) + return 0; + const FT_GlyphSlot slot = f->face->glyph; + if (slot->metrics.width <= 0 || slot->metrics.height <= 0) + return 0; + const FT_Pos origin = + (FT_Pos)(((int)(cell >> 6) - ceil_26_6(slot->advance.x)) / 2) * 64; + *left = origin + slot->metrics.horiBearingX; + *right = *left + slot->metrics.width; + return 1; +} + +// ui_font_shape's per-cell marks: no glyph of its own, keep the nominal glyph, +// drawn as a slice of another cell's glyph. +#define NONE UINT32_MAX +#define PLAIN 1 +#define CLAIMED 2 + +int ui_font_substitutes(UIFont *f, uint32_t glyph) { + if (!f) + return 0; + if (!f->hb) + start_harfbuzz(f); + return hb_set_has(f->substitutes, glyph); +} + +// The GSUB features shaping leaves on are the ligature ones; the others +// HarfBuzz would apply (glyph composition, local forms, fractions around +// U+2044, ...) are off, so a cell is either its nominal glyph, as the renderer +// always drew it, or part of a ligature. Positioning is untouched: the grid +// ignores advances and keeps nominal glyphs where marks would move. +static const hb_feature_t shaping_features[] = { + {HB_TAG('c', 'c', 'm', 'p'), 0, 0, (unsigned int)-1}, + {HB_TAG('l', 'o', 'c', 'l'), 0, 0, (unsigned int)-1}, + {HB_TAG('r', 'v', 'r', 'n'), 0, 0, (unsigned int)-1}, + {HB_TAG('l', 't', 'r', 'a'), 0, 0, (unsigned int)-1}, + {HB_TAG('l', 't', 'r', 'm'), 0, 0, (unsigned int)-1}, + {HB_TAG('r', 'a', 'n', 'd'), 0, 0, (unsigned int)-1}, + {HB_TAG('f', 'r', 'a', 'c'), 0, 0, (unsigned int)-1}, + {HB_TAG('n', 'u', 'm', 'r'), 0, 0, (unsigned int)-1}, + {HB_TAG('d', 'n', 'o', 'm'), 0, 0, (unsigned int)-1}, +}; + +void ui_font_shape(UIFont *f, float px, int32_t cell_w, const uint32_t *cps, + int32_t n, UIShapedCell *out) { + if (!f || !cps || !out || n <= 0) + return; + for (int32_t i = 0; i < n; i++) + out[i] = (UIShapedCell){ui_font_glyph(f, (int32_t)cps[i]), 0, 1, 0}; + if (!f->hb) + start_harfbuzz(f); + int substitutable = 0; + for (int32_t i = 0; i < n && !substitutable; i++) + substitutable = hb_set_has(f->substitutes, out[i].glyph); + if (!substitutable || cell_w <= 0 || !set_size(f, px)) + return; + if (n > f->scratch) { + uint32_t *own = (uint32_t *)realloc(f->own, (size_t)n * sizeof *own); + if (!own) + return; + f->own = own; + uint8_t *state = (uint8_t *)realloc(f->state, (size_t)n); + if (!state) + return; + f->state = state; + f->scratch = n; + } + // hb-ft reads the FreeType size when told; positions are only consulted + // for being nonzero, but a zero scale would hide exactly that. + if (f->hb_size != f->size) { + hb_ft_font_changed(f->hb); + f->hb_size = f->size; + } + hb_buffer_clear_contents(f->buffer); + // One codepoint per cell, so a cluster value IS a cell index. + hb_buffer_add_codepoints(f->buffer, cps, n, 0, n); + hb_buffer_set_direction(f->buffer, HB_DIRECTION_LTR); + hb_buffer_guess_segment_properties(f->buffer); + hb_shape(f->hb, f->buffer, shaping_features, + sizeof shaping_features / sizeof shaping_features[0]); + unsigned int count = 0; + const hb_glyph_info_t *info = hb_buffer_get_glyph_infos(f->buffer, &count); + const hb_glyph_position_t *pos = + hb_buffer_get_glyph_positions(f->buffer, &count); + + // Each cell's own glyph is the one HarfBuzz left in its cluster. A cell a + // ligature merged into an earlier cluster has NONE. A cluster the grid + // cannot place (several glyphs, a positioned mark) is PLAIN, and so are the + // cells merged into it: they keep their nominal glyphs. + uint32_t *own = f->own; + uint8_t *state = f->state; + for (int32_t i = 0; i < n; i++) { + own[i] = NONE; + state[i] = 0; + } + for (unsigned int k = 0; k < count; k++) { + const uint32_t cluster = info[k].cluster; + if (cluster >= (uint32_t)n) + continue; + if (own[cluster] != NONE || pos[k].x_offset != 0 || pos[k].y_offset != 0) + state[cluster] = PLAIN; + else + own[cluster] = info[k].codepoint; + } + for (int32_t i = 1; i < n; i++) + if (own[i] == NONE && (state[i - 1] & PLAIN)) + state[i] = PLAIN; + + // A substituted glyph (or one whose cluster swallowed cells) may reach over + // its neighbours: it claims those that draw nothing of their own, nearest + // first, and each draws its slice of the one strip. Ink past a cell edge by + // half a pixel or less is noise. + const FT_Pos cell = (FT_Pos)cell_w * 64; + for (int32_t i = 0; i < n; i++) { + if ((state[i] & (PLAIN | CLAIMED)) || own[i] == NONE) + continue; + int32_t merged = 0; + while (i + 1 + merged < n && own[i + 1 + merged] == NONE && + !(state[i + 1 + merged] & PLAIN)) + merged++; + if (own[i] == out[i].glyph && merged == 0) + continue; + FT_Pos left = 0, right = 0; + if (!ink(f, own[i], cell, &left, &right)) + continue; // a spacer: claimed, never claiming + int32_t want_left = + left < -32 ? (int32_t)((-left - 32 + cell - 1) / cell) : 0; + int32_t want_right = right > cell + 32 + ? (int32_t)((right - cell - 32 + cell - 1) / cell) + : 0; + if (want_right < merged) + want_right = merged; + int32_t lead = 0, trail = 0; + while (lead < want_left && 1 + lead + trail < UI_MAX_SPAN) { + const int32_t j = i - lead - 1; + FT_Pos l, r; + if (j < 0 || (state[j] & (PLAIN | CLAIMED)) || + (own[j] != NONE && + (own[j] == out[j].glyph || ink(f, own[j], cell, &l, &r)))) + break; + lead++; + } + while (trail < want_right && 1 + lead + trail < UI_MAX_SPAN) { + const int32_t j = i + trail + 1; + FT_Pos l, r; + if (j >= n || (state[j] & (PLAIN | CLAIMED)) || + (own[j] != NONE && + (own[j] == out[j].glyph || ink(f, own[j], cell, &l, &r)))) + break; + trail++; + } + for (int32_t j = i - lead; j <= i + trail; j++) { + out[j] = + (UIShapedCell){own[i], (uint8_t)lead, (uint8_t)(1 + lead + trail), + (uint8_t)(j - (i - lead))}; + state[j] |= CLAIMED; + } + } + // The rest draw their own glyph; a merged cell nothing claimed draws blank. + const uint32_t space = f->ascii[' ']; + for (int32_t i = 0; i < n; i++) { + if (state[i] & (PLAIN | CLAIMED)) + continue; + out[i].glyph = own[i] == NONE ? space : own[i]; + } } |
