1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
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"));
}
}
|