diff options
Diffstat (limited to 'src/gui')
| -rw-r--r-- | src/gui/font.c | 175 | ||||
| -rw-r--r-- | src/gui/font.h | 41 | ||||
| -rw-r--r-- | src/gui/fonts.zig | 140 | ||||
| -rw-r--r-- | src/gui/gui.zig | 141 |
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; |
