diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-10 09:58:31 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-11 09:58:59 -0300 |
| commit | eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c (patch) | |
| tree | cc993ad239451adb6f23645ee5ca001802832ed0 /src/fonts.zig | |
| parent | 38e9919a9ea9055538409b388d580c4e4c838434 (diff) | |
| download | pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.tar.gz pardes-eb11ab331b4e13e2b9e5a673a4c012d22fdd1d9c.zip | |
macos: the AppKit shell, its icon, and the offscreen e2e harness
Diffstat (limited to 'src/fonts.zig')
| -rw-r--r-- | src/fonts.zig | 405 |
1 files changed, 405 insertions, 0 deletions
diff --git a/src/fonts.zig b/src/fonts.zig new file mode 100644 index 00000000..28789e3d --- /dev/null +++ b/src/fonts.zig @@ -0,0 +1,405 @@ +//! The fonts installed on the machine: the list the picker shows, the path a +//! `Font <name>` resolves to, and the one word the two sides of that say to +//! each other. builtins.zig reads this file to build the rows and to resolve a +//! name; gui.zig and macos.zig read it to learn which file to load. It is the +//! whole seam, because the core has no font and the shell has no builtin +//! dispatch. +//! +//! It lives at src/ rather than under gui/ because two shells now draw their +//! own text: builtins.zig imports it behind `platform == .gui or .macos`, so +//! the tty binary compiles not one line of this and 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 and no CoreText. Enumerating fonts is a walk over a handful +//! of 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, and it keeps the answer identical on both platforms: the shells +//! disagree about how to RASTERIZE a file, never about which files there are. +const std = @import("std"); +const builtin = @import("builtin"); +const libc = std.c; + +/// Where each platform keeps fonts. The `home` list is relative to $HOME (a +/// machine with no $HOME simply has neither). ponytail: the unix set is the +/// freedesktop list 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. +/// +/// macOS keeps a third of its faces — Menlo and Courier among them — inside +/// `Supplemental`, which is an ordinary directory the walk would reach anyway; +/// it is named because the depth cap is the only thing that would stop it. +const system_dirs = switch (builtin.os.tag) { + .macos => [_][]const u8{ "/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts" }, + else => [_][]const u8{ "/usr/share/fonts", "/usr/local/share/fonts" }, +}; +const home_dirs = switch (builtin.os.tag) { + .macos => [_][]const u8{"Library/Fonts"}, + else => [_][]const u8{ ".local/share/fonts", ".fonts" }, +}; + +/// The same three safety rails look.find has, for the same reason: this walk +/// runs INSIDE the keystroke that asked for it, so it must end whatever it is +/// pointed at. A font tree is shallow and wide (one directory per family), so +/// the depth cap is lower than find's and the file cap is what a picker can +/// still be read as a list. +const max_fonts = 512; +const max_steps = 20_000; +const max_depth = 8; + +pub const Font = struct { name: []const u8, path: []const u8 }; + +/// The font the shell should be wearing, as a PATH — written by the Font +/// builtin, taken by the shell on its next pass through the loop. Exactly the +/// shape Pardes.restore_req has, including the buffer behind it: the request +/// outlives the scratch arena the walk found the path in. +/// +/// A module var rather than a field on Pardes because the core does not have a +/// font, has no opinion about one, and on every other platform does not have +/// this file either — a field would be state the tty build carries around to +/// never touch. +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. +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 + // single-threaded implementation is the synchronous walk a sans-IO core + // wants, the same one look.find uses. + const io = std.Io.Threaded.global_single_threaded.io(); + 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; + 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) + continue + else + std.fmt.bufPrint(&root_buf, "{s}/{s}", .{ std.mem.trimEnd(u8, home, "/"), home_dirs[i - system_dirs.len] }) catch continue; + var dir = std.Io.Dir.cwd().openDir(io, root, .{ .iterate = true }) catch continue; + defer dir.close(io); + // walkSelectively, not walk: descending is opt-in, which is the only + // way to express the depth cap at all (look.find, same reason) + var w = dir.walkSelectively(arena) catch continue; + defer w.deinit(); + var steps: usize = 0; + while (steps < max_steps and found_len < found.len) { + steps += 1; // an unreadable dir burns a step too, so it cannot spin + const e = (w.next(io) catch continue) orelse break; + if (e.kind == .directory) { + if (e.depth() < max_depth) w.enter(io, e) catch {}; + continue; + } + const ext = std.fs.path.extension(e.basename); + // .ttc is a collection: several cuts of one family in a single + // file, which is how macOS ships Menlo, Courier and a third of + // everything else. The probe below reads face 0 out of one, and + // the shell loading it takes the same face — see tableOffset. + if (!std.ascii.eqlIgnoreCase(ext, ".ttf") and + !std.ascii.eqlIgnoreCase(ext, ".otf") and + !std.ascii.eqlIgnoreCase(ext, ".ttc")) 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 (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 (wanted) |names| { + if (names.len == 1) return arena.dupe(Font, found[0..found_len]) catch &.{}; + if (found_len == names.len) break :roots; + } + } + } + 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 +/// row a different length, every column misaligned, and the mouse pointing at +/// the wrong character. That is why the picker filters rather than listing all +/// nine hundred faces and letting you find out one step into walking them; the +/// list you get is the list you can actually wear. +/// +/// Four reads and no allocation, which is why the walk can afford it per file: +/// the sfnt header and table directory, then `hhea`'s numberOfHMetrics, then +/// the start of `hmtx` — one {advance, lsb} pair per glyph. A font whose first +/// advances all agree is monospace. Zeros are skipped: .notdef and the +/// combining marks legitimately advance nothing, in any font. +/// +/// ponytail: the first 64 metrics, not all of them, so this is one 256-byte +/// read whatever the font's size. Those cover .notdef and the whole of basic +/// latin — the range a terminal is actually worn in — which also (deliberately) +/// keeps the CJK mono faces whose *later* glyphs are double-width, exactly the +/// fonts fontconfig calls "dual-width" and refuses. +fn monospaced(path_z: [*:0]const u8) bool { + const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }); + if (fd < 0) return false; + defer _ = libc.close(fd); + // 12-byte header + one 16-byte record per table; 256 records is far more + // than any real font carries + var head: [12 + 16 * 256]u8 = undefined; + const n = libc.pread(fd, &head, head.len, 0); + if (n < 12) return false; + const hhea = tableOffset(head[0..@intCast(n)], "hhea") orelse return false; + const hmtx = tableOffset(head[0..@intCast(n)], "hmtx") orelse return false; + var hh: [36]u8 = undefined; + if (libc.pread(fd, &hh, hh.len, hhea) != @as(isize, hh.len)) return false; + const metrics = std.mem.readInt(u16, hh[34..36], .big); + const k: usize = @min(@as(usize, metrics), 64); + if (k == 0) return false; + var mx: [64 * 4]u8 = undefined; + if (libc.pread(fd, &mx, k * 4, hmtx) != @as(isize, @intCast(k * 4))) return false; + var ref: u16 = 0; + for (0..k) |i| { + const adv = std.mem.readInt(u16, mx[i * 4 ..][0..2], .big); + if (adv == 0) continue; + if (ref == 0) ref = adv else if (adv != ref) return false; + } + return ref != 0; +} + +/// Where `tag`'s table starts, read out of an sfnt table directory. Called +/// twice per font, which is the only reason it is not inline up there. +fn tableOffset(head: []const u8, tag: *const [4]u8) ?u32 { + const dir = head[sfntBase(head) orelse return null ..]; + if (dir.len < 12) return null; + // 0x00010000 TrueType outlines, "OTTO" CFF ones, "true" the old Apple + // spelling. Anything else — a WOFF, a lie about its extension — is not an + // sfnt face this picker can inspect. + const ver = std.mem.readInt(u32, dir[0..4], .big); + if (ver != 0x00010000 and ver != 0x4F54544F and ver != 0x74727565) return null; + const num = std.mem.readInt(u16, dir[4..6], .big); + var i: usize = 0; + while (i < num and 12 + (i + 1) * 16 <= dir.len) : (i += 1) { + const rec = dir[12 + i * 16 ..][0..16]; + // Table offsets in a collection are from the start of the FILE, not + // from the directory that named them, so this needs no adjusting. + if (std.mem.eql(u8, rec[0..4], tag)) return std.mem.readInt(u32, rec[8..12], .big); + } + return null; +} + +/// Where the sfnt table directory begins. Zero for an ordinary font file; for +/// a `ttcf` collection, the offset of face 0 — the cut the file is named +/// after, and the one a shell asked for this path will load. A collection +/// whose first face lies past what was read has no answer here rather than a +/// guessed one. +fn sfntBase(head: []const u8) ?usize { + if (head.len < 12) return null; + if (!std.mem.eql(u8, head[0..4], "ttcf")) return 0; + if (head.len < 16) return null; + if (std.mem.readInt(u32, head[8..12], .big) == 0) return null; // numFonts + const base = std.mem.readInt(u32, head[12..16], .big); + return if (base + 12 <= head.len) base else null; +} + +/// A scratch font path only this process writes. +/// +/// Not a fixed name: `zig build unit-test` compiles this module into two test +/// binaries and runs them CONCURRENTLY, so a shared path in /tmp is one test +/// truncating the file another is halfway through reading. That surfaced as +/// `monospaced` flatly disagreeing with the bytes it had just been handed, +/// about one run in three, which reads like a bug in the probe and is not one. +fn scratchFont(buf: *[64:0]u8, ext: []const u8) [:0]const u8 { + return std.fmt.bufPrintSentinel(buf, "/tmp/pardes-fonts-test-{d}{s}", .{ + @as(u32, @intCast(libc.getpid())), ext, + }, 0) catch unreachable; +} + +/// Write `bytes` where `monospaced` can read them back. +fn writeScratch(path: [:0]const u8, bytes: []const u8) !void { + const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(c_uint, 0o600)); + try std.testing.expect(fd >= 0); + defer _ = libc.close(fd); + try std.testing.expectEqual(@as(isize, @intCast(bytes.len)), libc.write(fd, bytes.ptr, bytes.len)); +} + +test "monospaced reads the advances out of a real sfnt layout" { + // A whole font in 92 bytes: the header, a two-record table directory, and + // an hhea + hmtx that between them say "three glyphs, all 600 units wide". + // Everything a real .ttf has that this does not (glyf, cmap, name) is + // exactly what the probe never reads, which is the property under test. + var f: [92]u8 = @splat(0); + std.mem.writeInt(u32, f[0..4], 0x00010000, .big); // sfnt version + std.mem.writeInt(u16, f[4..6], 2, .big); // numTables + @memcpy(f[12..16], "hhea"); + std.mem.writeInt(u32, f[20..24], 44, .big); // hhea at 44, 36 bytes long + @memcpy(f[28..32], "hmtx"); + std.mem.writeInt(u32, f[36..40], 80, .big); // hmtx right after it + std.mem.writeInt(u16, f[44 + 34 ..][0..2], 3, .big); // numberOfHMetrics + for (0..3) |i| std.mem.writeInt(u16, f[80 + i * 4 ..][0..2], 600, .big); + + var path_buf: [64:0]u8 = undefined; + const path = scratchFont(&path_buf, ".ttf"); + defer _ = libc.unlink(path); + try writeScratch(path, &f); + try std.testing.expect(monospaced(path)); + + // ...and one glyph a different width is the whole difference between a + // font this can wear and one it cannot + std.mem.writeInt(u16, f[80 + 4 ..][0..2], 1200, .big); + try writeScratch(path, &f); + try std.testing.expect(!monospaced(path)); +} + +test "a ttc collection is read through its first face" { + // The same 92-byte font as above, moved 20 bytes down the file behind a + // `ttcf` header naming two faces. Table offsets stay absolute, which is + // the property that makes this work at all and the one a hand-rolled + // "add the base" would silently break. + const base = 20; + var f: [base + 92]u8 = @splat(0); + @memcpy(f[0..4], "ttcf"); + std.mem.writeInt(u16, f[4..6], 1, .big); // majorVersion + std.mem.writeInt(u32, f[8..12], 2, .big); // numFonts + std.mem.writeInt(u32, f[12..16], base, .big); // face 0 lives here + std.mem.writeInt(u32, f[16..20], base, .big); // face 1, same tables + + const sfnt = f[base..]; + std.mem.writeInt(u32, sfnt[0..4], 0x00010000, .big); + std.mem.writeInt(u16, sfnt[4..6], 2, .big); + @memcpy(sfnt[12..16], "hhea"); + std.mem.writeInt(u32, sfnt[20..24], base + 44, .big); + @memcpy(sfnt[28..32], "hmtx"); + std.mem.writeInt(u32, sfnt[36..40], base + 80, .big); + std.mem.writeInt(u16, sfnt[44 + 34 ..][0..2], 3, .big); + for (0..3) |i| std.mem.writeInt(u16, sfnt[80 + i * 4 ..][0..2], 600, .big); + + var path_buf: [64:0]u8 = undefined; + const path = scratchFont(&path_buf, ".ttc"); + defer _ = libc.unlink(path); + try writeScratch(path, &f); + try std.testing.expect(monospaced(path)); + + // A collection claiming no faces has no first one to read. + std.mem.writeInt(u32, f[8..12], 0, .big); + try writeScratch(path, &f); + try std.testing.expect(!monospaced(path)); +} + +test "the walk finds this machine's monospace faces" { + // Not a fixture: the directory list is the entire platform-specific part + // of this file, and a wrong one produces an empty picker rather than an + // error. So this asserts against the machine — every OS pardes draws its + // own text on ships a monospace face, and finding NONE means the walk is + // looking somewhere that does not exist. + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const found = list(arena_state.allocator(), null); + try std.testing.expect(found.len > 0); + for (found) |font| { + try std.testing.expect(font.name.len > 0); + try std.testing.expect(font.path[0] == '/'); + // The name is the stem, so the file it came from is always longer. + try std.testing.expect(std.fs.path.basename(font.path).len > font.name.len); + } + + // macOS ships Menlo, and ships it inside a .ttc. It is the one face this + // machine can be held to by name, and naming it here is what keeps the + // collection branch honest end to end: drop .ttc from the walk, or read a + // collection's directory at offset 0, and this is what notices. + if (comptime builtin.os.tag == .macos) { + const menlo = for (found) |font| { + if (std.mem.eql(u8, font.name, "Menlo")) break font; + } else return error.MenloMissing; + try std.testing.expect(std.mem.endsWith(u8, menlo.path, ".ttc")); + } +} |
