summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-11 15:58:58 -0300
committerGabriel Schneider <[email protected]>2026-08-11 16:26:20 -0300
commit4ca28745d774c232cd31a29c17878f19bbe24cf5 (patch)
treeface852acae5bc347e6bab2bb5cede501e0ce1d3 /src/gui
parentdedfdea43f0d6c7151c541284c81027969d89032 (diff)
parent89d93d5e7348304bc7d8a148f9ad9c1beb200459 (diff)
downloadpardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.tar.gz
pardes-4ca28745d774c232cd31a29c17878f19bbe24cf5.zip
merge the macOS app branch: the AppKit shell, pixel attachments, live theming, and mupdf -Djpx
Three commits off 38e9919 (macos-app@upstream) merged into main's ghostty bump. No textual conflicts, and two things the merge needed: - nested.zig asked libc for fstatat. Darwin has it; on linux std.c declares it `void` (glibc hides it behind a versioned symbol std cannot name), so the tty build stopped at 'type void not a function'. statNoFollow keeps fstatat on darwin and asks statx on linux for the same three fields, which is what this file did before the branch generalized it to both platforms. - .DS_Store rode along with a797a1a. Deleted, and .gitignore now says so. linux: snap 86/86, unit-test, image-harness and mupdf-check green. nested.zig also type-checks for aarch64-macos.
Diffstat (limited to 'src/gui')
-rw-r--r--src/gui/fonts.zig291
-rw-r--r--src/gui/gui.zig2
2 files changed, 1 insertions, 292 deletions
diff --git a/src/gui/fonts.zig b/src/gui/fonts.zig
deleted file mode 100644
index 54210136..00000000
--- a/src/gui/fonts.zig
+++ /dev/null
@@ -1,291 +0,0 @@
-//! 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. Enumerating fonts on a Unix box is a walk over four
-//! 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.
-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, whose legacy bitmap formats are outside this
-/// TTF/OTF picker; XDG_DATA_DIRS is the general answer if another font root
-/// becomes common.
-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;
-
-/// 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);
- if (!std.ascii.eqlIgnoreCase(ext, ".ttf") and !std.ascii.eqlIgnoreCase(ext, ".otf")) 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 {
- 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 an sfnt face this picker can inspect.
- 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 a03e51ce..c21ad069 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -23,7 +23,7 @@ const look = @import("../look.zig");
const message = @import("../message.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 fonts = @import("../fonts.zig"); // the Font builtin's half of the seam
const selection_pipe = @import("../selection_pipe.zig");
const is_emscripten = builtin.os.tag == .emscripten;