diff options
Diffstat (limited to 'src/gui')
| -rw-r--r-- | src/gui/font.c | 283 | ||||
| -rw-r--r-- | src/gui/font.h | 66 | ||||
| -rw-r--r-- | src/gui/gui.zig | 565 |
3 files changed, 736 insertions, 178 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]; + } } 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; |
