summaryrefslogtreecommitdiff
path: root/src/gui/fonts.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 19:02:04 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commit005178177a1ddb2eb2f1af5fad36e2717758ee8e (patch)
tree6e974c12def02fd855446d84db25232c1f3b4fd2 /src/gui/fonts.zig
parent41847fce244fdd0d01971c302b89d1252f6497ed (diff)
downloadpardes-005178177a1ddb2eb2f1af5fad36e2717758ee8e.tar.gz
pardes-005178177a1ddb2eb2f1af5fad36e2717758ee8e.zip
the gui wears any monospace font on the machine
Diffstat (limited to 'src/gui/fonts.zig')
-rw-r--r--src/gui/fonts.zig221
1 files changed, 221 insertions, 0 deletions
diff --git a/src/gui/fonts.zig b/src/gui/fonts.zig
new file mode 100644
index 00000000..e21f4674
--- /dev/null
+++ b/src/gui/fonts.zig
@@ -0,0 +1,221 @@
+//! 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 reads 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 under gui/ rather than at src/ — where the core lies flat —
+//! because it only exists in a GUI build: builtins.zig imports it behind
+//! `platform == .gui`, 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, 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.
+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.
+const system_dirs = [_][]const u8{ "/usr/share/fonts", "/usr/local/share/fonts" };
+const home_dirs = [_][]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;
+
+/// 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 {
+ var out: std.ArrayList(Font) = .empty;
+ // 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;
+ 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 out.items.len < max_fonts) {
+ 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);
+ if (!std.ascii.eqlIgnoreCase(ext, ".ttf") and !std.ascii.eqlIgnoreCase(ext, ".otf")) continue;
+ const name = e.basename[0 .. e.basename.len - ext.len];
+ // 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;
+ out.append(arena, .{
+ .name = arena.dupe(u8, name) catch break,
+ .path = arena.dupe(u8, path) catch break,
+ }) catch break;
+ if (want_name != null) return out.items; // asked for one, found it
+ }
+ }
+ // 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, out.items, {}, struct {
+ fn lt(_: void, a: Font, b: Font) bool {
+ return std.mem.lessThan(u8, a.name, b.name);
+ }
+ }.lt);
+ return out.items;
+}
+
+/// 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 {
+ 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.
+ 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);
+ var i: usize = 0;
+ while (i < num and 12 + (i + 1) * 16 <= head.len) : (i += 1) {
+ const rec = head[12 + i * 16 ..][0..16];
+ if (std.mem.eql(u8, rec[0..4], tag)) return std.mem.readInt(u32, rec[8..12], .big);
+ }
+ return null;
+}
+
+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);
+
+ const path = "/tmp/pardes-fonts-test.ttf";
+ {
+ const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(c_uint, 0o644));
+ try std.testing.expect(fd >= 0);
+ defer _ = libc.close(fd);
+ try std.testing.expectEqual(@as(isize, f.len), libc.write(fd, &f, f.len));
+ }
+ 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);
+ {
+ const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(c_uint, 0o644));
+ try std.testing.expect(fd >= 0);
+ defer _ = libc.close(fd);
+ try std.testing.expectEqual(@as(isize, f.len), libc.write(fd, &f, f.len));
+ }
+ try std.testing.expect(!monospaced(path));
+}