summaryrefslogtreecommitdiff
path: root/src/gui/fonts.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/gui/fonts.zig')
-rw-r--r--src/gui/fonts.zig140
1 files changed, 104 insertions, 36 deletions
diff --git a/src/gui/fonts.zig b/src/gui/fonts.zig
index 1ad7dd84..54210136 100644
--- a/src/gui/fonts.zig
+++ b/src/gui/fonts.zig
@@ -10,18 +10,18 @@
//! 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.
+//! 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, 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.
+/// 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" };
@@ -48,23 +48,40 @@ pub const Font = struct { name: []const u8, path: []const u8 };
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.
-///
-/// 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 {
+ 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
@@ -74,7 +91,7 @@ pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font {
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| {
+ 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)
@@ -98,30 +115,81 @@ pub fn list(arena: std.mem.Allocator, want_name: ?[]const u8) []const Font {
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 (want_name) |wn| {
- if (!std.mem.eql(u8, wn, name)) continue;
- } else if (!monospaced(path)) 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 (want_name != null) return arena.dupe(Font, found[0..found_len]) catch &.{}; // asked for one, found it
+ if (wanted) |names| {
+ if (names.len == 1) return arena.dupe(Font, found[0..found_len]) catch &.{};
+ if (found_len == names.len) break :roots;
+ }
}
}
- // 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);
+ 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
@@ -172,9 +240,9 @@ fn monospaced(path_z: [*:0]const u8) bool {
/// 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.
+ // 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);