summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/builtins.zig61
-rw-r--r--src/config.zig180
-rw-r--r--src/gui/fonts.zig221
-rw-r--r--src/gui/gui.zig99
-rw-r--r--src/main.zig9
-rw-r--r--src/output_pane.zig37
-rw-r--r--src/pardes.zig18
7 files changed, 537 insertions, 88 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index 7378e395..85a6470d 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -31,6 +31,11 @@ const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
const output_pane = @import("output_pane.zig");
const config = @import("config.zig");
+/// The installed fonts, for the two builtins at the bottom of this file. A
+/// GUI-only file behind a comptime branch, the way look.zig imports the web's
+/// source archive: the tty and web builds evaluate the other arm and compile
+/// none of it.
+const fonts = if (pardes.platform == .gui) @import("gui/fonts.zig") else struct {};
/// What a builtin gets to act on. One bundle rather than five parameters
/// because most builtins want two of them and zig rejects the unused rest.
@@ -228,6 +233,62 @@ pub const Crt = struct {
}
};
+// ---- the GUI's font, and NOTHING on any other platform ----
+//
+// Theme and ThemeSel again, one layer down: a word that takes a name, and the
+// list of what it takes. What is different is that these two only EXIST in a
+// gui build, and the mechanism is the one this file's header describes rather
+// than a new one — `all()` folds the enum out of the SHAPE of each decl, so a
+// struct whose `run` is not `fn (Ctx) void` is not a builtin. Here `run` is a
+// void const on tty and web: the enum has no field, `SPC ?` has no row, the
+// dispatcher has no prong, and nothing in a tty binary ever opens a font
+// directory. The body sits inside the struct as an ordinary private decl,
+// which the walk never sees (it only reads pub, file-scope declarations) and
+// which nothing on those platforms ever analyses.
+//
+// A `pub const Font = if (gui) struct {...} else struct {}` would read better
+// and is WRONG: zig names a struct born inside an if-expression
+// "builtins.Font__struct_32751", and that name is the user-visible word.
+
+/// The GUI font BY NAME — `Font DejaVuSansMono-Regular`, the way `Theme <name>`
+/// takes a theme, and inert without an argument for the same reason (there is
+/// no font called nothing). The name is a font FILE's stem, which is what the
+/// picker lists; resolving it is a walk of the font directories that stops at
+/// the first match, so nothing is cached and an install five seconds ago is
+/// findable.
+///
+/// The core cannot load a font — it has no rasterizer, no atlas and no window
+/// — so this asks: the resolved PATH goes in fonts.want, the shell takes it on
+/// its next pass and re-rasters. Exactly the shape Restore already has.
+pub const Font = struct {
+ pub const run = if (pardes.platform == .gui) apply else {};
+ fn apply(c: Ctx) void {
+ const want = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
+ const hit = fonts.list(c.p.scratch.allocator(), want);
+ if (hit.len == 0) return;
+ const path = hit[0].path;
+ if (path.len > fonts.want_buf.len) return;
+ @memcpy(fonts.want_buf[0..path.len], path);
+ fonts.want = fonts.want_buf[0..path.len];
+ }
+};
+
+/// ...and the list of what Font takes: every MONOSPACE font on the machine,
+/// one `Font <name>` row each, in the picker ThemeSel already is (rows that
+/// are commands, so n/N execute them — walking the list wears each font in
+/// turn and picking one is stopping on it).
+///
+/// Monospace only, which is the one judgement in the feature: the grid is a
+/// fixed cell, so a proportional face is not a worse-looking option but an
+/// unreadable one — and this picker EXECUTES what it steps onto, so listing
+/// them would mean the list wearing one on the way past. See fonts.monospaced.
+pub const FontSel = struct {
+ pub const run = if (pardes.platform == .gui) apply else {};
+ fn apply(c: Ctx) void {
+ output_pane.openFonts(c.p, c.id);
+ }
+};
+
// The image pane's three renderer toggles. They used to be words the image tag
// printed and the execute dispatcher matched by hand; as builtins they are
// executable anywhere, pressable under SPC and listed by `SPC ?`, the whole
diff --git a/src/config.zig b/src/config.zig
index 2603a40c..0db1d344 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -58,89 +58,114 @@ pub const leader: []const Chord = &.{.{ .cp = ' ' }};
/// still reach. Also Help's own path below, so the character is spelled once.
pub const leader_help: u8 = '?';
-/// SPC leader: ONE key path per builtin, the whole remapping surface. An
-/// EnumArray, so a new enum field is a compile error until someone has DECIDED
-/// its path. Groups are just shared first letters (f files, h docs, c columns,
-/// t toggles, s session, l language, w windows).
+/// what an unlisted builtin's path is until someone says otherwise: a value no
+/// key path can be, so the loop at the bottom of the table can refuse it
+const undecided: []const u8 = "<undecided>";
+
+/// SPC leader: ONE key path per builtin, the whole remapping surface. A new
+/// enum field is a compile error until someone has DECIDED its path — that is
+/// what `undecided` and the loop under the table are for, and it used to be
+/// EnumArray.init's own doing (it demands every field). It cannot be any more:
+/// the gui-only builtins are not fields of this literal's type on tty or web,
+/// so the literal cannot name them and a default is the only way to have both.
+/// Groups are just shared first letters (f files, h docs, c columns, t
+/// toggles, s session, l language, w windows).
///
/// `null` = this builtin's shortcut is not a leader path. Look and Exec are
/// the two: their shortcuts are Enter/Tab and the two mouse buttons below, and
/// a third spelling under SPC would be a key that does nothing you cannot
/// already do with the key your hand is on. The option is the honest type —
/// "every builtin has a leader path" was only ever true by accident.
-pub const leader_path = std.EnumArray(Builtin, ?[]const u8).init(.{
- .Help = &[_]u8{leader_help},
- // The whole LANGUAGE group lives under `l`, and pardes's own builtins keep
- // the letters they always had — `SPC d` is Del, `SPC k` is Kill.
- //
- // Helix puts these on bare `<space>` letters, and an earlier pass followed
- // it there, which cost `d`, `k`, `s`, `h` and the session group. That is
- // the wrong trade: those five are pardes's most-pressed keys and predate
- // the language work, whereas an LSP command is something you reach for
- // deliberately and can afford one more keystroke. Each one still keeps
- // HELIX'S OWN LETTER inside the group, so the mapping is `<space>X` ->
- // `SPC l X` with nothing to re-learn but the prefix.
- //
- // The five GOTOS are untouched and remain exactly helix's — `gd` `gD` `gy`
- // `gi` `gr`, plus `]d`/`[d` and `=`. Those never collided with anything, so
- // there was never a reason to move them. They are in PART 3.
- .Hover = "lk",
- .Rename = "lr",
- .CodeAction = "la",
- .SelectRefs = "lh",
- .Symbols = "ls",
- .WsSymbols = "lS",
- .Diagnostics = "ld",
- .WsDiagnostics = "lD",
- .Lspinfo = "li",
- .Lspwhy = "lw",
- .Del = "d",
- .Kill = "k",
- // the `f` file group (spacemacs): Save left vim's `w` to join Find here,
- // which frees `w` for the window group (SPC w h/j/k/l) to move into.
- .Save = "fs",
- .Find = "ff",
- .Grep = "fg",
- .Tutor = "ht",
- .Newcol = "cn",
- .Delcol = "cd",
- .Debug = "td",
- .Colors = "tc",
- .NextColor = "tn",
- .Crt = "tr",
- // the theme picker joins the toggles it belongs with; `Theme` itself takes
- // a NAME, and a key path can never carry one, so it has none (the same
- // reason Look and Exec have none)
- .ThemeSel = "tt",
- .Theme = null,
- // the image toggles join the same `t` group; Palette takes `l` because
- // `p` is Petscii's and `c` is Colors'.
- .Petscii = "tp",
- .Palette = "tl",
- .Ascii = "ta",
- .Dump = "sd",
- .Restore = "sr",
- // the `w` window group `Save` vacated: the four directional focus moves
- // the Ctrl-w prefix does, spelled h/j/k/l because focus IS a motion, plus
- // `t` for the file<->terminal hop.
- .Left = "wh",
- .Down = "wj",
- .Up = "wk",
- .Right = "wl",
- .Toggleterm = "wt",
- // the `j` JUMP group, its own letter rather than more of `w`: the window
- // group moves focus by GEOMETRY (the pane left of this one), these move it
- // by TIME (the pane I was in before). `o` and `i` are the letters of the
- // chords that do the same thing, `jj` is the group's obvious verb, and
- // `jl` is the list itself.
- .Back = "jo",
- .Forward = "ji",
- .Last = "jj",
- .Jumplist = "jl",
- // the two acme verbs: keys and buttons, no leader path — see above
- .Look = null,
- .Exec = null,
-});
+pub const leader_path = paths: {
+ var table = std.EnumArray(Builtin, ?[]const u8).initDefault(@as(?[]const u8, undecided), .{
+ .Help = &[_]u8{leader_help},
+ // The whole LANGUAGE group lives under `l`, and pardes's own builtins keep
+ // the letters they always had — `SPC d` is Del, `SPC k` is Kill.
+ //
+ // Helix puts these on bare `<space>` letters, and an earlier pass followed
+ // it there, which cost `d`, `k`, `s`, `h` and the session group. That is
+ // the wrong trade: those five are pardes's most-pressed keys and predate
+ // the language work, whereas an LSP command is something you reach for
+ // deliberately and can afford one more keystroke. Each one still keeps
+ // HELIX'S OWN LETTER inside the group, so the mapping is `<space>X` ->
+ // `SPC l X` with nothing to re-learn but the prefix.
+ //
+ // The five GOTOS are untouched and remain exactly helix's — `gd` `gD` `gy`
+ // `gi` `gr`, plus `]d`/`[d` and `=`. Those never collided with anything, so
+ // there was never a reason to move them. They are in PART 3.
+ .Hover = "lk",
+ .Rename = "lr",
+ .CodeAction = "la",
+ .SelectRefs = "lh",
+ .Symbols = "ls",
+ .WsSymbols = "lS",
+ .Diagnostics = "ld",
+ .WsDiagnostics = "lD",
+ .Lspinfo = "li",
+ .Lspwhy = "lw",
+ .Del = "d",
+ .Kill = "k",
+ // the `f` file group (spacemacs): Save left vim's `w` to join Find here,
+ // which frees `w` for the window group (SPC w h/j/k/l) to move into.
+ .Save = "fs",
+ .Find = "ff",
+ .Grep = "fg",
+ .Tutor = "ht",
+ .Newcol = "cn",
+ .Delcol = "cd",
+ .Debug = "td",
+ .Colors = "tc",
+ .NextColor = "tn",
+ .Crt = "tr",
+ // the theme picker joins the toggles it belongs with; `Theme` itself takes
+ // a NAME, and a key path can never carry one, so it has none (the same
+ // reason Look and Exec have none)
+ .ThemeSel = "tt",
+ .Theme = null,
+ // the image toggles join the same `t` group; Palette takes `l` because
+ // `p` is Petscii's and `c` is Colors'.
+ .Petscii = "tp",
+ .Palette = "tl",
+ .Ascii = "ta",
+ .Dump = "sd",
+ .Restore = "sr",
+ // the `w` window group `Save` vacated: the four directional focus moves
+ // the Ctrl-w prefix does, spelled h/j/k/l because focus IS a motion, plus
+ // `t` for the file<->terminal hop.
+ .Left = "wh",
+ .Down = "wj",
+ .Up = "wk",
+ .Right = "wl",
+ .Toggleterm = "wt",
+ // the `j` JUMP group, its own letter rather than more of `w`: the window
+ // group moves focus by GEOMETRY (the pane left of this one), these move it
+ // by TIME (the pane I was in before). `o` and `i` are the letters of the
+ // chords that do the same thing, `jj` is the group's obvious verb, and
+ // `jl` is the list itself.
+ .Back = "jo",
+ .Forward = "ji",
+ .Last = "jj",
+ .Jumplist = "jl",
+ // the two acme verbs: keys and buttons, no leader path — see above
+ .Look = null,
+ .Exec = null,
+ });
+ // The GUI's two font builtins, in the same `t` group as the theme picker
+ // they mirror — but set here rather than named above, because on tty and
+ // web they are not builtins at all (see builtins.zig) and the literal's
+ // type has no field to write. `Font` takes a NAME, so it has no path, for
+ // the same reason `Theme` has none.
+ if (pardes.platform == .gui) {
+ table.set(.FontSel, "tf");
+ table.set(.Font, null);
+ }
+ // ...and the property EnumArray.init used to give for free: every builtin
+ // this build HAS is a builtin someone decided a path (or a null) for.
+ for (std.enums.values(Builtin)) |b| if (table.get(b)) |p| {
+ if (std.mem.eql(u8, p, undecided)) @compileError("builtin has no leader path decided: " ++ @tagName(b));
+ };
+ break :paths table;
+};
// ---- the acme chords ----
@@ -514,6 +539,7 @@ pub const search_buffer = "+Search";
pub const help_buffer = "+Help";
pub const jumps_buffer = "+Jumps";
pub const themes_buffer = "+Themes";
+pub const fonts_buffer = "+Fonts";
pub const hover_buffer = "+Hover";
pub const lsp_buffer = "+Lsp";
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 <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));
+}
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) {
diff --git a/src/main.zig b/src/main.zig
index 3808e1f8..9822ea3c 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -177,7 +177,12 @@ fn parseCtrlKey(raw: []const u8) ?u21 {
// The shells are imported inside main(), which a test build never analyses —
// so their inline tests need naming here to exist at all. gui.zig only
// compiles when it IS the shell (it @cImports SDL), hence the comptime gate;
-// under -Dplatform=tty this block analyses to nothing.
+// under -Dplatform=tty this block analyses to nothing. Naming a file gets THAT
+// file's tests and no further: fonts.zig is imported by both gui.zig and
+// builtins.zig and still needs its own line here.
test {
- if (comptime pardes.platform == .gui) _ = @import("gui/gui.zig");
+ if (comptime pardes.platform == .gui) {
+ _ = @import("gui/gui.zig");
+ _ = @import("gui/fonts.zig");
+ }
}
diff --git a/src/output_pane.zig b/src/output_pane.zig
index 9c004dff..3308e22a 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -25,6 +25,9 @@ const builtins = @import("builtins.zig");
const Builtin = builtins.Builtin();
const config = @import("config.zig");
const lsp = @import("lsp/lsp.zig");
+/// the installed fonts, for openFonts. GUI only, behind the same comptime
+/// branch builtins.zig imports it through — see the note there.
+const fonts = if (pardes.platform == .gui) @import("gui/fonts.zig") else struct {};
/// What opened this buffer — THE field, and the only input to `traits`.
///
@@ -139,10 +142,21 @@ pub fn traits(o: Origin) Traits {
// rows are COMMANDS rather than locations, so n/N execute them:
// walking the list wears each theme, and stopping is picking one.
.ThemeSel => .{ .name = config.themes_buffer, .steps = true, .executes = true },
+ // the font picker, the theme picker one layer down: same rows,
+ // same verb, its own name. It is a BRANCH and not a prong because
+ // FontSel only exists in a gui build (see builtins.zig) — a prong
+ // naming an enum field the tty fold does not have is a compile
+ // error, and a comptime-false `if` is the one form that is not
+ // even analysed there.
+ //
// Find (file names) and Grep (file contents) both list locations;
// no other builtin opens a buffer, and the day one does it lands
// here rather than in a call site.
- else => .{ .name = config.search_buffer, .steps = true },
+ else => blk: {
+ if (pardes.platform == .gui) if (b == .FontSel)
+ break :blk .{ .name = config.fonts_buffer, .steps = true, .executes = true };
+ break :blk .{ .name = config.search_buffer, .steps = true };
+ },
},
.query => |k| switch (k) {
.hover => .{ .name = config.hover_buffer },
@@ -287,6 +301,27 @@ pub fn openThemes(p: *Pardes, id: usize) void {
openStepped(p, id, .{ .cmd = .ThemeSel }, out.items);
}
+/// The FontSel builtin: openThemes over the fonts installed on the machine
+/// instead of the themes compiled into the binary — one `Font <name>` row
+/// each, in a buffer whose rows n/N RUN, so walking it wears the fonts and
+/// stopping picks one. Everything that makes that work is already above; this
+/// is the same eight lines pointed at a different list.
+///
+/// GUI only, and the body says so rather than the signature: fonts.list and
+/// the FontSel origin both exist only there, and a comptime-false `if` is what
+/// keeps the tty build from analysing either. The dead parameters on that
+/// build are the honest shape of "this platform cannot open one".
+pub fn openFonts(p: *Pardes, id: usize) void {
+ if (pardes.platform == .gui) {
+ const arena = p.scratch.allocator();
+ var out: std.ArrayList(u8) = .empty;
+ for (fonts.list(arena, null)) |f| {
+ out.print(arena, comptime builtins.word(builtins.Font) ++ " {s}\n", .{f.name}) catch return;
+ }
+ openStepped(p, id, .{ .cmd = .FontSel }, out.items);
+ }
+}
+
/// Open a buffer n/N will walk, and arm them on it: the shared tail of every
/// builtin that answers with a list. `text` is borrowed (the callers build it
/// in the scratch arena) and copied into a gpa buffer the pane adopts.
diff --git a/src/pardes.zig b/src/pardes.zig
index eb2c8402..760a452f 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -6292,16 +6292,24 @@ pub const Pardes = struct {
defer p.exec_depth -= 1;
// The builtins that take an ARGUMENT match their name with a TAIL:
// `Restore <path>`, `Find <pat>`, `Grep <pat>`, `Rename <name>`,
- // `WsSymbols <sym>`, `Theme <name>`, and the two verbs themselves —
- // `Look <word>`, `Exec <cmd>`, which is what makes `` @`Look .` `` nest
- // (the tail goes straight back through here). Every other name must
- // match WHOLE, so `Kill foo` is a shell command and not Kill.
+ // `WsSymbols <sym>`, `Theme <name>`, `Font <name>` (gui only), and the
+ // two verbs themselves — `Look <word>`, `Exec <cmd>`, which is what
+ // makes `` @`Look .` `` nest (the tail goes straight back through
+ // here). Every other name must match WHOLE, so `Kill foo` is a shell
+ // command and not Kill.
const sp = std.mem.indexOfAny(u8, cmd, " \t");
const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: {
const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null;
break :blk switch (head) {
.Restore, .Find, .Grep, .Rename, .WsSymbols, .Theme, .Look, .Exec => head,
- else => null,
+ // Font is a BRANCH and not another name in that prong for the
+ // reason output_pane.traits has the same shape: it is not in
+ // the enum at all off the gui, and a comptime-false `if` is
+ // the one form that is not analysed there.
+ else => {
+ if (platform == .gui) if (head == .Font) break :blk head;
+ break :blk null;
+ },
};
};
if (bi) |b| {