summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 20:31:09 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit4b5e793f48f6e721c39ab6ddcf755604df73b643 (patch)
treef3cf8b009bbfbd5667635381f0f624cd6b466084 /src/gui
parent9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a (diff)
downloadpardes-4b5e793f48f6e721c39ab6ddcf755604df73b643.tar.gz
pardes-4b5e793f48f6e721c39ab6ddcf755604df73b643.zip
Better text rendering
Diffstat (limited to 'src/gui')
-rw-r--r--src/gui/font.c175
-rw-r--r--src/gui/font.h41
-rw-r--r--src/gui/fonts.zig140
-rw-r--r--src/gui/gui.zig141
4 files changed, 452 insertions, 45 deletions
diff --git a/src/gui/font.c b/src/gui/font.c
new file mode 100644
index 00000000..e94480b5
--- /dev/null
+++ b/src/gui/font.c
@@ -0,0 +1,175 @@
+// 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.
+#include "font.h"
+#include <freetype-zig.h>
+
+#include <stdlib.h>
+#include <string.h>
+
+struct UIFont {
+ FT_Library library;
+ FT_Face face;
+ FT_F26Dot6 size;
+};
+
+static FT_F26Dot6 size_26_6(float px) {
+ if (px < 1.0f)
+ px = 1.0f;
+ return (FT_F26Dot6)(px * 64.0f + 0.5f);
+}
+
+static int set_size(UIFont *f, float px) {
+ const FT_F26Dot6 size = size_26_6(px);
+ if (f->size == size)
+ return 1;
+ if (FT_Set_Char_Size(f->face, 0, size, 72, 72) != 0)
+ return 0;
+ f->size = size;
+ return 1;
+}
+
+static int ceil_26_6(FT_Pos value) { return (int)((value + 63) >> 6); }
+
+static FT_Int32 load_flags(void) {
+ // Hinting is on because FT_LOAD_NO_HINTING is absent. Likewise, leaving
+ // FT_LOAD_NO_AUTOHINT absent lets FreeType fall back to its auto-hinter
+ // when the face has no native instructions. This is Ghostty's default.
+ return FT_LOAD_DEFAULT | FT_LOAD_TARGET_LIGHT;
+}
+
+UIFont *ui_font_new(const uint8_t *data, int32_t len) {
+ if (!data || len <= 0)
+ return NULL;
+
+ UIFont *f = (UIFont *)calloc(1, sizeof(UIFont));
+ if (!f)
+ return NULL;
+ if (FT_Init_FreeType(&f->library) != 0) {
+ free(f);
+ return NULL;
+ }
+ if (FT_New_Memory_Face(f->library, data, (FT_Long)len, 0, &f->face) != 0) {
+ FT_Done_FreeType(f->library);
+ free(f);
+ return NULL;
+ }
+ // 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);
+ return f;
+}
+
+int ui_font_has_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;
+}
+
+void ui_font_free(UIFont *f) {
+ if (!f)
+ return;
+ if (f->face)
+ FT_Done_Face(f->face);
+ if (f->library)
+ FT_Done_FreeType(f->library);
+ 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)) {
+ if (cell_w)
+ *cell_w = 1;
+ if (cell_h)
+ *cell_h = 1;
+ if (ascent)
+ *ascent = 1;
+ return;
+ }
+
+ const FT_Size_Metrics metrics = f->face->size->metrics;
+ const int asc = ceil_26_6(metrics.ascender);
+ const int desc = ceil_26_6(-metrics.descender);
+ int height = ceil_26_6(metrics.height);
+ if (height < asc + desc)
+ height = asc + desc;
+
+ int width = ceil_26_6(metrics.max_advance);
+ const FT_UInt m = FT_Get_Char_Index(f->face, 'M');
+ if (FT_Load_Glyph(f->face, m, load_flags()) == 0) {
+ const int advance = ceil_26_6(f->face->glyph->advance.x);
+ if (advance > 0)
+ width = advance;
+ }
+
+ *cell_w = width > 0 ? width : 1;
+ *cell_h = height > 0 ? height : 1;
+ *ascent = asc > 0 ? asc : 1;
+}
+
+int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out,
+ int32_t stride, int32_t cell_w, int32_t cell_h,
+ int32_t ascent) {
+ if (!f || !out || stride < cell_w || cell_w <= 0 || cell_h <= 0)
+ return 0;
+ for (int y = 0; y < cell_h; y++)
+ memset(out + (size_t)y * (size_t)stride, 0, (size_t)cell_w);
+ 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 (FT_Render_Glyph(f->face->glyph, FT_RENDER_MODE_NORMAL) != 0)
+ return 0;
+
+ const FT_GlyphSlot glyph = f->face->glyph;
+ const FT_Bitmap *bitmap = &glyph->bitmap;
+ if (!bitmap->buffer || bitmap->width == 0 || bitmap->rows == 0)
+ return 0;
+
+ 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 = glyph->bitmap_left + (cell_w - advance) / 2;
+ const int gy = 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)
+ 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)
+ continue;
+
+ uint8_t coverage;
+ if (bitmap->pixel_mode == FT_PIXEL_MODE_MONO) {
+ coverage = (src[x >> 3] & (0x80u >> (x & 7))) ? 255 : 0;
+ } else if (bitmap->pixel_mode == FT_PIXEL_MODE_GRAY) {
+ coverage = src[x];
+ if (bitmap->num_grays > 1 && bitmap->num_grays != 256)
+ coverage = (uint8_t)((unsigned int)coverage * 255u /
+ (bitmap->num_grays - 1u));
+ } else {
+ continue;
+ }
+ out[(size_t)dy * (size_t)stride + (size_t)dx] = coverage;
+ }
+ }
+ return 1;
+}
diff --git a/src/gui/font.h b/src/gui/font.h
new file mode 100644
index 00000000..050fbeb0
--- /dev/null
+++ b/src/gui/font.h
@@ -0,0 +1,41 @@
+// 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.
+#ifndef UI_FONT_H
+#define UI_FONT_H
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct UIFont UIFont;
+
+// Parse the first face in an in-memory font. `data` must stay alive for the
+// lifetime of the font. Returns NULL on failure.
+UIFont *ui_font_new(const uint8_t *data, int32_t len);
+
+// 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 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);
+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.
+// Returns 1 when the glyph produced coverage, 0 for blank/error.
+int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out,
+ int32_t stride, int32_t cell_w, int32_t cell_h,
+ int32_t ascent);
+
+void ui_font_free(UIFont *f);
+
+#ifdef __cplusplus
+}
+#endif
+#endif
diff --git a/src/gui/fonts.zig b/src/gui/fonts.zig
index 1ad7dd84..54210136 100644
--- a/src/gui/fonts.zig
+++ b/src/gui/fonts.zig
@@ -10,18 +10,18 @@
//! never opens a font directory, and the browser (which has no font
//! directories to open) is out for a better reason than taste.
//!
-//! No fontconfig, no freetype. Enumerating fonts on a unix box is a walk over
-//! four well-known directories, and the one thing a terminal must know about a
-//! file it finds there — is every glyph the same width — is four small reads
-//! into the file itself. `monospaced` below is the whole font library.
+//! No fontconfig. Enumerating fonts on a Unix box is a walk over four
+//! well-known directories, and the one thing the picker must know about each
+//! face — whether every glyph has the same advance — is four small sfnt reads.
+//! That avoids initializing a FreeType face for every file in the directory.
const std = @import("std");
const libc = std.c;
/// Where a unix box keeps fonts. The last two are relative to $HOME (a machine
/// with no $HOME simply has neither). ponytail: this is the freedesktop list
-/// minus /usr/share/X11/fonts, which holds the bitmap console fonts stb
-/// cannot rasterize anyway; XDG_DATA_DIRS would be the general answer and is
-/// two lines here the day someone keeps fonts somewhere else.
+/// minus /usr/share/X11/fonts, whose legacy bitmap formats are outside this
+/// TTF/OTF picker; XDG_DATA_DIRS is the general answer if another font root
+/// becomes common.
const system_dirs = [_][]const u8{ "/usr/share/fonts", "/usr/local/share/fonts" };
const home_dirs = [_][]const u8{ ".local/share/fonts", ".fonts" };
@@ -48,23 +48,40 @@ pub const Font = struct { name: []const u8, path: []const u8 };
pub var want_buf: [4096]u8 = undefined;
pub var want: ?[]const u8 = null;
+/// Optional fallback faces, in preference order after the always-available
+/// embedded Adwaita Mono face. Keep text faces before symbol faces so ordinary
+/// letters retain terminal-like metrics; DejaVu Sans is the final broad,
+/// proportional safety net.
+pub const fallback_names = [_][]const u8{
+ "NotoSansMono-Regular",
+ "DejaVuSansMono",
+ "SymbolsNerdFont-Regular",
+ "NotoSansSymbols2-Regular",
+ "NotoSansSymbols-Regular",
+ "DejaVuSans",
+};
+
/// Every monospace font installed, `{name, path}`, arena-owned and sorted by
/// name — or, when `want_name` is given, just the one that answers to it.
-///
-/// The two callers are the two halves of the feature, so it is one walk with
-/// one difference: asking for a font BY NAME skips the monospace test, because
-/// the question that test exists to answer is the one you just answered
-/// yourself, and it stops at the first hit instead of reading every file.
-///
-/// ponytail: a font's NAME here is its FILE STEM ("DejaVuSansMono-Bold"), not
-/// the pretty name in its TTF `name` table ("DejaVu Sans Mono Bold"). The stem
-/// is unique per file, is what the file is called everywhere else, and is one
-/// word — so a middle-click on a picker row hits the whole of it. The ceiling
-/// is a font whose filename is not its name (a hashed download, a `.otf` from
-/// a foundry); reading the real one means a `ui_font_name` beside the other
-/// four in vendor/stb/font.c plus a UTF-16BE decode, and this file is where it
-/// would land.
pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font {
+ if (want_name) |name| {
+ const wanted = [1][]const u8{name};
+ return scan(arena, &wanted);
+ }
+ return scan(arena, null);
+}
+
+/// Installed members of `fallback_names`, returned in preference order. This
+/// is runtime discovery only: no local font path or exploration result enters
+/// the build, and a machine with none simply uses embedded Adwaita Mono.
+pub fn fallbacks(arena: std.mem.Allocator) []const Font {
+ return scan(arena, &fallback_names);
+}
+
+/// One bounded directory walk for the picker, one named font, or the fallback
+/// chain. `wanted == null` means every monospace face; named scans accept
+/// proportional symbol/general faces and preserve the requested priority.
+fn scan(arena: std.mem.Allocator, wanted: ?[]const []const u8) []const Font {
var found: [max_fonts]Font = undefined;
var found_len: usize = 0;
// Zig 0.16 moved the filesystem behind std.Io; the blocking
@@ -74,7 +91,7 @@ pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font {
const home: []const u8 = if (libc.getenv("HOME")) |h| std.mem.span(h) else "";
var root_buf: [512]u8 = undefined;
var path_buf: [4096]u8 = undefined;
- for (0..system_dirs.len + home_dirs.len) |i| {
+ roots: for (0..system_dirs.len + home_dirs.len) |i| {
const root: []const u8 = if (i < system_dirs.len)
system_dirs[i]
else if (home.len == 0)
@@ -98,30 +115,81 @@ pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font {
const ext = std.fs.path.extension(e.basename);
if (!std.ascii.eqlIgnoreCase(ext, ".ttf") and !std.ascii.eqlIgnoreCase(ext, ".otf")) continue;
const name = e.basename[0 .. e.basename.len - ext.len];
+ if (wanted) |names| {
+ var matches = false;
+ for (names) |candidate| {
+ if (std.mem.eql(u8, candidate, name)) {
+ matches = true;
+ break;
+ }
+ }
+ if (!matches) continue;
+ for (found[0..found_len]) |prior| {
+ if (std.mem.eql(u8, prior.name, name)) {
+ matches = false;
+ break;
+ }
+ }
+ if (!matches) continue;
+ }
// e.path points into the walker's own buffer and dies at the next
// next(), so the path is spelled out here and copied below
const path = std.fmt.bufPrintSentinel(&path_buf, "{s}/{s}", .{ root, e.path }, 0) catch continue;
- if (want_name) |wn| {
- if (!std.mem.eql(u8, wn, name)) continue;
- } else if (!monospaced(path)) continue;
+ if (wanted == null and !monospaced(path)) continue;
found[found_len] = .{
.name = arena.dupe(u8, name) catch break,
.path = arena.dupe(u8, path) catch break,
};
found_len += 1;
- if (want_name != null) return arena.dupe(Font, found[0..found_len]) catch &.{}; // asked for one, found it
+ if (wanted) |names| {
+ if (names.len == 1) return arena.dupe(Font, found[0..found_len]) catch &.{};
+ if (found_len == names.len) break :roots;
+ }
}
}
- // readdir order is undefined; sort so the picker is the same list twice
- // running and n/N walks a font's own variants in a row
- std.mem.sort(Font, found[0..found_len], {}, struct {
- fn lt(_: void, a: Font, b: Font) bool {
- return std.mem.lessThan(u8, a.name, b.name);
- }
- }.lt);
+ if (wanted) |names| {
+ std.mem.sort(Font, found[0..found_len], names, struct {
+ fn rank(order: []const []const u8, name: []const u8) usize {
+ for (order, 0..) |candidate, i|
+ if (std.mem.eql(u8, candidate, name)) return i;
+ return order.len;
+ }
+ fn lt(order: []const []const u8, a: Font, b: Font) bool {
+ return rank(order, a.name) < rank(order, b.name);
+ }
+ }.lt);
+ } else {
+ // readdir order is undefined; sort so the picker is the same list twice
+ // running and n/N walks a font's own variants in a row
+ std.mem.sort(Font, found[0..found_len], {}, struct {
+ fn lt(_: void, a: Font, b: Font) bool {
+ return std.mem.lessThan(u8, a.name, b.name);
+ }
+ }.lt);
+ }
return arena.dupe(Font, found[0..found_len]) catch &.{};
}
+test "fallback discovery is unique and preserves candidate priority" {
+ for (fallback_names, 0..) |name, i| {
+ try std.testing.expect(name.len != 0);
+ for (fallback_names[0..i]) |prior|
+ try std.testing.expect(!std.mem.eql(u8, prior, name));
+ }
+
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ const installed = fallbacks(arena_state.allocator());
+ var previous: ?usize = null;
+ for (installed) |font| {
+ const rank = for (fallback_names, 0..) |name, i| {
+ if (std.mem.eql(u8, name, font.name)) break i;
+ } else return error.UnknownFallback;
+ if (previous) |p| try std.testing.expect(rank > p);
+ previous = rank;
+ }
+}
+
/// Is every glyph in this font the same width? The terminal grid IS a
/// monospace cell — one advance for every column, chosen once from 'M' — so a
/// proportional font does not render badly in it, it renders as rubble: every
@@ -172,9 +240,9 @@ fn monospaced(path_z: [*:0]const u8) bool {
/// twice per font, which is the only reason it is not inline up there.
fn tableOffset(head: []const u8, tag: *const [4]u8) ?u32 {
if (head.len < 12) return null;
- // 0x00010000 truetype outlines, "OTTO" CFF ones, "true" the old apple
- // spelling. Anything else — a .ttc collection, a woff, a lie about its
- // extension — is not a font this can read, and not one stb would take.
+ // 0x00010000 TrueType outlines, "OTTO" CFF ones, "true" the old Apple
+ // spelling. Anything else — a .ttc collection, a WOFF, a lie about its
+ // extension — is not an sfnt face this picker can inspect.
const ver = std.mem.readInt(u32, head[0..4], .big);
if (ver != 0x00010000 and ver != 0x4F54544F and ver != 0x74727565) return null;
const num = std.mem.readInt(u16, head[4..6], .big);
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 951564f0..7a9c2135 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1,8 +1,8 @@
//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input
//! into core events, performs the core's effects (fork ptys, write them,
//! resize them — same duties as tty.zig, this is also native), and
-//! rasterizes the core's Surface: one instanced quad per cell, glyphs from an
-//! stb_truetype R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL,
+//! rasterizes the core's Surface: one instanced quad per cell, glyphs from a
+//! FreeType-hinted R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL,
//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the
//! real renderer, drives input from stdin, and captures frames to PPM.
//!
@@ -104,6 +104,12 @@ const web_render_scale: f32 = 1.5;
const atlas_w: u32 = 2048;
const atlas_h: u32 = 2048;
const Slot = struct { u: u32, v: u32 };
+const max_fallback_fonts = 1 + fonts.fallback_names.len;
+const LoadedFallback = struct {
+ face: *c.UIFont,
+ /// Empty for embedded Adwaita; otherwise the owned bytes FreeType borrows.
+ bytes: []u8 = &.{},
+};
const max_touch_points: usize = 16;
const max_touch_trail_points: usize = 8;
@@ -192,6 +198,68 @@ test "every font size step moves the cell, and the ends are reachable exactly" {
try std.testing.expectEqual(font_px_min, std.math.clamp(font_px_min - font_px_step, font_px_min, font_px_max));
}
+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 size = c.ui_font_scale_for_height(font, 13.0);
+ var cell_w: c_int = 0;
+ var cell_h: c_int = 0;
+ var ascent: c_int = 0;
+ c.ui_font_cell_metrics(font, size, &cell_w, &cell_h, &ascent);
+ try std.testing.expect(cell_w > 0 and cell_w <= 128);
+ try std.testing.expect(cell_h > 0 and cell_h <= 128);
+
+ const stride: c_int = 128;
+ var bitmap: [128 * 128]u8 = @splat(0xaa);
+ try std.testing.expectEqual(
+ @as(c_int, 1),
+ c.ui_font_raster(font, size, 'H', &bitmap, stride, cell_w, cell_h, ascent),
+ );
+
+ var ink: usize = 0;
+ var soft: usize = 0;
+ for (0..@intCast(cell_h)) |y| {
+ for (0..@intCast(cell_w)) |x| {
+ const coverage = bitmap[y * @as(usize, @intCast(stride)) + x];
+ if (coverage > 0) ink += 1;
+ if (coverage > 0 and coverage < 255) soft += 1;
+ }
+ }
+ try std.testing.expect(ink > 0);
+ try std.testing.expect(soft > 0);
+
+ try std.testing.expectEqual(
+ @as(c_int, 0),
+ c.ui_font_raster(font, size, ' ', &bitmap, stride, cell_w, cell_h, ascent),
+ );
+ for (0..@intCast(cell_h)) |y|
+ for (0..@intCast(cell_w)) |x|
+ try std.testing.expectEqual(
+ @as(u8, 0),
+ bitmap[y * @as(usize, @intCast(stride)) + x],
+ );
+}
+
+test "installed Nerd Symbols fallback covers Yazi directory icons" {
+ try std.testing.expectEqualStrings("SymbolsNerdFont-Regular", fonts.fallback_names[2]);
+
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ const candidate = for (fonts.fallbacks(arena_state.allocator())) |font| {
+ if (std.mem.eql(u8, font.name, "SymbolsNerdFont-Regular")) break font;
+ } else return;
+
+ const bytes = try look.readFile(std.testing.allocator, candidate.path);
+ 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);
+ // Yazi's default directory icon ``.
+ try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(face, 0xe5ff));
+}
+
// One instance per cell; the vertex shader expands it to a 2-triangle quad
// with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1.
const CellInstance = extern struct {
@@ -1016,8 +1084,13 @@ const Gui = struct {
font: *c.UIFont,
/// the file behind `font`, when it is one the Font builtin loaded. Empty
/// for the font the binary ships with, which is @embedFile'd and not ours
- /// to free — stb keeps a pointer into these bytes, so they outlive nothing.
+ /// to free — FreeType borrows these bytes for the face lifetime.
font_bytes: []u8 = &.{},
+ /// Faces are discovered and opened once at startup. `glyphs` below caches
+ /// the raster result by codepoint, so fallback probing happens once per
+ /// glyph/atlas epoch rather than once per cell or frame.
+ fallbacks: [max_fallback_fonts]?LoadedFallback = @splat(null),
+ fallback_count: usize = 0,
/// the cell height the metrics are asked for, in pixels. A field and not
/// the local constant it used to be because refitFont reads it: changing
/// the FACE has to re-ask at the same size, and changing the SIZE (the
@@ -1028,7 +1101,9 @@ const Gui = struct {
cell_h: u32,
ascent: i32,
- // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc
+ // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc.
+ // The slot is also the fallback-resolution cache: after first rasterization
+ // every cell/frame takes the hash hit without probing any face again.
atlas_stage: []u8,
glyphs: std.AutoHashMap(u32, Slot),
pen_x: u32 = 0,
@@ -1095,6 +1170,50 @@ const Gui = struct {
gl_instances: []CellInstance = &.{},
};
+fn loadFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void {
+ // The shipped face is the first fallback whenever Font selects a narrower
+ // user face. A second FT_Face is cheap and keeps both lifetimes independent.
+ if (c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len))) |face| {
+ g.fallbacks[0] = .{ .face = face };
+ g.fallback_count = 1;
+ }
+
+ var arena_state = std.heap.ArenaAllocator.init(gpa);
+ defer arena_state.deinit();
+ for (fonts.fallbacks(arena_state.allocator())) |candidate| {
+ if (g.fallback_count == g.fallbacks.len) break;
+ const bytes = look.readFile(gpa, candidate.path) catch continue;
+ const len = std.math.cast(c_int, bytes.len) orelse {
+ gpa.free(bytes);
+ continue;
+ };
+ const face = c.ui_font_new(bytes.ptr, len) orelse {
+ gpa.free(bytes);
+ continue;
+ };
+ g.fallbacks[g.fallback_count] = .{ .face = face, .bytes = bytes };
+ g.fallback_count += 1;
+ }
+}
+
+fn deinitFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void {
+ for (g.fallbacks[0..g.fallback_count]) |loaded| {
+ const fallback = loaded.?;
+ c.ui_font_free(fallback.face);
+ if (fallback.bytes.len != 0) gpa.free(fallback.bytes);
+ }
+}
+
+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;
+ }
+ // Preserve FreeType's useful .notdef box when no face has the codepoint.
+ return g.font;
+}
+
/// A cell's on-screen rect. Native: exactly cell_w×cell_h at (0,0). Web: the
/// canvas resize and the grid resize land on different frames, so the grid is
/// scaled to fit and centered (letterboxed) instead of clipping.
@@ -1297,6 +1416,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.capture = test_mode,
.capture_dir = capture_dir orelse "",
};
+ loadFallbackFonts(&g, gpa);
+ defer deinitFallbackFonts(&g, gpa);
defer g.glyphs.deinit();
defer {
clearNativeImages(&g);
@@ -1306,6 +1427,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf);
defer c.SDL_ReleaseGPUGraphicsPipeline(device, image_pipeline);
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
// slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only)
_ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc);
@@ -1505,9 +1627,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
fonts.want = null;
const bytes = look.readFile(gpa, path) catch break :blk;
const nf = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse {
- // stb turned it down (an .otf whose outlines it cannot read).
- // Keep wearing the one that works: a font pardes cannot
- // rasterize is a blank window with no way back out of it.
+ // FreeType turned it down. Keep wearing the one that works: a
+ // font pardes cannot rasterize is a blank window with no way
+ // back out of it.
log.err("ui_font_new failed: {s}", .{path});
gpa.free(bytes);
break :blk;
@@ -1515,7 +1637,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
c.ui_font_free(g.font);
if (g.font_bytes.len != 0) gpa.free(g.font_bytes);
g.font = nf;
- g.font_bytes = bytes; // stb reads them for as long as the font lives
+ g.font_bytes = bytes; // FreeType borrows them for the face lifetime
refitFont(&g, core);
}
// 5. live cwd for tags/look: cheap /proc readlink per pane, per frame
@@ -3728,7 +3850,8 @@ fn ensureGlyph(g: *Gui, cp: u32) Slot {
if (g.pen_y + g.cell_h > atlas_h) return g.space_slot; // 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;
- _ = c.ui_font_raster(g.font, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent);
+ const face = fontForCodepoint(g, cp);
+ _ = c.ui_font_raster(face, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent);
g.glyphs.put(cp, s) catch return g.space_slot;
g.pen_x += g.cell_w;
g.atlas_dirty = true;