summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 20:31:09 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit4b5e793f48f6e721c39ab6ddcf755604df73b643 (patch)
treef3cf8b009bbfbd5667635381f0f624cd6b466084
parent9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a (diff)
downloadpardes-4b5e793f48f6e721c39ab6ddcf755604df73b643.tar.gz
pardes-4b5e793f48f6e721c39ab6ddcf755604df73b643.zip
Better text rendering
-rw-r--r--build.zig18
-rw-r--r--build.zig.zon6
-rw-r--r--docs/config.md7
-rw-r--r--docs/design.typ8
-rw-r--r--docs/macos.md2
-rw-r--r--shaders/ui.frag.glsl5
-rw-r--r--src/gui/font.c175
-rw-r--r--src/gui/font.h41
-rw-r--r--src/gui/fonts.zig140
-rw-r--r--src/gui/gui.zig141
-rw-r--r--vendor/stb/font.c81
-rw-r--r--vendor/stb/font.h41
12 files changed, 483 insertions, 182 deletions
diff --git a/build.zig b/build.zig
index e399f537..84accf93 100644
--- a/build.zig
+++ b/build.zig
@@ -532,10 +532,9 @@ pub fn build(b: *std.Build) void {
root_mod.addOptions("pardes_config", opts);
hx_core_mod.addOptions("pardes_config", opts);
- // SDL3 shell wiring (-Dplatform=gui). The sdl package builds SDL3
- // from source as a static lib + emits the SDL3/ headers; gui.zig @cImports
- // those, plus a small stb_truetype C shim for the glyph atlas, and embeds
- // the SPIR-V compiled at build time from shaders/*.glsl.
+ // SDL3 shell wiring (-Dplatform=gui). SDL3 and FreeType are both built
+ // from source as static libraries; gui.zig @cImports their headers plus
+ // the small FreeType policy shim, and embeds SPIR-V compiled from GLSL.
if (platform == .gui) {
// sanitize_c MUST stay off: zig cc's UBSan (on for C in Debug AND
// ReleaseSafe) traps hidapi's mismatched fn-pointer calls the moment
@@ -549,15 +548,20 @@ pub fn build(b: *std.Build) void {
root_mod.linkLibrary(sdl_lib);
root_mod.addIncludePath(dep.path("include"));
}
+ const freetype_dep = b.dependency("freetype", .{
+ .target = target,
+ .optimize = optimize,
+ .@"enable-libpng" = false,
+ });
+ root_mod.linkLibrary(freetype_dep.artifact("freetype"));
// darwin: SDL_GPU only speaks SPIRV here (shaders/*.glsl -> glslc), so
// it must pick its vulkan backend via the Vulkan SDK's loader +
// MoltenVK in /usr/local/lib — a path dyld no longer searches for
// bare dlopen names. The rpath restores that lookup.
if (target.result.os.tag.isDarwin())
root_mod.addRPath(.{ .cwd_relative = "/usr/local/lib" });
- root_mod.addIncludePath(b.path("vendor/stb"));
- root_mod.addCSourceFile(.{ .file = b.path("vendor/stb/stb_truetype_impl.c") });
- root_mod.addCSourceFile(.{ .file = b.path("vendor/stb/font.c") });
+ root_mod.addIncludePath(b.path("src/gui"));
+ root_mod.addCSourceFile(.{ .file = b.path("src/gui/font.c") });
// the embedded UI font: provided as a build import since assets/ lives
// outside the src/ module root (@embedFile can't escape it)
root_mod.addAnonymousImport("AdwaitaMono-Regular.ttf", .{
diff --git a/build.zig.zon b/build.zig.zon
index e557a3b1..02254fff 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -165,6 +165,12 @@
.hash = "N-V-__8AAAa9gQo4C4cIEpZfRqQLBv-ZnRIB4NQqsMOy8bNx",
.lazy = true,
},
+ // FreeType's native/light hinter and grayscale rasterizer for the SDL
+ // glyph atlas. Added with `zig fetch --save=freetype` and pinned.
+ .freetype = .{
+ .url = "git+https://github.com/allyourcodebase/freetype#884fd2235e6ae1ec4306eda30b3259f20930ed2e",
+ .hash = "freetype-2.14.3-C3-WdXSKAAB-kHs4qgRoeoFnRkNQn6M_SSlUGZ6ywgBQ",
+ },
},
.minimum_zig_version = "0.16.0",
.paths = .{""},
diff --git a/docs/config.md b/docs/config.md
index b3eee911..cba7e5fc 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -18,6 +18,13 @@ Theme acme
Font DejaVuSansMono-Regular
```
+On the SDL GUI, a selected face falls back to embedded Adwaita Mono, then to
+installed `NotoSansMono-Regular`, `DejaVuSansMono`,
+`SymbolsNerdFont-Regular`, `NotoSansSymbols2-Regular`,
+`NotoSansSymbols-Regular`, and `DejaVuSans`, in that order. Missing entries are
+skipped. Faces are discovered and opened once at startup, and each resolved
+glyph is retained in the GPU atlas cache.
+
Blank, unknown, malformed, or unsuccessful lines are ignored silently, and a
bad line does not prevent later lines from running. Top-level text that is not
a builtin is not sent to a shell. (`Exec ...` remains an ordinary builtin and
diff --git a/docs/design.typ b/docs/design.typ
index 75c89812..38fa6fdb 100644
--- a/docs/design.typ
+++ b/docs/design.typ
@@ -174,9 +174,9 @@ test.
`zig build` produces three statically linked artifacts: `pardes` (vaxis),
`pardes-gui` (SDL3), `pardes.wasm` + a vanilla DOM shell (wasm32-freestanding).
-Same native dependency set as the prototype — ghostty, vaxis, uucode (shared config), zstbi, SDL
-(castholm, lazy), zig-tree-sitter + grammars — all via `zig fetch`, wired in
-`build.zig`. Codegen during build, consumed by comptime: tree-sitter
+Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL
+(castholm, lazy), FreeType, and zig-tree-sitter + grammars — all pinned through
+`zig fetch` and wired in `build.zig`. Codegen during build, consumed by comptime:
`highlights.scm` → options module; tutor text → embedded pane content. Debug
builds are incremental for the seconds-loop; release builds are the product.
@@ -282,7 +282,7 @@ tap-LOOK/drag-scroll, but finger-down on
a tagline or a one-cell-tolerant pane separator latches to a left-mouse gesture
for its lifetime, keeping layout drags out of the scroll heuristic. Touch
input translation and scroll/tap state live entirely in the JavaScript shell.
-The native SDL shell uses an stb_truetype atlas over the SDL GPU API (SPIR-V).
+The native SDL shell uses a FreeType light-hinted grayscale atlas over the SDL GPU API (SPIR-V).
The browser exposes each cell as selectable, inspectable text and applies the
surface styles with CSS. The browser `.snap` harness drives real Chromium touch
input and reads both text and per-cell styles from the DOM renderer's packed
diff --git a/docs/macos.md b/docs/macos.md
index 35fba5f3..e430f677 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -28,7 +28,7 @@ Pardes goes the other way because its frame is *already* a cell grid.
draw one: attributed runs, the system font stack, and Apple's own subpixel and
color-emoji handling, for free. The alternative is a hand-rolled glyph atlas,
and the SDL backend is the measurement of what that costs — roughly three
-thousand of `src/gui/gui.zig`'s 4,300 lines are a 2048² stb_truetype R8 atlas,
+thousand of `src/gui/gui.zig`'s 4,300 lines are a 2048² FreeType-hinted R8 atlas,
GPU pipelines, transfer buffers and shaders. Writing a second one, blind, buys
nothing the grid needs.
diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl
index d0967309..d00a0601 100644
--- a/shaders/ui.frag.glsl
+++ b/shaders/ui.frag.glsl
@@ -13,9 +13,8 @@ layout(location = 0) out vec4 o_col;
void main() {
float a = texture(u_atlas, v_uv).r;
- // stb_truetype is unhinted. A small coverage lift counters its grey edge
- // halo without turning the atlas into hard-edged bitmap text.
- a = pow(a, 0.8);
+ // FreeType's hinted grayscale bitmap is already the intended coverage.
+ // Preserve it exactly; weight-changing gamma hacks blur small stems.
vec3 col = mix(v_bg, v_fg, a);
o_col = vec4(col, 1.0);
}
diff --git a/src/gui/font.c b/src/gui/font.c
new file mode 100644
index 00000000..e94480b5
--- /dev/null
+++ b/src/gui/font.c
@@ -0,0 +1,175 @@
+// FreeType wrapper for gui.zig. The policy intentionally follows Ghostty's
+// FreeType backend: native hinting enabled, the auto-hinter available as a
+// fallback, and the light target for shape-preserving grayscale text.
+#include "font.h"
+#include <freetype-zig.h>
+
+#include <stdlib.h>
+#include <string.h>
+
+struct UIFont {
+ FT_Library library;
+ FT_Face face;
+ FT_F26Dot6 size;
+};
+
+static FT_F26Dot6 size_26_6(float px) {
+ if (px < 1.0f)
+ px = 1.0f;
+ return (FT_F26Dot6)(px * 64.0f + 0.5f);
+}
+
+static int set_size(UIFont *f, float px) {
+ const FT_F26Dot6 size = size_26_6(px);
+ if (f->size == size)
+ return 1;
+ if (FT_Set_Char_Size(f->face, 0, size, 72, 72) != 0)
+ return 0;
+ f->size = size;
+ return 1;
+}
+
+static int ceil_26_6(FT_Pos value) { return (int)((value + 63) >> 6); }
+
+static FT_Int32 load_flags(void) {
+ // Hinting is on because FT_LOAD_NO_HINTING is absent. Likewise, leaving
+ // FT_LOAD_NO_AUTOHINT absent lets FreeType fall back to its auto-hinter
+ // when the face has no native instructions. This is Ghostty's default.
+ return FT_LOAD_DEFAULT | FT_LOAD_TARGET_LIGHT;
+}
+
+UIFont *ui_font_new(const uint8_t *data, int32_t len) {
+ if (!data || len <= 0)
+ return NULL;
+
+ UIFont *f = (UIFont *)calloc(1, sizeof(UIFont));
+ if (!f)
+ return NULL;
+ if (FT_Init_FreeType(&f->library) != 0) {
+ free(f);
+ return NULL;
+ }
+ if (FT_New_Memory_Face(f->library, data, (FT_Long)len, 0, &f->face) != 0) {
+ FT_Done_FreeType(f->library);
+ free(f);
+ return NULL;
+ }
+ // Some symbol fonts do not expose a Unicode charmap. FreeType already
+ // selects a usable default for those, so failure here is non-fatal.
+ (void)FT_Select_Charmap(f->face, FT_ENCODING_UNICODE);
+ return f;
+}
+
+int ui_font_has_glyph(UIFont *f, int32_t cp) {
+ if (!f || cp < 0)
+ return 0;
+ return FT_Get_Char_Index(f->face, (FT_ULong)(uint32_t)cp) != 0;
+}
+
+void ui_font_free(UIFont *f) {
+ if (!f)
+ return;
+ if (f->face)
+ FT_Done_Face(f->face);
+ if (f->library)
+ FT_Done_FreeType(f->library);
+ free(f);
+}
+
+float ui_font_scale_for_height(UIFont *f, float px) {
+ (void)f;
+ // Kept as a size token so the Zig renderer can continue to distinguish
+ // measurement size from high-DPI raster size.
+ return px;
+}
+
+void ui_font_cell_metrics(UIFont *f, float px, int32_t *cell_w, int32_t *cell_h,
+ int32_t *ascent) {
+ if (!f || !cell_w || !cell_h || !ascent || !set_size(f, px)) {
+ if (cell_w)
+ *cell_w = 1;
+ if (cell_h)
+ *cell_h = 1;
+ if (ascent)
+ *ascent = 1;
+ return;
+ }
+
+ const FT_Size_Metrics metrics = f->face->size->metrics;
+ const int asc = ceil_26_6(metrics.ascender);
+ const int desc = ceil_26_6(-metrics.descender);
+ int height = ceil_26_6(metrics.height);
+ if (height < asc + desc)
+ height = asc + desc;
+
+ int width = ceil_26_6(metrics.max_advance);
+ const FT_UInt m = FT_Get_Char_Index(f->face, 'M');
+ if (FT_Load_Glyph(f->face, m, load_flags()) == 0) {
+ const int advance = ceil_26_6(f->face->glyph->advance.x);
+ if (advance > 0)
+ width = advance;
+ }
+
+ *cell_w = width > 0 ? width : 1;
+ *cell_h = height > 0 ? height : 1;
+ *ascent = asc > 0 ? asc : 1;
+}
+
+int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out,
+ int32_t stride, int32_t cell_w, int32_t cell_h,
+ int32_t ascent) {
+ if (!f || !out || stride < cell_w || cell_w <= 0 || cell_h <= 0)
+ return 0;
+ for (int y = 0; y < cell_h; y++)
+ memset(out + (size_t)y * (size_t)stride, 0, (size_t)cell_w);
+ if (!set_size(f, px))
+ return 0;
+
+ const FT_UInt glyph_index =
+ FT_Get_Char_Index(f->face, (FT_ULong)(uint32_t)cp);
+ if (FT_Load_Glyph(f->face, glyph_index, load_flags()) != 0)
+ return 0;
+ if (FT_Render_Glyph(f->face->glyph, FT_RENDER_MODE_NORMAL) != 0)
+ return 0;
+
+ const FT_GlyphSlot glyph = f->face->glyph;
+ const FT_Bitmap *bitmap = &glyph->bitmap;
+ if (!bitmap->buffer || bitmap->width == 0 || bitmap->rows == 0)
+ return 0;
+
+ const int advance = ceil_26_6(glyph->advance.x);
+ // Fallbacks may be proportional. Center their advance in the primary face's
+ // fixed cell, exactly as Ghostty centers a narrower fallback face.
+ const int gx = glyph->bitmap_left + (cell_w - advance) / 2;
+ const int gy = ascent - glyph->bitmap_top;
+ const int pitch = bitmap->pitch;
+ const int row_bytes = pitch < 0 ? -pitch : pitch;
+
+ for (unsigned int y = 0; y < bitmap->rows; y++) {
+ const int dy = gy + (int)y;
+ if (dy < 0 || dy >= cell_h)
+ continue;
+ const unsigned int source_y = pitch < 0 ? bitmap->rows - 1 - y : y;
+ const uint8_t *src = bitmap->buffer + (size_t)source_y * (size_t)row_bytes;
+
+ for (unsigned int x = 0; x < bitmap->width; x++) {
+ const int dx = gx + (int)x;
+ if (dx < 0 || dx >= cell_w)
+ continue;
+
+ uint8_t coverage;
+ if (bitmap->pixel_mode == FT_PIXEL_MODE_MONO) {
+ coverage = (src[x >> 3] & (0x80u >> (x & 7))) ? 255 : 0;
+ } else if (bitmap->pixel_mode == FT_PIXEL_MODE_GRAY) {
+ coverage = src[x];
+ if (bitmap->num_grays > 1 && bitmap->num_grays != 256)
+ coverage = (uint8_t)((unsigned int)coverage * 255u /
+ (bitmap->num_grays - 1u));
+ } else {
+ continue;
+ }
+ out[(size_t)dy * (size_t)stride + (size_t)dx] = coverage;
+ }
+ }
+ return 1;
+}
diff --git a/src/gui/font.h b/src/gui/font.h
new file mode 100644
index 00000000..050fbeb0
--- /dev/null
+++ b/src/gui/font.h
@@ -0,0 +1,41 @@
+// Tiny C shim over FreeType, so gui.zig only imports the stable renderer API.
+// The implementation owns FreeType's library and face objects and keeps the
+// caller's font bytes borrowed for the face lifetime.
+#ifndef UI_FONT_H
+#define UI_FONT_H
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct UIFont UIFont;
+
+// Parse the first face in an in-memory font. `data` must stay alive for the
+// lifetime of the font. Returns NULL on failure.
+UIFont *ui_font_new(const uint8_t *data, int32_t len);
+
+// Whether the selected Unicode charmap maps `cp` to a real glyph. Glyph zero is
+// the face's .notdef box and deliberately does not count for fallback choice.
+int ui_font_has_glyph(UIFont *f, int32_t cp);
+
+// The size token is a pixel height. Metrics and rasterization both configure
+// FreeType at that size, so hinted advances, baseline and bitmaps stay in step.
+float ui_font_scale_for_height(UIFont *f, float px);
+void ui_font_cell_metrics(UIFont *f, float px, int32_t *cell_w, int32_t *cell_h,
+ int32_t *ascent);
+
+// Rasterize `cp` into `out` as a hinted grayscale cell mask (`stride` bytes per
+// row). The origin is the baseline `ascent` pixels from the top. Pixels outside
+// the glyph are zero. Missing codepoints render the face's .notdef glyph.
+// Returns 1 when the glyph produced coverage, 0 for blank/error.
+int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out,
+ int32_t stride, int32_t cell_w, int32_t cell_h,
+ int32_t ascent);
+
+void ui_font_free(UIFont *f);
+
+#ifdef __cplusplus
+}
+#endif
+#endif
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);
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 951564f0..7a9c2135 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1,8 +1,8 @@
//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input
//! into core events, performs the core's effects (fork ptys, write them,
//! resize them — same duties as tty.zig, this is also native), and
-//! rasterizes the core's Surface: one instanced quad per cell, glyphs from an
-//! stb_truetype R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL,
+//! rasterizes the core's Surface: one instanced quad per cell, glyphs from a
+//! FreeType-hinted R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL,
//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the
//! real renderer, drives input from stdin, and captures frames to PPM.
//!
@@ -104,6 +104,12 @@ const web_render_scale: f32 = 1.5;
const atlas_w: u32 = 2048;
const atlas_h: u32 = 2048;
const Slot = struct { u: u32, v: u32 };
+const max_fallback_fonts = 1 + fonts.fallback_names.len;
+const LoadedFallback = struct {
+ face: *c.UIFont,
+ /// Empty for embedded Adwaita; otherwise the owned bytes FreeType borrows.
+ bytes: []u8 = &.{},
+};
const max_touch_points: usize = 16;
const max_touch_trail_points: usize = 8;
@@ -192,6 +198,68 @@ test "every font size step moves the cell, and the ends are reachable exactly" {
try std.testing.expectEqual(font_px_min, std.math.clamp(font_px_min - font_px_step, font_px_min, font_px_max));
}
+test "FreeType atlas raster is grayscale, bounded, and clears blank cells" {
+ const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
+ defer c.ui_font_free(font);
+ try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(font, 'H'));
+ try std.testing.expectEqual(@as(c_int, 0), c.ui_font_has_glyph(font, 0x10ffff));
+
+ const size = c.ui_font_scale_for_height(font, 13.0);
+ var cell_w: c_int = 0;
+ var cell_h: c_int = 0;
+ var ascent: c_int = 0;
+ c.ui_font_cell_metrics(font, size, &cell_w, &cell_h, &ascent);
+ try std.testing.expect(cell_w > 0 and cell_w <= 128);
+ try std.testing.expect(cell_h > 0 and cell_h <= 128);
+
+ const stride: c_int = 128;
+ var bitmap: [128 * 128]u8 = @splat(0xaa);
+ try std.testing.expectEqual(
+ @as(c_int, 1),
+ c.ui_font_raster(font, size, 'H', &bitmap, stride, cell_w, cell_h, ascent),
+ );
+
+ var ink: usize = 0;
+ var soft: usize = 0;
+ for (0..@intCast(cell_h)) |y| {
+ for (0..@intCast(cell_w)) |x| {
+ const coverage = bitmap[y * @as(usize, @intCast(stride)) + x];
+ if (coverage > 0) ink += 1;
+ if (coverage > 0 and coverage < 255) soft += 1;
+ }
+ }
+ try std.testing.expect(ink > 0);
+ try std.testing.expect(soft > 0);
+
+ try std.testing.expectEqual(
+ @as(c_int, 0),
+ c.ui_font_raster(font, size, ' ', &bitmap, stride, cell_w, cell_h, ascent),
+ );
+ for (0..@intCast(cell_h)) |y|
+ for (0..@intCast(cell_w)) |x|
+ try std.testing.expectEqual(
+ @as(u8, 0),
+ bitmap[y * @as(usize, @intCast(stride)) + x],
+ );
+}
+
+test "installed Nerd Symbols fallback covers Yazi directory icons" {
+ try std.testing.expectEqualStrings("SymbolsNerdFont-Regular", fonts.fallback_names[2]);
+
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ const candidate = for (fonts.fallbacks(arena_state.allocator())) |font| {
+ if (std.mem.eql(u8, font.name, "SymbolsNerdFont-Regular")) break font;
+ } else return;
+
+ const bytes = try look.readFile(std.testing.allocator, candidate.path);
+ defer std.testing.allocator.free(bytes);
+ const face = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit;
+ defer c.ui_font_free(face);
+ // Yazi's default directory icon ``.
+ try std.testing.expectEqual(@as(c_int, 1), c.ui_font_has_glyph(face, 0xe5ff));
+}
+
// One instance per cell; the vertex shader expands it to a 2-triangle quad
// with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1.
const CellInstance = extern struct {
@@ -1016,8 +1084,13 @@ const Gui = struct {
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.
+ /// to free — FreeType borrows these bytes for the face lifetime.
font_bytes: []u8 = &.{},
+ /// Faces are discovered and opened once at startup. `glyphs` below caches
+ /// the raster result by codepoint, so fallback probing happens once per
+ /// glyph/atlas epoch rather than once per cell or frame.
+ fallbacks: [max_fallback_fonts]?LoadedFallback = @splat(null),
+ fallback_count: usize = 0,
/// 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
@@ -1028,7 +1101,9 @@ const Gui = struct {
cell_h: u32,
ascent: i32,
- // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc
+ // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc.
+ // The slot is also the fallback-resolution cache: after first rasterization
+ // every cell/frame takes the hash hit without probing any face again.
atlas_stage: []u8,
glyphs: std.AutoHashMap(u32, Slot),
pen_x: u32 = 0,
@@ -1095,6 +1170,50 @@ const Gui = struct {
gl_instances: []CellInstance = &.{},
};
+fn loadFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void {
+ // The shipped face is the first fallback whenever Font selects a narrower
+ // user face. A second FT_Face is cheap and keeps both lifetimes independent.
+ if (c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len))) |face| {
+ g.fallbacks[0] = .{ .face = face };
+ g.fallback_count = 1;
+ }
+
+ var arena_state = std.heap.ArenaAllocator.init(gpa);
+ defer arena_state.deinit();
+ for (fonts.fallbacks(arena_state.allocator())) |candidate| {
+ if (g.fallback_count == g.fallbacks.len) break;
+ const bytes = look.readFile(gpa, candidate.path) catch continue;
+ const len = std.math.cast(c_int, bytes.len) orelse {
+ gpa.free(bytes);
+ continue;
+ };
+ const face = c.ui_font_new(bytes.ptr, len) orelse {
+ gpa.free(bytes);
+ continue;
+ };
+ g.fallbacks[g.fallback_count] = .{ .face = face, .bytes = bytes };
+ g.fallback_count += 1;
+ }
+}
+
+fn deinitFallbackFonts(g: *Gui, gpa: std.mem.Allocator) void {
+ for (g.fallbacks[0..g.fallback_count]) |loaded| {
+ const fallback = loaded.?;
+ c.ui_font_free(fallback.face);
+ if (fallback.bytes.len != 0) gpa.free(fallback.bytes);
+ }
+}
+
+fn fontForCodepoint(g: *const Gui, cp: u32) *c.UIFont {
+ if (c.ui_font_has_glyph(g.font, @intCast(cp)) != 0) return g.font;
+ for (g.fallbacks[0..g.fallback_count]) |loaded| {
+ const face = loaded.?.face;
+ if (c.ui_font_has_glyph(face, @intCast(cp)) != 0) return face;
+ }
+ // Preserve FreeType's useful .notdef box when no face has the codepoint.
+ return g.font;
+}
+
/// A cell's on-screen rect. Native: exactly cell_w×cell_h at (0,0). Web: the
/// canvas resize and the grid resize land on different frames, so the grid is
/// scaled to fit and centered (letterboxed) instead of clipping.
@@ -1297,6 +1416,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.capture = test_mode,
.capture_dir = capture_dir orelse "",
};
+ loadFallbackFonts(&g, gpa);
+ defer deinitFallbackFonts(&g, gpa);
defer g.glyphs.deinit();
defer {
clearNativeImages(&g);
@@ -1306,6 +1427,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf);
defer c.SDL_ReleaseGPUGraphicsPipeline(device, image_pipeline);
defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran
+ defer c.ui_font_free(g.font);
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);
@@ -1505,9 +1627,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
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.
+ // FreeType turned it down. 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;
@@ -1515,7 +1637,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
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
+ g.font_bytes = bytes; // FreeType borrows them for the face lifetime
refitFont(&g, core);
}
// 5. live cwd for tags/look: cheap /proc readlink per pane, per frame
@@ -3728,7 +3850,8 @@ fn ensureGlyph(g: *Gui, cp: u32) Slot {
if (g.pen_y + g.cell_h > atlas_h) return g.space_slot; // atlas full: fall back
const s = Slot{ .u = g.pen_x, .v = g.pen_y };
const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u;
- _ = c.ui_font_raster(g.font, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent);
+ const face = fontForCodepoint(g, cp);
+ _ = c.ui_font_raster(face, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent);
g.glyphs.put(cp, s) catch return g.space_slot;
g.pen_x += g.cell_w;
g.atlas_dirty = true;
diff --git a/vendor/stb/font.c b/vendor/stb/font.c
deleted file mode 100644
index 6c5106f6..00000000
--- a/vendor/stb/font.c
+++ /dev/null
@@ -1,81 +0,0 @@
-// stb_truetype wrapper for ui_sdl.zig. See font.h. Compiled into the SDL UI
-// backend module alongside stb_truetype_impl.c (which defines the impl).
-#include "font.h"
-#include "stb_truetype.h"
-
-#include <stdlib.h>
-#include <string.h>
-
-struct UIFont {
- stbtt_fontinfo info;
- const uint8_t *data;
-};
-
-UIFont *ui_font_new(const uint8_t *data, int32_t len) {
- if (!data || len <= 0) return NULL;
- UIFont *f = (UIFont *)calloc(1, sizeof(UIFont));
- if (!f) return NULL;
- f->data = data;
- int offset = stbtt_GetFontOffsetForIndex(data, 0);
- if (offset < 0 || !stbtt_InitFont(&f->info, (const unsigned char *)data, offset)) {
- free(f);
- return NULL;
- }
- return f;
-}
-
-void ui_font_free(UIFont *f) {
- free(f);
-}
-
-float ui_font_scale_for_height(UIFont *f, float px) {
- return stbtt_ScaleForPixelHeight(&f->info, px);
-}
-
-void ui_font_cell_metrics(UIFont *f, float scale, int32_t *cell_w, int32_t *cell_h, int32_t *ascent) {
- int asc = 0, desc = 0, gap = 0;
- stbtt_GetFontVMetrics(&f->info, &asc, &desc, &gap);
- int a = (int)(asc * scale + 0.5f);
- int d = (int)(-desc * scale + 0.5f);
- int g = (int)(gap * scale + 0.5f);
- *ascent = a;
- *cell_h = a + d + g;
- if (*cell_h < 1) *cell_h = 1;
- // monospace advance of 'M'
- int adv = 0, lsb = 0;
- stbtt_GetCodepointHMetrics(&f->info, 'M', &adv, &lsb);
- int w = (int)(adv * scale + 0.5f);
- if (w < 1) w = 1;
- *cell_w = w;
-}
-
-int ui_font_raster(UIFont *f, float scale, int32_t cp,
- uint8_t *out, int32_t stride, int32_t cell_w, int32_t cell_h,
- int32_t ascent) {
- if (!out || cell_w <= 0 || cell_h <= 0) return 0;
- // zero the cell
- for (int y = 0; y < cell_h; y++)
- memset(out + (size_t)y * stride, 0, (size_t)cell_w);
-
- int w = 0, h = 0, xoff = 0, yoff = 0;
- unsigned char *bm = stbtt_GetCodepointBitmap(&f->info, scale, scale, cp, &w, &h, &xoff, &yoff);
- if (!bm || w <= 0 || h <= 0) {
- if (bm) stbtt_FreeBitmap(bm, NULL);
- return 0;
- }
- // origin = (0, baseline=ascent); glyph top-left = (xoff, ascent + yoff).
- int gx = xoff;
- int gy = ascent + yoff;
- for (int y = 0; y < h; y++) {
- int dy = gy + y;
- if (dy < 0 || dy >= cell_h) continue;
- for (int x = 0; x < w; x++) {
- int dx = gx + x;
- if (dx < 0 || dx >= cell_w) continue;
- uint8_t v = bm[y * w + x];
- if (v) out[(size_t)dy * stride + dx] = v;
- }
- }
- stbtt_FreeBitmap(bm, NULL);
- return 1;
-}
diff --git a/vendor/stb/font.h b/vendor/stb/font.h
deleted file mode 100644
index 93bbac8d..00000000
--- a/vendor/stb/font.h
+++ /dev/null
@@ -1,41 +0,0 @@
-// Tiny C shim over stb_truetype, so ui_sdl.zig can @cInclude a small header
-// instead of feeding the whole single-header lib to translate-c. The
-// implementation (font.c) pulls in stb_truetype.h (declarations only —
-// stb_truetype_impl.c defines STB_TRUETYPE_IMPLEMENTATION once elsewhere).
-//
-// A "font" is an opaque handle owning a stbtt_fontinfo; all rasterization goes
-// through these functions so the Zig side never touches stb internals.
-#ifndef UI_FONT_H
-#define UI_FONT_H
-#include <stdint.h>
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-typedef struct UIFont UIFont;
-
-// Parse the embedded TTF. `data` must stay alive for the lifetime of the font.
-// Returns NULL on failure.
-UIFont *ui_font_new(const uint8_t *data, int32_t len);
-
-// Font metrics at a given pixel height. cell_w is the monospace advance (rounded
-// up); ascent is the baseline offset from the top of a cell.
-float ui_font_scale_for_height(UIFont *f, float px);
-void ui_font_cell_metrics(UIFont *f, float scale, int32_t *cell_w, int32_t *cell_h, int32_t *ascent);
-
-// Rasterize `cp` into `out` (a cell_w*cell_h single-channel mask, row-major,
-// `stride` bytes per row). The glyph is positioned for a monospace cell: origin
-// at the baseline `ascent` pixels from the top. Pixels outside the glyph are 0.
-// Returns 1 if the glyph produced any coverage (0 for an empty/missing glyph,
-// but `out` is still zeroed).
-int ui_font_raster(UIFont *f, float scale, int32_t cp,
- uint8_t *out, int32_t stride, int32_t cell_w, int32_t cell_h,
- int32_t ascent);
-
-void ui_font_free(UIFont *f);
-
-#ifdef __cplusplus
-}
-#endif
-#endif