summaryrefslogtreecommitdiff
path: root/src/gui/gui.zig
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 /src/gui/gui.zig
parent9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a (diff)
downloadpardes-4b5e793f48f6e721c39ab6ddcf755604df73b643.tar.gz
pardes-4b5e793f48f6e721c39ab6ddcf755604df73b643.zip
Better text rendering
Diffstat (limited to 'src/gui/gui.zig')
-rw-r--r--src/gui/gui.zig141
1 files changed, 132 insertions, 9 deletions
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;