From 4b5e793f48f6e721c39ab6ddcf755604df73b643 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 9 Aug 2026 20:31:09 -0300 Subject: Better text rendering --- src/gui/fonts.zig | 140 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 104 insertions(+), 36 deletions(-) (limited to 'src/gui/fonts.zig') 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); -- cgit v1.3