From 005178177a1ddb2eb2f1af5fad36e2717758ee8e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 31 Jul 2026 19:02:04 -0300 Subject: the gui wears any monospace font on the machine --- src/gui/fonts.zig | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/gui/gui.zig | 99 +++++++++++++++++++++++- 2 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 src/gui/fonts.zig (limited to 'src/gui') 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 ` 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)); +} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 6280ab00..66812575 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -22,6 +22,7 @@ const config = @import("../config.zig"); const look = @import("../look.zig"); const deck = @import("deck.zig"); const crt = @import("crt.zig"); +const fonts = @import("fonts.zig"); // the Font builtin's half of the seam const is_emscripten = builtin.os.tag == .emscripten; @@ -780,6 +781,15 @@ const Gui = struct { overlay_vertices: []OverlayVertex, font: *c.UIFont, + /// the file behind `font`, when it is one the Font builtin loaded. Empty + /// for the font the binary ships with, which is @embedFile'd and not ours + /// to free — stb keeps a pointer into these bytes, so they outlive nothing. + font_bytes: []u8 = &.{}, + /// the cell height the metrics are asked for, in pixels. A field and not + /// the local constant it used to be because refitFont reads it: changing + /// the FACE has to re-ask at the same size, and changing the SIZE (the + /// Ctrl+/Ctrl- this leaves the path for) is writing here and calling that. + px: f32, scale: f32, cell_w: u32, cell_h: u32, @@ -1027,6 +1037,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { .overlay_vxfer = overlay_vxfer, .overlay_vertices = overlay_vertices, .font = font, + .px = px, .scale = scale, .cell_w = cell_w, .cell_h = cell_h, @@ -1037,6 +1048,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { .capture_dir = capture_dir orelse "", }; defer g.glyphs.deinit(); + defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran defer gpa.free(g.scroll_edge); // grown on demand by stepScroll // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); @@ -1179,12 +1191,35 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { core.deinit(); core = nc; } + // Font builtin: the core resolved a name to a path and asked for it — + // it cannot load a font itself, having no rasterizer, no atlas and no + // window. Inline here beside Restore because it is the same kind of + // thing and this is the flat loop. + if (fonts.want) |path| blk: { + fonts.want = null; + const bytes = look.readFile(gpa, path) catch break :blk; + const nf = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse { + // stb turned it down (an .otf whose outlines it cannot read). + // Keep wearing the one that works: a font pardes cannot + // rasterize is a blank window with no way back out of it. + log.err("ui_font_new failed: {s}", .{path}); + gpa.free(bytes); + break :blk; + }; + c.ui_font_free(g.font); + if (g.font_bytes.len != 0) gpa.free(g.font_bytes); + g.font = nf; + g.font_bytes = bytes; // stb reads them for as long as the font lives + refitFont(&g, core); + } // 5. live cwd for tags/look: cheap /proc readlink per pane, per frame pollCwds(core, &ptys); - // 6. the grid follows the window (covers WINDOW_RESIZED and test resizes) + // 6. the grid follows the window (covers WINDOW_RESIZED and test + // resizes). Off g.cell_w/h, not the init locals: a font change moves + // them, and this is the line that would go on dividing by the old cell. _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); - const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); - const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); + const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); + const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); if (cols != core.screen_w or rows != core.screen_h) core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); // 7. render — the scroll animation steps LAST before it, while @@ -1417,6 +1452,7 @@ fn runWeb(opts_in: pardes.Options) !void { .gl_overlay_vbo = overlay_vbo, .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"), .font = font, + .px = px, .scale = scale, .cell_w = cell_w, .cell_h = cell_h, @@ -2794,6 +2830,63 @@ fn firstCp(s: []const u8) u32 { return std.unicode.utf8Decode(s[0..n]) catch ' '; } +/// Re-measure the cell, throw the glyph atlas away, and re-fit the grid to the +/// window. THE path for any change to what a cell LOOKS like: point g.font at +/// a different face (the Font builtin, above) or write a different g.px (the +/// Ctrl+/Ctrl- sizing this leaves the road open for) and call this — those are +/// one line each, and everything that has to follow from them is here. +/// +/// The atlas is the part that must not be skipped, and the reason the whole +/// thing is a function rather than three lines at a call site. It is keyed by +/// CODEPOINT ALONE — one face, one size, a pen walking rows of cell_w×cell_h +/// slots — so after a change every slot in it holds the wrong picture at the +/// wrong metrics, and every codepoint already in the map would keep being +/// drawn from that slot forever, because ensureGlyph's first line is a cache +/// hit. Clearing the map, zeroing the staging bitmap and rewinding the pen put +/// it back to exactly what init built, and ensureGlyph refills it as the next +/// frame draws. The zeroing is not tidiness: the upload is the WHOLE texture, +/// the new cell size is a different grid over the same 2048², and a leftover +/// bitmap no slot points at any more would still be sampled by whatever new +/// slot overlaps it. +fn refitFont(g: *Gui, core: *pardes.Pardes) void { + // Native rasters at the size it measures. Web MEASURES at the CSS size and + // RASTERS web_render_scale bigger, so text stays sharp on a scaled canvas + // and cellLayout scales the quads back down — the same two scales initGl + // takes, asked again because a different face answers differently. + const measure = c.ui_font_scale_for_height(g.font, g.px); + g.scale = if (is_emscripten) c.ui_font_scale_for_height(g.font, g.px * web_render_scale) else measure; + var cw: c_int = 10; + var chh: c_int = 20; + var asc: c_int = 16; + c.ui_font_cell_metrics(g.font, measure, &cw, &chh, &asc); + const grow: f32 = if (is_emscripten) web_render_scale else 1.0; + g.cell_w = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * grow)); + g.cell_h = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * grow)); + g.ascent = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * grow)); + + g.glyphs.clearRetainingCapacity(); + @memset(g.atlas_stage, 0); + // slot (0,0) is the space glyph, exactly as init lays it out + _ = c.ui_font_raster(g.font, g.scale, ' ', g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); + g.space_slot = .{ .u = 0, .v = 0 }; + g.pen_x = g.cell_w; + g.pen_y = 0; + g.atlas_dirty = true; + + // ...and the grid: the same window is a different number of cells now. The + // shells re-derive this every frame anyway, so this is only the frame the + // change happens on — but it is the frame the surface is about to be + // rendered for, and a stale screen_w here is a row of cells drawn off the + // right edge of the window. + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); + const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); + if (cols != core.screen_w or rows != core.screen_h) + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); +} + fn ensureGlyph(g: *Gui, cp: u32) Slot { if (g.glyphs.get(cp)) |s| return s; if (g.pen_x + g.cell_w > atlas_w) { -- cgit v1.3