From 4b5e793f48f6e721c39ab6ddcf755604df73b643 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 9 Aug 2026 20:31:09 -0300 Subject: Better text rendering --- src/gui/gui.zig | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 9 deletions(-) (limited to 'src/gui/gui.zig') 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; -- cgit v1.3