summaryrefslogtreecommitdiff
path: root/src/gui/fonts.zig
blob: e21f4674ea0f02b01c6ba8bbb41f8a80b2b70db3 (plain) (blame)
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
//! 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));
}