summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig33
-rw-r--r--build.zig.zon9
-rw-r--r--docs/design.typ6
-rw-r--r--src/gui/font.c283
-rw-r--r--src/gui/font.h66
-rw-r--r--src/gui/gui.zig565
-rw-r--r--src/pardes.zig2
7 files changed, 783 insertions, 181 deletions
diff --git a/build.zig b/build.zig
index 7010c863..21df1555 100644
--- a/build.zig
+++ b/build.zig
@@ -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,
};