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 | |
| 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]>
| -rw-r--r-- | build.zig | 33 | ||||
| -rw-r--r-- | build.zig.zon | 9 | ||||
| -rw-r--r-- | docs/design.typ | 6 | ||||
| -rw-r--r-- | src/gui/font.c | 283 | ||||
| -rw-r--r-- | src/gui/font.h | 66 | ||||
| -rw-r--r-- | src/gui/gui.zig | 565 | ||||
| -rw-r--r-- | src/pardes.zig | 2 |
7 files changed, 783 insertions, 181 deletions
@@ -642,6 +642,39 @@ pub fn build(b: *std.Build) void { .@"enable-libpng" = false, }); gm.linkLibrary(freetype_dep.artifact("freetype")); + // HarfBuzz from source, like FreeType: its FreeType integration + // (HAVE_FREETYPE, hb-ft) links the freetype artifact above, so the two + // share one FreeType. The flags are the ones Ghostty builds it with. + if (b.lazyDependency("harfbuzz", .{})) |upstream| { + const harfbuzz = b.addLibrary(.{ + .name = "harfbuzz", + .linkage = .static, + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + .link_libcpp = true, + }), + }); + harfbuzz.root_module.linkLibrary(freetype_dep.artifact("freetype")); + harfbuzz.root_module.addIncludePath(upstream.path("src")); + harfbuzz.root_module.addCSourceFile(.{ + .file = upstream.path("src/harfbuzz.cc"), + .flags = &.{ + "-DHAVE_STDBOOL_H", + "-DHAVE_UNISTD_H", + "-DHAVE_SYS_MMAN_H", + "-DHAVE_PTHREAD=1", + "-DHAVE_FREETYPE=1", + "-DHAVE_FT_GET_VAR_BLEND_COORDINATES=1", + "-DHAVE_FT_SET_VAR_BLEND_COORDINATES=1", + "-DHAVE_FT_DONE_MM_VAR=1", + "-DHAVE_FT_GET_TRANSFORM=1", + }, + }); + gm.linkLibrary(harfbuzz); + gm.addIncludePath(upstream.path("src")); + } if (target.result.os.tag.isDarwin()) gm.addRPath(.{ .cwd_relative = "/usr/local/lib" }); gm.addIncludePath(b.path("src/gui")); diff --git a/build.zig.zon b/build.zig.zon index 8f1b8068..31649350 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -189,6 +189,15 @@ .url = "git+https://github.com/allyourcodebase/freetype#884fd2235e6ae1ec4306eda30b3259f20930ed2e", .hash = "freetype-2.14.3-C3-WdXSKAAB-kHs4qgRoeoFnRkNQn6M_SSlUGZ6ywgBQ", }, + // HarfBuzz shapes the SDL shell's text runs, which is what turns `->` + // into a font's arrow ligature. The upstream source release (the same + // tarball Ghostty pins); build.zig compiles harfbuzz.cc against the + // freetype above. Lazy: only a gui build fetches or compiles it. + .harfbuzz = .{ + .url = "https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz", + .hash = "N-V-__8AAG02ugUcWec-Ndp-i7JTsJ0dgF8nnJRUInkGLG7G", + .lazy = true, + }, }, .minimum_zig_version = "0.16.0", .paths = .{""}, diff --git a/docs/design.typ b/docs/design.typ index f8f0b05c..8ec025f1 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -1632,7 +1632,8 @@ and web and macOS are libraries whose host owns `main()`. == Generated inputs Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL (a -pinned fork, lazy), FreeType, MuPDF (`-Dmupdf`, on by default everywhere but the +pinned fork, lazy), FreeType, HarfBuzz (the SDL shell's text shaping, lazy, +built against that FreeType), MuPDF (`-Dmupdf`, on by default everywhere but the web and the board, lazy), ZLS, mvzr (the regex engine behind `s`/`S`), zig-tree-sitter with 29 grammars for 28 languages (markdown takes two, block and inline) — all pinned through `zig fetch` and wired in `build.zig`. The board's @@ -2041,7 +2042,8 @@ tap-LOOK/drag-scroll, but finger-down on a tagline or a one-cell-tolerant pane separator latches to a left-mouse gesture for its lifetime, keeping layout drags out of the scroll heuristic. Touch input translation and scroll/tap state live entirely in the JavaScript shell. -The native SDL shell uses a FreeType light-hinted grayscale atlas over the SDL GPU API (SPIR-V). +The native SDL shell uses a FreeType light-hinted grayscale atlas over the SDL GPU API (SPIR-V), +with words shaped by HarfBuzz so a font's ligatures span their cells. The browser exposes each cell as selectable, inspectable text and applies the surface styles with CSS. The browser `.snap` harness drives real Chromium touch input and reads both text and per-cell styles from the DOM renderer's packed 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]; + } } diff --git a/src/gui/font.h b/src/gui/font.h index 4410bb7c..b38f822a 100644 --- a/src/gui/font.h +++ b/src/gui/font.h @@ -1,6 +1,7 @@ -// Tiny C shim over FreeType, so gui.zig only imports the stable renderer API. -// The implementation owns FreeType's library and face objects and keeps the -// caller's font bytes borrowed for the face lifetime. +// Tiny C shim over FreeType and HarfBuzz, so gui.zig only imports the stable +// renderer API. The implementation owns FreeType's library and face objects +// and HarfBuzz's font and buffer, and keeps the caller's font bytes borrowed +// for the face lifetime. #ifndef UI_FONT_H #define UI_FONT_H #include <stdint.h> @@ -20,27 +21,56 @@ UIFont *ui_font_new(const uint8_t *data, int32_t len); // font has no PostScript table. Borrowed for the UIFont lifetime. const char *ui_font_name(UIFont *f); -// Whether the selected Unicode charmap maps `cp` to a real glyph. Glyph zero is -// the face's .notdef box and deliberately does not count for fallback choice. -int ui_font_has_glyph(UIFont *f, int32_t cp); +// The glyph the selected Unicode charmap maps `cp` to. Glyph zero is the +// face's .notdef box: the codepoint is missing, which is what fallback choice +// asks. +uint32_t ui_font_glyph(UIFont *f, int32_t cp); -// The size token is a pixel height. Metrics and rasterization both configure -// FreeType at that size, so hinted advances, baseline and bitmaps stay in step. -float ui_font_scale_for_height(UIFont *f, float px); +// `px` is the pixel height. Metrics and rasterization both configure FreeType +// at that size, so hinted advances, baseline and bitmaps stay in step. void ui_font_cell_metrics(UIFont *f, float px, int32_t *cell_w, int32_t *cell_h, int32_t *ascent); -// Rasterize `cp` into `out` as a hinted grayscale cell mask (`stride` bytes per -// row). The origin is the baseline `ascent` pixels from the top. Pixels outside -// the glyph are zero. Missing codepoints render the face's .notdef glyph. -// `bold` emboldens the outline before rendering (synthetic bold, same advance). -// Returns 1 when the glyph produced coverage, 0 for blank/error. -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); +// What one grid cell draws: `slice` of the glyph `glyph` rasterized across +// `span` cells with its own cell `lead` cells from the left. A plain cell is +// its own glyph, lead 0, span 1, slice 0. +typedef struct { + uint32_t glyph; + uint8_t lead; + uint8_t span; + uint8_t slice; +} UIShapedCell; + +// The longest glyph strip a ligature may claim, in cells. +#define UI_MAX_SPAN 8 + +// Whether a lookup of the font's ligature features (liga, calt, clig, rlig, +// rclt) could replace `glyph`. When it cannot, shaping leaves the glyph alone +// and its cell needs no shaping at all. +int ui_font_substitutes(UIFont *f, uint32_t glyph); + +// One cell per codepoint of `cps`, all in this face, shaped by HarfBuzz with +// the font's ligature features (a run with no glyph ui_font_substitutes +// reports comes back nominal without it). A substituted glyph whose ink +// reaches over neighbouring cells that draw nothing of their own (a +// ligature's spacers, or the cells a merged cluster swallowed) claims them as +// a strip. Otherwise, and for anything the grid cannot place (several glyphs +// in one cell, positioned marks), each cell is its nominal glyph. +void ui_font_shape(UIFont *f, float px, int32_t cell_w, const uint32_t *cps, + int32_t n, UIShapedCell *out); + +// Rasterize `glyph` into `out` as a hinted grayscale mask of `width` x +// `height` (`stride` bytes per row). The glyph's advance is centered in the +// `cell_w` pixels that start at `cell_x`, and its baseline is `ascent` pixels +// from the top; ink outside the mask is clipped. `bold` emboldens the outline +// before rendering (synthetic bold, same advance). Returns 1 when the glyph +// produced coverage, 0 for blank/error. +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); // Center bitmap ink bounds, independent of advance and baseline bearings. -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); diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 7ad9fe9a..5bcb2867 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -80,14 +80,22 @@ const p9_arrow_mask = blk: { const atlas_w: u32 = 2048; const atlas_h: u32 = 2048; const Slot = struct { u: u32, v: u32 }; -const GlyphKey = struct { - codepoint: u32, - centered: bool = false, +/// An atlas entry is a glyph of a face (0 the primary font, i the fallback +/// i - 1) at a role's size, drawn `span` cells wide with its own cell `lead` +/// cells in: a ligature is one strip whose cells sample their own slices. +/// Packed, so the atlas map hashes one word instead of walking fields. +const GlyphKey = packed struct(u64) { + face: u8, + glyph: u32, role: pardes.FontRole, decoration: GlyphDecoration = .{}, + centered: bool = false, + lead: u4 = 0, + span: u4 = 1, + _: u2 = 0, }; -const GlyphDecoration = struct { +const GlyphDecoration = packed struct(u5) { bold: bool = false, underline: @FieldType(pardes.CellStyle, "ul") = .off, strikethrough: bool = false, @@ -103,7 +111,7 @@ const GlyphDecoration = struct { }; const TaglineRaster = struct { - scale: f32, + px: f32, width: u32, baseline: i32, height: u32, @@ -111,16 +119,15 @@ const TaglineRaster = struct { fn taglineRaster(font: *c.UIFont, body_px: f32, body_cell_w: u32, body_cell_h: u32, percent: u8) TaglineRaster { const px = body_px * @as(f32, @floatFromInt(percent)) / 100.0; - const scale = c.ui_font_scale_for_height(font, px); var own_w: c_int = 1; var own_h: c_int = 1; var own_ascent: c_int = 1; - c.ui_font_cell_metrics(font, scale, &own_w, &own_h, &own_ascent); + c.ui_font_cell_metrics(font, px, &own_w, &own_h, &own_ascent); const fixed_w: c_int = @intCast(body_cell_w); const fixed_h: c_int = @intCast(body_cell_h); const band_h = std.math.clamp(own_h, 1, fixed_h); return .{ - .scale = scale, + .px = px, .width = @intCast(std.math.clamp(own_w, 1, fixed_w)), .baseline = std.math.clamp(own_ascent, 1, band_h), .height = @intCast(band_h), @@ -227,8 +234,8 @@ test "every font size step moves the cell, and the ends are reachable exactly" { var asc: c_int = 0; var pw: c_int = 0; var ph: c_int = 0; - c.ui_font_cell_metrics(font, c.ui_font_scale_for_height(font, px), &cw, &ch, &asc); - c.ui_font_cell_metrics(font, c.ui_font_scale_for_height(font, px - font_px_step), &pw, &ph, &asc); + c.ui_font_cell_metrics(font, px, &cw, &ch, &asc); + c.ui_font_cell_metrics(font, px - font_px_step, &pw, &ph, &asc); try std.testing.expect(cw > pw and ch > ph); } try std.testing.expectEqual(font_px_max, std.math.clamp(font_px_max + font_px_step, font_px_min, font_px_max)); @@ -238,10 +245,11 @@ test "every font size step moves the cell, and the ends are reachable exactly" { test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); - try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(font, 'H')); - try std.testing.expectEqual(@as(c_int, 0), c.ui_font_has_glyph(font, 0x10ffff)); + const h = c.ui_font_glyph(font, 'H'); + try std.testing.expect(h != 0); + try std.testing.expectEqual(@as(u32, 0), c.ui_font_glyph(font, 0x10ffff)); - const size = c.ui_font_scale_for_height(font, 13.0); + const size: f32 = 13.0; var cell_w: c_int = 0; var cell_h: c_int = 0; var ascent: c_int = 0; @@ -253,7 +261,7 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { var bitmap: [128 * 128]u8 = @splat(0xaa); try std.testing.expectEqual( @as(c_int, 1), - c.ui_font_raster(font, size, 'H', 0, &bitmap, stride, cell_w, cell_h, ascent), + c.ui_font_raster(font, size, h, 0, &bitmap, stride, cell_w, cell_h, 0, cell_w, ascent), ); var ink: usize = 0; @@ -272,7 +280,7 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { // Synthetic bold thickens the outline inside the same cell. try std.testing.expectEqual( @as(c_int, 1), - c.ui_font_raster(font, size, 'H', 1, &bitmap, stride, cell_w, cell_h, ascent), + c.ui_font_raster(font, size, h, 1, &bitmap, stride, cell_w, cell_h, 0, cell_w, ascent), ); var bold_coverage: usize = 0; for (0..@intCast(cell_h)) |y| { @@ -284,7 +292,7 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { try std.testing.expectEqual( @as(c_int, 0), - c.ui_font_raster(font, size, ' ', 0, &bitmap, stride, cell_w, cell_h, ascent), + c.ui_font_raster(font, size, c.ui_font_glyph(font, ' '), 0, &bitmap, stride, cell_w, cell_h, 0, cell_w, ascent), ); for (0..@intCast(cell_h)) |y| for (0..@intCast(cell_w)) |x| @@ -314,40 +322,21 @@ test "tagline glyph shrinks into its own cell and stays vertically centered" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); - const body_scale = c.ui_font_scale_for_height(font, 27.0); + const body_px: f32 = 27.0; var cell_w: c_int = 1; var cell_h: c_int = 1; var body_ascent: c_int = 1; - c.ui_font_cell_metrics(font, body_scale, &cell_w, &cell_h, &body_ascent); - const tag = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), config.gui_tagline_font_percent); - try std.testing.expect(tag.scale < body_scale); + c.ui_font_cell_metrics(font, body_px, &cell_w, &cell_h, &body_ascent); + const tag = taglineRaster(font, body_px, @intCast(cell_w), @intCast(cell_h), config.gui_tagline_font_percent); + try std.testing.expect(tag.px < body_px); try std.testing.expect(tag.width < @as(u32, @intCast(cell_w))); const stride: c_int = 128; var body: [128 * 128]u8 = @splat(0); var tagline: [128 * 128]u8 = @splat(0); - try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster( - font, - body_scale, - 'H', - 0, - &body, - stride, - cell_w, - cell_h, - body_ascent, - )); - try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster( - font, - tag.scale, - 'H', - 0, - &tagline, - stride, - @intCast(tag.width), - @intCast(tag.height), - tag.baseline, - )); + const h = c.ui_font_glyph(font, 'H'); + try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(font, body_px, h, 0, &body, stride, cell_w, cell_h, 0, cell_w, body_ascent)); + try std.testing.expectEqual(@as(c_int, 1), c.ui_font_raster(font, tag.px, h, 0, &tagline, stride, @intCast(tag.width), @intCast(tag.height), 0, @intCast(tag.width), tag.baseline)); const b = inkBounds(&body, @intCast(stride), @intCast(cell_w), @intCast(cell_h)).?; const t = inkBounds(&tagline, @intCast(stride), @intCast(tag.width), @intCast(tag.height)).?; try std.testing.expect(t.max_x - t.min_x < b.max_x - b.min_x); @@ -367,11 +356,10 @@ test "tagline glyph shrinks into its own cell and stays vertically centered" { test "tagline percentage changes measured band height without body metrics" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); - const body_scale = c.ui_font_scale_for_height(font, 27.0); var cell_w: c_int = 1; var cell_h: c_int = 1; var ascent: c_int = 1; - c.ui_font_cell_metrics(font, body_scale, &cell_w, &cell_h, &ascent); + c.ui_font_cell_metrics(font, 27.0, &cell_w, &cell_h, &ascent); const small = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 40); const configured = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 82); const full = taglineRaster(font, 27.0, @intCast(cell_w), @intCast(cell_h), 100); @@ -381,18 +369,17 @@ test "tagline percentage changes measured band height without body metrics" { try std.testing.expect(small.height < configured.height); try std.testing.expect(configured.height <= full.height); try std.testing.expect(full.height <= @as(u32, @intCast(cell_h))); - try std.testing.expectEqual(body_scale, c.ui_font_scale_for_height(font, 27.0)); } test "glyph atlas cache separates body and tagline roles" { var glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator); defer glyphs.deinit(); - try glyphs.put(.{ .codepoint = 'A', .role = .body }, .{ .u = 0, .v = 0 }); - try glyphs.put(.{ .codepoint = 'A', .role = .tagline }, .{ .u = 10, .v = 0 }); - try glyphs.put(.{ .codepoint = 'A', .role = .body, .decoration = .{ .bold = true } }, .{ .u = 20, .v = 0 }); + try glyphs.put(.{ .face = 0, .glyph = 36, .role = .body }, .{ .u = 0, .v = 0 }); + try glyphs.put(.{ .face = 0, .glyph = 36, .role = .tagline }, .{ .u = 10, .v = 0 }); + try glyphs.put(.{ .face = 0, .glyph = 36, .role = .body, .decoration = .{ .bold = true } }, .{ .u = 20, .v = 0 }); try std.testing.expectEqual(@as(usize, 3), glyphs.count()); - try std.testing.expectEqual(@as(u32, 0), glyphs.get(.{ .codepoint = 'A', .role = .body }).?.u); - try std.testing.expectEqual(@as(u32, 10), glyphs.get(.{ .codepoint = 'A', .role = .tagline }).?.u); + try std.testing.expectEqual(@as(u32, 0), glyphs.get(.{ .face = 0, .glyph = 36, .role = .body }).?.u); + try std.testing.expectEqual(@as(u32, 10), glyphs.get(.{ .face = 0, .glyph = 36, .role = .tagline }).?.u); } test "installed Nerd Symbols fallback covers Yazi directory icons" { @@ -408,7 +395,7 @@ test "installed Nerd Symbols fallback covers Yazi directory icons" { defer std.testing.allocator.free(bytes); const face = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; defer c.ui_font_free(face); - try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(face, 0xe5ff)); + try std.testing.expect(c.ui_font_glyph(face, 0xe5ff) != 0); } const CellInstance = extern struct { @@ -1767,8 +1754,7 @@ const Gui = struct { fallbacks: [max_fallback_fonts]?LoadedFallback = @splat(null), fallback_count: usize = 0, px: f32, - scale: f32, - tagline_scale: f32, + tagline_px: f32, tagline_percent: u8, tagline_width: u32, tagline_height: u32, @@ -1785,6 +1771,7 @@ const Gui = struct { pen_y: u32 = 0, atlas_dirty: bool = true, space_slot: Slot = .{ .u = 0, .v = 0 }, + words: Words = .{}, touch: Touch = .{}, pet_state: pet.State = .{}, @@ -1894,13 +1881,19 @@ fn deinitFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void { } } -fn fontForCodepoint(g: *const Gui, cp: u32) *c.UIFont { - if (c.ui_font_has_glyph(g.font, @intCast(cp)) != 0) return g.font; - for (g.fallbacks[0..g.fallback_count]) |loaded| { - const face = loaded.?.face; - if (c.ui_font_has_glyph(face, @intCast(cp)) != 0) return face; +const FaceGlyph = struct { face: u8, glyph: u32 }; + +/// The face and glyph that draw `cp`, the face as a GlyphKey face: 0 is the +/// primary font and i the fallback i - 1. A codepoint no face has draws the +/// primary's .notdef. +fn glyphForCodepoint(g: *const Gui, cp: u32) FaceGlyph { + const primary = c.ui_font_glyph(g.font, @intCast(cp)); + if (primary != 0) return .{ .face = 0, .glyph = primary }; + for (g.fallbacks[0..g.fallback_count], 1..) |loaded, face| { + const glyph = c.ui_font_glyph(loaded.?.face, @intCast(cp)); + if (glyph != 0) return .{ .face = @intCast(face), .glyph = glyph }; } - return g.font; + return .{ .face = 0, .glyph = 0 }; } const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; @@ -2193,11 +2186,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u return error.FontInit; }; const px: f32 = 27.0; - const scale = c.ui_font_scale_for_height(font, px); var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; - c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); + c.ui_font_cell_metrics(font, px, &cw, &chh, &asc); const cell_w: u32 = @intCast(@max(cw, 1)); const cell_h: u32 = @intCast(@max(chh, 1)); const tagline = taglineRaster(font, px, cell_w, cell_h, config.gui_tagline_font_percent); @@ -2282,8 +2274,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .image_capacity = initial_image_capacity, .font = font, .px = px, - .scale = scale, - .tagline_scale = tagline.scale, + .tagline_px = tagline.px, .tagline_percent = config.gui_tagline_font_percent, .tagline_width = tagline.width, .tagline_height = tagline.height, @@ -2305,6 +2296,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u loadFallbackFonts(&g, gpa); defer deinitFallbackFonts(&g, gpa); defer g.glyphs.deinit(); + defer g.words.deinit(gpa); defer { clearNativeImages(&g); g.native_images.deinit(gpa); @@ -2317,8 +2309,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran defer c.ui_font_free(g.font); defer gpa.free(g.scroll_edge); // grown on demand by stepScroll - _ = c.ui_font_raster(font, scale, ' ', 0, atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); - g.pen_x = cell_w; + resetGlyphAtlas(&g); if (attach) |requested| return attachRequested(gpa, &g, requested); var attached: ?detached_client.Client = null; @@ -4392,10 +4383,12 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard var n: u32 = 0; var row = y0; while (row < y0 + bh) : (row += 1) { + const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols]; + const row_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null; var col = x0; while (col < x0 + bw) : (col += 1) { const sidx: u32 = @as(u32, row) * surface.cols + col; - emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), surface.at(col, row), false, sidx == cursor_idx and !cursor_bar, page); + emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), line, col, row_cursor, false, sidx == cursor_idx and !cursor_bar, page); n += 1; } } @@ -4403,7 +4396,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1; var i: u16 = 0; while (i < bw) : (i += 1) { - emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, cellFontRole(&g.scroll_edge[i]), &g.scroll_edge[i], false, false, page); + emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, cellFontRole(&g.scroll_edge[i]), g.scroll_edge[0..bw], i, null, false, false, page); n += 1; } } @@ -4911,6 +4904,7 @@ fn renderFrame( if (cursor) |value| _ = c.SDL_SetCursor(value); } g.scene_target_failed = false; + g.words.memo = .{}; // positional, and this frame's cells are new const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return false; var command_consumed = false; defer if (!command_consumed) { @@ -5111,6 +5105,8 @@ fn renderFrame( const cursor_bar = if (surface.cursor) |cu| cu.bar else false; row = 0; while (row < surface.rows) : (row += 1) { + const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols]; + const line_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null; var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (bodyLayerAt(surface, col, row) != null or tagLayerIn(surface.tagLayers(), col, row) != null) continue; @@ -5134,7 +5130,9 @@ fn renderFrame( win_w, win_h, track, - &surface.previous_cells[logical_idx], + surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], + col, + null, &tagline_base, paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row), true, @@ -5153,7 +5151,9 @@ fn renderFrame( win_w, win_h, if (data_effect and !data_diff) null else track, - surface.at(col, row), + line, + col, + line_cursor, &tagline_base, paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row), false, @@ -5204,7 +5204,6 @@ fn renderFrame( while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; if (bodyLayerIn(surface.previous_body_layers, col, row) != null or tagLayerIn(surface.previous_tag_layers, col, row) != null) continue; - const logical_idx: u32 = @as(u32, row) * surface.cols + col; const destination: usize = if (duplicate_under) 0 else batch_index; emitSurfaceCell( g, @@ -5217,7 +5216,9 @@ fn renderFrame( win_w, win_h, if (duplicate_under) null else track, - &surface.previous_cells[logical_idx], + surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], + col, + null, &tagline_base, paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row), false, @@ -5381,12 +5382,14 @@ test "tagline bands center in their cells and Tagbottom faces the window edge" { try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h)); } -fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_cursor: bool, page: Ground) ResolvedCell { +/// The colours and atlas slot of `line[at]`. `cursor` is where the cursor sits +/// in `line`, block or bar: shaping breaks around it. +fn resolveCell(g: *Gui, line: []const pardes.Cell, at: usize, cursor: ?usize, role: pardes.FontRole, is_cursor: bool, page: Ground) ResolvedCell { + const cell = &line[at]; var fg = fg_default; var bg = page.rgb; var clear_bg = page.clear; var reverse = is_cursor; - var blank = false; if (!cell.default) { const st = cell.style; fg = switch (st.fg) { @@ -5409,8 +5412,8 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_curs if (st.invisible) { fg = bg; // With a translucent background, painting bg-colored glyphs at - // full opacity would expose text that the terminal concealed. - blank = true; + // full opacity would expose text that the terminal concealed, so + // cellSlot draws a concealed cell blank. } if (st.dim) for (&fg) |*ch| { ch.* = @intCast(@as(u16, ch.*) * 6 / 10); @@ -5421,10 +5424,8 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_curs clear_bg = false; } - const cp = if (blank) ' ' else cellCodepoint(cell); - const decoration = GlyphDecoration.fromCell(cell); return .{ - .slot = if (cp == ' ' and !decoration.drawsBlank()) g.space_slot else ensureGlyph(g, cp, role, decoration), + .slot = cellSlot(g, line, at, cursor, role), .fg = fg, .bg = bg, .role = role, @@ -5439,10 +5440,11 @@ fn cellFontRole(cell: *const pardes.Cell) pardes.FontRole { test "concealed terminal text emits no ink on opaque or transparent ground" { var g: Gui = undefined; g.space_slot = .{ .u = 0, .v = 0 }; + g.words = .{}; var cell: pardes.Cell = .{ .default = false, .style = .{ .invisible = true, .ul = .single } }; cell.text[0] = 'X'; for ([_]Ground{ .opaqueRgb(bg_default), .{ .rgb = bg_default, .clear = true } }) |page| { - const resolved = resolveCell(&g, &cell, .body, false, page); + const resolved = resolveCell(&g, (&cell)[0..1], 0, null, .body, false, page); try std.testing.expectEqualDeep(g.space_slot, resolved.slot); try std.testing.expectEqual(page.rgb, resolved.bg); } @@ -5512,12 +5514,13 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p // A window is rarely a whole number of cells wide: a band that ends at // the last column runs on through the leftover pixels to the edge. const band_right = if (win_w - right < bw) @max(right, win_w) else right; - emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, &background, old_layer, false, page); + emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page); if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; + const line_cursor: ?usize = if (layer.cursor) |cu| cu.col else null; for (0..tagLayerCellCount(g, layer)) |col| { const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false; - emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, &layer.cells[col], old_layer, cursor, page); + emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, layer.cells[0..layer.cols], col, line_cursor, old_layer, cursor, page); clipInstanceRight(&instances[next.*], right, win_w); if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; @@ -5579,17 +5582,18 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const if (header) { var background: pardes.Cell = .{ .default = false, .style = layer.cells[index * layer.cols].style }; background.style.font_role = .body; - emitInstance(g, instances, next.*, 0, 0, .{ .w = @as(f32, @floatFromInt(layer.viewport.w)) * bw, .h = height, .x_off = left, .y_off = top }, win_w, win_h, track, .body, &background, old_layer, false, page); + emitInstance(g, instances, next.*, 0, 0, .{ .w = @as(f32, @floatFromInt(layer.viewport.w)) * bw, .h = height, .x_off = left, .y_off = top }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page); clipBodyInstance(&instances[next.*], bottom, win_h); clipInstanceRight(&instances[next.*], right, win_w); next.* += 1; } + const line = layer.cells[index * layer.cols ..][0..layer.cols]; + const line_cursor: ?usize = if (layer.cursor) |cu| if (cu.y == row) cu.x else null else null; for (0..layer.cols) |col| { if (left + @as(f32, @floatFromInt(col)) * width >= right) break; - const cell = &layer.cells[index * layer.cols + col]; const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.x == col and cu.y == row else false; const glyph_top = top - (if (header) @as(f32, @floatFromInt(taglineBandOffset(0, win_h, g.cell_h, g.tagline_height))) else 0); - emitInstance(g, instances, next.*, 0, 0, .{ .w = width, .h = height, .x_off = left + @as(f32, @floatFromInt(col)) * width, .y_off = glyph_top }, win_w, win_h, track, role, cell, old_layer, cursor, page); + emitInstance(g, instances, next.*, 0, 0, .{ .w = width, .h = height, .x_off = left + @as(f32, @floatFromInt(col)) * width, .y_off = glyph_top }, win_w, win_h, track, role, line, col, line_cursor, old_layer, cursor, page); clipBodyInstance(&instances[next.*], bottom, win_h); clipInstanceRight(&instances[next.*], right, win_w); next.* += 1; @@ -5601,7 +5605,7 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const .h = 1, .x_off = left, .y_off = top + height - 1, - }, win_w, win_h, track, .body, &border, old_layer, false, page); + }, win_w, win_h, track, .body, (&border)[0..1], 0, null, old_layer, false, page); clipBodyInstance(&instances[next.*], bottom, win_h); clipInstanceRight(&instances[next.*], right, win_w); next.* += 1; @@ -5653,33 +5657,38 @@ fn emitSurfaceCell( win_w: f32, win_h: f32, track: ?pardes.layout.Track, - cell: *const pardes.Cell, + line: []const pardes.Cell, + at: usize, + cursor: ?usize, tagline_base: *const pardes.Cell, grip: ?GripCell, old_layer: bool, is_cursor: bool, page: Ground, ) void { - const role = cellFontRole(cell); + const role = cellFontRole(&line[at]); if (role == .tagline) { var band_base = tagline_base.*; if (core) |p| band_base.style.bg = .{ .rgb = taglineBaseRgb(p, col, row) }; // The smaller font changes the ink, not the chrome's coverage. Painting // this underlay at tagline height leaves page-colored gutters between // adjacent workspace, column and pane tags at TaglineSize < 100. - emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, &band_base, old_layer, false, page); + emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page); next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. - emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page); + emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); // An anchor starts at its column's rule: its left edge is inset by // its top margin so the band frames it on the left as above and // below, and its mark moves right by half that to stay centered. const inset: u32 = if (core) |p| if (std.mem.indexOfScalar(u16, p.col_x[0..p.ncol], col -| (if (grip) |indicator| indicator.offset else 0)) != null) anchorInset(g, row, win_h) else 0 else 0; if (grip) |indicator| { - const cached = cachedGlyph(g, cellCodepoint(indicator.cell), .tagline, GlyphDecoration.fromCell(indicator.cell), true); + // A grip is one glyph centered across the gutter, not text: it is + // looked up by its codepoint's glyph, never shaped. + const drawn = glyphForCodepoint(g, cellCodepoint(indicator.cell)); + const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(indicator.cell), .centered = true }); const slot = cached orelse g.space_slot; const offset: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, indicator.offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; - const resolved = resolveCell(g, indicator.cell, .tagline, false, page); + const resolved = resolveCell(g, indicator.cell[0..1], 0, null, .tagline, false, page); instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255; instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255; instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255; @@ -5695,7 +5704,7 @@ fn emitSurfaceCell( next.* += 1; return; } - emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, role, cell, old_layer, is_cursor, page); + emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, role, line, at, cursor, old_layer, is_cursor, page); next.* += 1; } @@ -5703,6 +5712,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer core.deinit(); var g: Gui = undefined; + g.words = .{}; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 8; @@ -5713,7 +5723,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" { var next: u32 = 0; for (0..3) |row| { // Column 1: past the anchor's inset edge at the column's left. - emitSurfaceCell(&g, core, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, core, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default)); const base = instances[row * 2]; const ink = instances[row * 2 + 1]; try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001); @@ -5726,12 +5736,12 @@ test "small tagline fonts leave no background gutters between adjacent tags" { // Column 0 is an anchor's edge: its ink is inset by the band's margin // (2 px here) and its underlay still covers the whole cell. next = 0; - emitSurfaceCell(&g, core, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, core, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default)); try std.testing.expectApproxEqAbs(@as(f32, 10.0 / 800.0 * 2), instances[0].x1 - instances[0].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 8.0 / 800.0 * 2), instances[1].x1 - instances[1].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 8) / atlas_w, instances[1].u1 - instances[1].u0, 0.0001); next = 0; - emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, true, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, true, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(u32, 2), next); try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), instances[1].y0 - instances[1].y1, 0.0001); } @@ -5747,12 +5757,14 @@ fn emitInstance( win_h: f32, track: ?pardes.layout.Track, role: pardes.FontRole, - cell: *const pardes.Cell, + line: []const pardes.Cell, + at: usize, + cursor: ?usize, old_layer: bool, is_cursor: bool, page: Ground, ) void { - const resolved = resolveCell(g, cell, role, is_cursor, page); + const resolved = resolveCell(g, line, at, cursor, role, is_cursor, page); const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; const visual_h: f32 = if (resolved.role == .tagline) @@ -5818,13 +5830,14 @@ fn emitInstance( test "block cursor alone overrides transparent cell background" { var g: Gui = undefined; + g.words = .{}; g.cell_w = 10; g.cell_h = 20; g.space_slot = .{ .u = 0, .v = 0 }; const cell: pardes.Cell = .{}; var instances: [2]CellInstance = undefined; for ([_]bool{ false, true }, 0..) |cursor, i| { - emitInstance(&g, &instances, @intCast(i), 0, 0, fixedCellLayout(&g), 800, 480, null, .body, &cell, false, cursor, Ground.opaqueRgb(bg_default)); + emitInstance(&g, &instances, @intCast(i), 0, 0, fixedCellLayout(&g), 800, 480, null, .body, (&cell)[0..1], 0, null, false, cursor, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(cursor, instances[i].effect & opaque_bg_bit != 0); } } @@ -5875,16 +5888,15 @@ fn firstCp(s: []const u8) u32 { } fn refitFont(g: *Gui, core: ?*pardes.Pardes) void { - g.scale = c.ui_font_scale_for_height(g.font, g.px); var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; - c.ui_font_cell_metrics(g.font, g.scale, &cw, &chh, &asc); + c.ui_font_cell_metrics(g.font, g.px, &cw, &chh, &asc); g.cell_w = @intCast(@max(cw, 1)); g.cell_h = @intCast(@max(chh, 1)); g.ascent = @intCast(@max(asc, 1)); const tagline = taglineRaster(g.font, g.px, g.cell_w, g.cell_h, g.tagline_percent); - g.tagline_scale = tagline.scale; + g.tagline_px = tagline.px; g.tagline_width = tagline.width; g.tagline_baseline = tagline.baseline; g.tagline_height = tagline.height; @@ -5899,10 +5911,11 @@ fn refitFont(g: *Gui, core: ?*pardes.Pardes) void { resetScroll(g); } +// The first cell of the cleared atlas stays blank: the space slot. fn resetGlyphAtlas(g: *Gui) void { g.glyphs.clearRetainingCapacity(); + g.words.clear(); @memset(g.atlas_stage, 0); - _ = c.ui_font_raster(g.font, g.scale, ' ', 0, g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); g.space_slot = .{ .u = 0, .v = 0 }; g.pen_x = g.cell_w; g.pen_y = 0; @@ -5914,7 +5927,7 @@ fn syncTaglineFont(g: *Gui, core: *pardes.Pardes) void { if (percent == g.tagline_percent) return; g.tagline_percent = percent; const tagline = taglineRaster(g.font, g.px, g.cell_w, g.cell_h, percent); - g.tagline_scale = tagline.scale; + g.tagline_px = tagline.px; g.tagline_width = tagline.width; g.tagline_baseline = tagline.baseline; g.tagline_height = tagline.height; @@ -5923,14 +5936,294 @@ fn syncTaglineFont(g: *Gui, core: *pardes.Pardes) void { _ = updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height); } -fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecoration) Slot { - return cachedGlyph(g, cp, role, decoration, false) orelse g.space_slot; +/// What text draws, so a frame does not work it out again. `cells` is the +/// atlas slot of a codepoint no ligature can touch (`shape_slot` for one that +/// is shaped with its word); `map` is the atlas slot of every cell of a word +/// holding one, found by the word's text. Bounded: past `max_cells` word cells +/// it starts over, and the next frames look up what they show again (the +/// glyphs stay in the atlas). +const Words = struct { + cells: std.AutoHashMapUnmanaged(CellKey, Slot) = .empty, + map: std.AutoHashMapUnmanaged(u64, Word) = .empty, + /// Every word's codepoints and slots, end to end; a Word indexes both. + text: std.ArrayListUnmanaged(u32) = .empty, + slots: std.ArrayListUnmanaged(Slot) = .empty, + shaped: std.ArrayListUnmanaged(c.UIShapedCell) = .empty, + /// The word the last cell was in, so the rest of it costs a compare. It + /// is positional, so every frame forgets it: the cells change under it. + memo: Memo = .{}, + + const max_cells = 1 << 17; + const CellKey = packed struct(u64) { cp: u21, role: pardes.FontRole, decoration: GlyphDecoration, _: u30 = 0 }; + const shape_slot: Slot = .{ .u = std.math.maxInt(u32), .v = std.math.maxInt(u32) }; + const Word = struct { start: u32, len: u32, decoration: GlyphDecoration }; + const Memo = struct { + line: ?[*]const pardes.Cell = null, + len: usize = 0, + start: usize = 0, + end: usize = 0, + cursor: ?usize = null, + slots: usize = 0, + }; + + fn clear(w: *Words) void { + w.cells.clearRetainingCapacity(); + w.map.clearRetainingCapacity(); + w.text.clearRetainingCapacity(); + w.slots.clearRetainingCapacity(); + w.memo = .{}; + } + + fn deinit(w: *Words, gpa: std.mem.Allocator) void { + w.cells.deinit(gpa); + w.map.deinit(gpa); + w.text.deinit(gpa); + w.slots.deinit(gpa); + w.shaped.deinit(gpa); + } +}; + +/// What decides where a word ends, for one cell. A concealed cell is a space. +const WordCell = struct { cp: u32, decoration: GlyphDecoration, role: pardes.FontRole }; + +fn wordCell(cell: *const pardes.Cell) WordCell { + return .{ + .cp = if (!cell.default and cell.style.invisible) ' ' else cellCodepoint(cell), + .decoration = GlyphDecoration.fromCell(cell), + .role = cellFontRole(cell), + }; +} + +/// Whether a word ends between `a`, cell `i`, and `b`, cell `i + 1`: at a +/// space, a decoration or role change, either side of the cursor (Ghostty's +/// rule: the cell being edited shows its own glyph, so a ligature under the +/// cursor comes apart), 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, and each +/// cell tints its own slice. +fn wordBreak(a: WordCell, b: WordCell, i: usize, cursor: ?usize) bool { + if (a.cp == ' ' or b.cp == ' ') return true; + if (cursor) |at| if (at == i or at == i + 1) return true; + if (a.role != b.role or !std.meta.eql(a.decoration, b.decoration)) return true; + return (a.cp == 'f' and (b.cp == 'i' or b.cp == 'l')) or (a.cp == 's' and b.cp == 't'); } -fn cachedGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecoration, centered: bool) ?Slot { - const key: GlyphKey = .{ .codepoint = cp, .role = role, .decoration = decoration, .centered = centered }; - const slot_w = g.cell_w * @as(u32, if (centered) config.GUTTER else 1); +/// The atlas slot `line[at]` draws: blank for a plain space (or concealed +/// text), its own glyph when no ligature could replace that glyph (every +/// cell, in a font without ligatures), else its slice of the word around it, +/// shaped once and cached by the word's text. The cells of a word being walked +/// cost a compare. Taglines are not shaped: they are drawn at two pitches +/// (tagline_width in a tag layer, a whole cell in the grid), and a strip would +/// not line up in one of them. +fn cellSlot(g: *Gui, line: []const pardes.Cell, at: usize, cursor: ?usize, role: pardes.FontRole) Slot { + const words = &g.words; + const memo = words.memo; + if (memo.line == line.ptr and memo.len == line.len and memo.cursor == cursor and at >= memo.start and at < memo.end and role == .body) + return words.slots.items[memo.slots + at - memo.start]; + var here = wordCell(&line[at]); + // An underlined or struck-out space is a glyph of its own: it draws its line. + if (here.cp == ' ' and !here.decoration.drawsBlank()) return g.space_slot; + const gpa = g.glyphs.allocator; + const cell_key: Words.CellKey = .{ .cp = @intCast(here.cp), .role = role, .decoration = here.decoration }; + const known = words.cells.getOrPut(gpa, cell_key) catch return g.space_slot; + if (!known.found_existing) { + const drawn = glyphForCodepoint(g, here.cp); + const font = if (drawn.face == 0) g.font else g.fallbacks[drawn.face - 1].?.face; + known.value_ptr.* = if (role == .body and c.ui_font_substitutes(font, drawn.glyph) != 0) + Words.shape_slot + else + cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = role, .decoration = here.decoration }) orelse g.space_slot; + } + const plain = known.value_ptr.*; + if (!std.meta.eql(plain, Words.shape_slot)) return plain; + + if (words.text.items.len > Words.max_cells) words.clear(); + var start = at; + while (start > 0) { + const before = wordCell(&line[start - 1]); + if (wordBreak(before, here, start - 1, cursor)) break; + start -= 1; + here = before; + } + // The word's text goes on the end of `text`: a new word keeps it there, + // a known one gives it back. + const text_start = words.text.items.len; + var end = start; + while (true) { + words.text.append(gpa, here.cp) catch return g.space_slot; + end += 1; + if (end == line.len) break; + const next = wordCell(&line[end]); + if (wordBreak(here, next, end - 1, cursor)) break; + here = next; + } + const text = words.text.items[text_start..]; + const decoration = here.decoration; + const hash = std.hash.Wyhash.hash(@as(u5, @bitCast(decoration)), std.mem.sliceAsBytes(text)); + if (words.map.get(hash)) |word| { + if (word.len == text.len and std.meta.eql(word.decoration, decoration) and + std.mem.eql(u32, words.text.items[word.start..][0..word.len], text)) + { + words.text.shrinkRetainingCapacity(text_start); + words.memo = .{ .line = line.ptr, .len = line.len, .start = start, .end = end, .cursor = cursor, .slots = word.start }; + return words.slots.items[word.start + at - start]; + } + } + + // A new word (a hash collision takes the map entry over). Runs of one + // face are shaped in that face; fallback faces shape like the primary. + const n = text.len; + words.slots.ensureUnusedCapacity(gpa, n) catch { + words.text.shrinkRetainingCapacity(text_start); + return g.space_slot; + }; + words.shaped.resize(gpa, n) catch { + words.text.shrinkRetainingCapacity(text_start); + return g.space_slot; + }; + const shaped = words.shaped.items; + var run_start: usize = 0; + var face = glyphForCodepoint(g, text[0]).face; + while (run_start < n) { + var run_end = run_start + 1; + var next_face = face; + while (run_end < n) : (run_end += 1) { + next_face = glyphForCodepoint(g, text[run_end]).face; + if (next_face != face) break; + } + const run_font = if (face == 0) g.font else g.fallbacks[face - 1].?.face; + c.ui_font_shape(run_font, g.px, @intCast(g.cell_w), text[run_start..].ptr, @intCast(run_end - run_start), shaped[run_start..].ptr); + for (shaped[run_start..run_end]) |cell| { + const key: GlyphKey = .{ .face = face, .glyph = cell.glyph, .role = .body, .decoration = decoration, .lead = @intCast(cell.lead), .span = @intCast(cell.span) }; + const slot: Slot = if (cachedGlyph(g, key)) |strip| .{ .u = strip.u + g.cell_w * cell.slice, .v = strip.v } else g.space_slot; + words.slots.appendAssumeCapacity(slot); + } + run_start = run_end; + face = next_face; + } + words.map.put(gpa, hash, .{ .start = @intCast(text_start), .len = @intCast(n), .decoration = decoration }) catch {}; + words.memo = .{ .line = line.ptr, .len = line.len, .start = start, .end = end, .cursor = cursor, .slots = text_start }; + return words.slots.items[text_start + at - start]; +} + +/// A Gui that can shape and rasterize `font` at `px`, and nothing else. +fn textTestGui(font: *c.UIFont, px: f32) !Gui { + var g: Gui = undefined; + g.font = font; + g.fallback_count = 0; + g.px = px; + var cw: c_int = 0; + var ch: c_int = 0; + c.ui_font_cell_metrics(font, px, &cw, &ch, &g.ascent); + g.cell_w = @intCast(cw); + g.cell_h = @intCast(ch); + const tag = taglineRaster(font, px, g.cell_w, g.cell_h, 82); + g.tagline_px = tag.px; + g.tagline_width = tag.width; + g.tagline_height = tag.height; + g.tagline_baseline = tag.baseline; + g.atlas_stage = try std.testing.allocator.alloc(u8, atlas_w * atlas_h); + g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator); + g.words = .{}; + resetGlyphAtlas(&g); + return g; +} + +fn textTestLine(comptime text: []const u8) [text.len]pardes.Cell { + var line: [text.len]pardes.Cell = @splat(.{ .default = false }); + for (&line, text) |*cell, byte| cell.text[0] = byte; + return line; +} + +test "a ligature draws across its cells, one slice each, and comes apart under the cursor" { + const bytes = filesystem.readFile(std.testing.allocator, "assets/MapleMono-NF-Regular.ttf") catch return error.SkipZigTest; + defer std.testing.allocator.free(bytes); + const font = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; + defer c.ui_font_free(font); + var g = try textTestGui(font, 27); + defer std.testing.allocator.free(g.atlas_stage); + defer g.glyphs.deinit(); + defer g.words.deinit(std.testing.allocator); + + const line = textTestLine("a->b"); + var slots: [4]Slot = undefined; + for (&slots, 0..) |*slot, i| slot.* = cellSlot(&g, &line, i, null, .body); + // `->` is one glyph strip two cells wide: each cell samples its own half. + try std.testing.expectEqual(slots[1].v, slots[2].v); + try std.testing.expectEqual(slots[1].u + g.cell_w, slots[2].u); + var strips: usize = 0; + var it = g.glyphs.iterator(); + while (it.next()) |entry| if (entry.key_ptr.span == 2) { + strips += 1; + try std.testing.expectEqual(@as(u4, 1), entry.key_ptr.lead); + try std.testing.expectEqual(slots[1], entry.value_ptr.*); + }; + try std.testing.expectEqual(@as(usize, 1), strips); + // The arrow runs through the seam: both halves carry ink at it. + const seam = g.atlas_stage[@as(usize, slots[2].v) * atlas_w ..]; + var left_ink: usize = 0; + var right_ink: usize = 0; + for (0..g.cell_h) |y| { + left_ink += seam[y * atlas_w + slots[2].u - 1]; + right_ink += seam[y * atlas_w + slots[2].u]; + } + try std.testing.expect(left_ink > 0 and right_ink > 0); + // `a` and `b` are their plain glyphs. + try std.testing.expectEqual(g.glyphs.get(.{ .face = 0, .glyph = c.ui_font_glyph(font, 'a'), .role = .body }).?, slots[0]); + try std.testing.expectEqual(g.glyphs.get(.{ .face = 0, .glyph = c.ui_font_glyph(font, 'b'), .role = .body }).?, slots[3]); + + // A second line with the same word reuses the shaped slots. + const again = textTestLine("a->b"); + const text_len = g.words.text.items.len; + for (0..4) |i| try std.testing.expectEqual(slots[i], cellSlot(&g, &again, i, null, .body)); + try std.testing.expectEqual(text_len, g.words.text.items.len); + + // The cursor on either half shows plain `-` and `>`. + const plain_minus = cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, '-'), .role = .body }).?; + const plain_greater = cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, '>'), .role = .body }).?; + for ([_]usize{ 1, 2 }) |cursor| { + try std.testing.expectEqual(plain_minus, cellSlot(&g, &line, 1, cursor, .body)); + try std.testing.expectEqual(plain_greater, cellSlot(&g, &line, 2, cursor, .body)); + } + // A tagline is never shaped. + try std.testing.expectEqual(c.ui_font_glyph(font, '-'), (try glyphKeyAt(&g, cellSlot(&g, &line, 1, null, .tagline))).glyph); +} + +fn glyphKeyAt(g: *Gui, slot: Slot) !GlyphKey { + var it = g.glyphs.iterator(); + while (it.next()) |entry| if (std.meta.eql(entry.value_ptr.*, slot)) return entry.key_ptr.*; + return error.NoGlyph; +} + +test "a font without ligatures draws every cell as its own glyph, exactly as before shaping" { + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; + defer c.ui_font_free(font); + var g = try textTestGui(font, 18); + defer std.testing.allocator.free(g.atlas_stage); + defer g.glyphs.deinit(); + defer g.words.deinit(std.testing.allocator); + const text = "a->b != c => d <= e |> www == :: 0x1F"; + var line = textTestLine(text); + line[3].style.bold = true; // a style change inside a word + var expected: [64 * 64]u8 = undefined; + for (line, 0..) |cell, i| { + if (text[i] == ' ') continue; + const slot = cellSlot(&g, &line, i, null, .body); + const key = try glyphKeyAt(&g, slot); + try std.testing.expectEqual(c.ui_font_glyph(font, text[i]), key.glyph); + try std.testing.expectEqual(@as(u4, 1), key.span); + _ = c.ui_font_raster(font, 18, key.glyph, @intFromBool(cell.style.bold), &expected, 64, @intCast(g.cell_w), @intCast(g.cell_h), 0, @intCast(g.cell_w), g.ascent); + for (0..g.cell_h) |y| + try std.testing.expectEqualSlices(u8, expected[y * 64 ..][0..g.cell_w], g.atlas_stage[(slot.v + y) * atlas_w + slot.u ..][0..g.cell_w]); + } +} + +/// The atlas slot of `key`, rasterized on first use. A strip is `span` cells +/// wide (a grip glyph GUTTER cells, centered); a cell samples its slice of it. +/// Null when the atlas is full. +fn cachedGlyph(g: *Gui, key: GlyphKey) ?Slot { if (g.glyphs.get(key)) |s| return s; + const slot_w = g.cell_w * @as(u32, if (key.centered) config.GUTTER else key.span); if (g.pen_x + slot_w > atlas_w) { g.pen_x = 0; g.pen_y += g.cell_h; @@ -5938,18 +6231,20 @@ fn cachedGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecorat if (g.pen_y + g.cell_h > atlas_h) return null; // atlas full: fall back const s = Slot{ .u = g.pen_x, .v = g.pen_y }; const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u; - const face = fontForCodepoint(g, cp); - const scale = if (role == .tagline) g.tagline_scale else g.scale; - const baseline = if (role == .tagline) g.tagline_baseline else g.ascent; - const raster_w = if (centered) slot_w else if (role == .tagline) g.tagline_width else g.cell_w; - const raster_h = if (role == .tagline) g.tagline_height else g.cell_h; - if (centered) { - _ = c.ui_font_raster_centered(face, scale, @intCast(cp), @intFromBool(decoration.bold), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h)); + const face = if (key.face == 0) g.font else g.fallbacks[key.face - 1].?.face; + const px = if (key.role == .tagline) g.tagline_px else g.px; + const baseline = if (key.role == .tagline) g.tagline_baseline else g.ascent; + const cell_w = if (key.role == .tagline) g.tagline_width else g.cell_w; + const raster_w = if (key.centered) slot_w else cell_w * key.span; + const raster_h = if (key.role == .tagline) g.tagline_height else g.cell_h; + const bold = @intFromBool(key.decoration.bold); + if (key.centered) { + _ = c.ui_font_raster_centered(face, px, key.glyph, bold, out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h)); } else { - _ = c.ui_font_raster(face, scale, @intCast(cp), @intFromBool(decoration.bold), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), baseline); + _ = c.ui_font_raster(face, px, key.glyph, bold, out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), @intCast(cell_w * key.lead), @intCast(cell_w), baseline); } - decorateLine(out, atlas_w, raster_w, raster_h, baseline, decoration); - if (centered) centerGlyphInk(out, raster_w, raster_h); + decorateLine(out, atlas_w, raster_w, raster_h, baseline, key.decoration); + if (key.centered) centerGlyphInk(out, raster_w, raster_h); g.glyphs.put(key, s) catch return null; g.pen_x += slot_w; g.atlas_dirty = true; @@ -5988,23 +6283,25 @@ test "pane indicator cached ink centers bearings styles and sizes" { defer std.testing.allocator.free(g.atlas_stage); g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator); defer g.glyphs.deinit(); + g.words = .{}; for ([_]f32{ 13, 27, 40 }) |px| { - g.scale = c.ui_font_scale_for_height(font, px); + g.px = px; var cw: c_int = 0; var ch: c_int = 0; - c.ui_font_cell_metrics(font, g.scale, &cw, &ch, &g.ascent); + c.ui_font_cell_metrics(font, px, &cw, &ch, &g.ascent); g.cell_w = @intCast(cw); g.cell_h = @intCast(ch); for ([_]u8{ 40, 82, 100 }) |percent| { const tag = taglineRaster(font, px, g.cell_w, g.cell_h, percent); g.tagline_width = tag.width; g.tagline_height = tag.height; - g.tagline_scale = tag.scale; + g.tagline_px = tag.px; g.tagline_baseline = tag.baseline; resetGlyphAtlas(&g); try std.testing.expectEqual(@as(usize, 0), g.glyphs.count()); for ([_]u32{ '^', '$', 'j' }) |cp| for ([_]bool{ false, true }) |bold| { - const slot = cachedGlyph(&g, cp, .tagline, .{ .bold = bold }, true).?; + const key: GlyphKey = .{ .face = 0, .glyph = c.ui_font_glyph(font, @intCast(cp)), .role = .tagline, .decoration = .{ .bold = bold }, .centered = true }; + const slot = cachedGlyph(&g, key).?; const bounds = inkBounds(g.atlas_stage[@as(usize, slot.v) * atlas_w + slot.u ..], atlas_w, g.cell_w * config.GUTTER, tag.height).?; const center_x: isize = @intCast(bounds.min_x + bounds.max_x); const center_y: isize = @intCast(bounds.min_y + bounds.max_y); @@ -6012,7 +6309,7 @@ test "pane indicator cached ink centers bearings styles and sizes" { try std.testing.expect(@abs(center_y - @as(isize, @intCast(tag.height - 1))) <= 1); const count = g.glyphs.count(); const pen = g.pen_x; - try std.testing.expectEqualDeep(slot, cachedGlyph(&g, cp, .tagline, .{ .bold = bold }, true).?); + try std.testing.expectEqualDeep(slot, cachedGlyph(&g, key).?); try std.testing.expectEqual(count, g.glyphs.count()); try std.testing.expectEqual(pen, g.pen_x); }; @@ -6020,7 +6317,7 @@ test "pane indicator cached ink centers bearings styles and sizes" { } resetGlyphAtlas(&g); g.pen_y = atlas_h; - try std.testing.expectEqual(@as(?Slot, null), cachedGlyph(&g, '^', .tagline, .{}, true)); + try std.testing.expectEqual(@as(?Slot, null), cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, '^'), .role = .tagline, .centered = true })); } test "pane indicator halves share dissolve changes and safe exhausted atlas" { @@ -6040,7 +6337,13 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { layers[0].kind = .column; try std.testing.expect(paneGripCell(&layers, &cells, 6, 0, 0) == null); + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; + defer c.ui_font_free(font); var g: Gui = undefined; + g.font = font; + g.fallback_count = 0; + g.px = 20; + g.tagline_px = 16; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 8; @@ -6050,11 +6353,13 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { g.pen_y = atlas_h; g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator); defer g.glyphs.deinit(); + g.words = .{}; + defer g.words.deinit(std.testing.allocator); for ([_]bool{ false, true }) |old| { var instances: [4]CellInstance = undefined; var next: u32 = 0; for (0..2) |col| { - emitSurfaceCell(&g, null, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, &cells[col], &cells[2], .{ .cell = &cells[0], .offset = @intCast(col) }, old, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, null, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], col, null, &cells[2], .{ .cell = &cells[0], .offset = @intCast(col) }, old, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(f32, 0), instances[col * 2 + 1].u0); try std.testing.expectEqual(old, instances[col * 2 + 1].effect & old_layer_bit != 0); } @@ -6472,8 +6777,9 @@ test "background opacity retains exact endpoints and percentage precision" { } /// PARDES_TEST_LATENCY trace for an external driver, CLOCK_MONOTONIC ns: -/// `R t` when stdin input is read, `F start fence` per captured frame (present -/// entry, and the GPU fence of that frame). Nothing is written without it. +/// `R t` when stdin input is read, `F start fence submit` per captured frame +/// (present entry, the GPU fence of that frame, and its submission: submit - +/// start is the frame's CPU cost). Nothing is written without it. var latency_fd: c_int = -1; var frame_started_ns: u64 = 0; @@ -6513,11 +6819,12 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t c.SDL_DownloadFromGPUTexture(copy, &src, &dst); c.SDL_EndGPUCopyPass(copy); + const submit_ns = monoNs(); const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; defer c.SDL_ReleaseGPUFence(g.device, fence); var fences = [_]*c.SDL_GPUFence{fence}; if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; - latencyNote("F {d} {d}\n", .{ frame_started_ns, monoNs() }); + latencyNote("F {d} {d} {d}\n", .{ frame_started_ns, monoNs(), submit_ns }); const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); @@ -7260,6 +7567,7 @@ fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { test "compact body layer rendering fills recovered rows and clips the final glyph" { var g: Gui = undefined; + g.words = .{}; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 6; @@ -7299,6 +7607,7 @@ test "compact body layer rendering fills recovered rows and clips the final glyp test "compact tag layers fill available width and clip at their physical edge" { var g: Gui = undefined; + g.words = .{}; g.cell_w = 10; g.cell_h = 20; g.tagline_width = 6; diff --git a/src/pardes.zig b/src/pardes.zig index ca2c34a4..f968511a 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3702,7 +3702,7 @@ pub const CellStyle = struct { reverse: bool = false, invisible: bool = false, strikethrough: bool = false, - ul: enum { off, single, double, curly, dotted, dashed } = .off, + ul: enum(u3) { off, single, double, curly, dotted, dashed } = .off, font_role: FontRole = .body, }; |
