feat(gui): M3 - TrueType rasterizer with fontconfig font discovery

Replace the M2 stub rasterizer with a real font rasterizer:

- build.zig.zon: add TrueType as a lazy path dependency
- build.zig: swap stub_rasterizer_mod for truetype_rasterizer_mod; link
  fontconfig + libc on Linux; TrueType dep is lazy (falls back gracefully)
- src/gui/rasterizer/truetype.zig: andrewrk/TrueType rasterizer; loadFont
  uses fontconfig to locate the font file, derives cell dimensions from
  vertical metrics (ascent−descent) and 'M' advance width; render blits the
  A8 glyph bitmap into the caller-provided staging buffer with correct
  baseline placement (ascent_px + off_y) and double-wide support (+cell_w
  x-offset for kind=.right); arena allocator per render call
- src/gui/rasterizer/font_finder.zig: OS dispatcher (Linux only for now)
- src/gui/rasterizer/font_finder/linux.zig: fontconfig C API - FcFontMatch
  resolves a font name pattern to an absolute file path

Requires: libfontconfig-dev  (already present alongside libgl-dev etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
CJ van den Berg 2026-03-29 19:20:06 +02:00
parent 39b482b2e0
commit ae0f62c3bf
Signed by: neurocyte
GPG key ID: 8EB1E1BB660E3FB9
5 changed files with 173 additions and 3 deletions

View file

@ -554,18 +554,29 @@ pub fn build_exe(
const gui_glyph_cache_mod = b.createModule(.{ .root_source_file = b.path("src/gui/GlyphIndexCache.zig") });
const gui_xterm_mod = b.createModule(.{ .root_source_file = b.path("src/gui/xterm.zig") });
const stub_rasterizer_mod = b.createModule(.{
.root_source_file = b.path("src/gui/rasterizer/stub.zig"),
const tt_dep = b.lazyDependency("TrueType", .{
.target = target,
.optimize = optimize_deps,
}) orelse break :blk tui_renderer_mod;
const truetype_rasterizer_mod = b.createModule(.{
.root_source_file = b.path("src/gui/rasterizer/truetype.zig"),
.target = target,
.imports = &.{
.{ .name = "TrueType", .module = tt_dep.module("TrueType") },
.{ .name = "xy", .module = gui_xy_mod },
},
});
if (target.result.os.tag == .linux) {
truetype_rasterizer_mod.linkSystemLibrary("fontconfig", .{});
truetype_rasterizer_mod.link_libc = true;
}
const gpu_mod = b.createModule(.{
.root_source_file = b.path("src/gui/gpu/gpu.zig"),
.imports = &.{
.{ .name = "sokol", .module = sokol_mod },
.{ .name = "rasterizer", .module = stub_rasterizer_mod },
.{ .name = "rasterizer", .module = truetype_rasterizer_mod },
.{ .name = "xy", .module = gui_xy_mod },
.{ .name = "Cell", .module = gui_cell_mod },
.{ .name = "GlyphIndexCache", .module = gui_glyph_cache_mod },

View file

@ -50,6 +50,10 @@
.path = "../flow-deps/sokol-zig",
.lazy = true,
},
.TrueType = .{
.path = "../flow-deps/TrueType",
.lazy = true,
},
.diffz = .{
.url = "git+https://github.com/ziglibs/diffz.git#fbdf690b87db6b1142bbce6d4906f90b09ce60bb",
.hash = "diffz-0.0.1-G2tlIezMAQBwGNGDs7Hn_N25dWSjEzgR_FAx9GFAvCuZ",

View file

@ -0,0 +1,9 @@
const std = @import("std");
const builtin = @import("builtin");
pub fn findFont(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
return switch (builtin.os.tag) {
.linux => @import("font_finder/linux.zig").find(allocator, name),
else => error.FontFinderNotSupported,
};
}

View file

@ -0,0 +1,28 @@
const std = @import("std");
const fc = @cImport({
@cInclude("fontconfig/fontconfig.h");
});
pub fn find(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
const config = fc.FcInitLoadConfigAndFonts() orelse return error.FontconfigInit;
defer fc.FcConfigDestroy(config);
const name_z = try allocator.dupeZ(u8, name);
defer allocator.free(name_z);
const pat = fc.FcNameParse(name_z.ptr) orelse return error.FontPatternParse;
defer fc.FcPatternDestroy(pat);
_ = fc.FcConfigSubstitute(config, pat, fc.FcMatchPattern);
fc.FcDefaultSubstitute(pat);
var result: fc.FcResult = undefined;
const font = fc.FcFontMatch(config, pat, &result) orelse return error.FontNotFound;
defer fc.FcPatternDestroy(font);
var file: [*c]fc.FcChar8 = undefined;
if (fc.FcPatternGetString(font, fc.FC_FILE, 0, &file) != fc.FcResultMatch)
return error.FontPathNotFound;
return allocator.dupe(u8, std.mem.sliceTo(file, 0));
}

View file

@ -0,0 +1,118 @@
const std = @import("std");
const TrueType = @import("TrueType");
const XY = @import("xy").XY;
const font_finder = @import("font_finder.zig");
const Self = @This();
pub const GlyphKind = enum {
single,
left,
right,
};
pub const Font = struct {
cell_size: XY(u16),
scale: f32 = 0,
ascent_px: i32 = 0,
tt: ?TrueType = null,
};
pub const Fonts = struct {};
allocator: std.mem.Allocator,
font_data: std.ArrayListUnmanaged([]u8),
pub fn init(allocator: std.mem.Allocator) !Self {
return .{
.allocator = allocator,
.font_data = .empty,
};
}
pub fn deinit(self: *Self) void {
for (self.font_data.items) |data| {
self.allocator.free(data);
}
self.font_data.deinit(self.allocator);
}
pub fn loadFont(self: *Self, name: []const u8, size_px: u16) !Font {
const path = try font_finder.findFont(self.allocator, name);
defer self.allocator.free(path);
const data = try std.fs.cwd().readFileAlloc(self.allocator, path, 64 * 1024 * 1024);
errdefer self.allocator.free(data);
const tt = try TrueType.load(data);
const scale = tt.scaleForPixelHeight(@floatFromInt(size_px));
const vm = tt.verticalMetrics();
const ascent_px: i32 = @intFromFloat(@round(@as(f32, @floatFromInt(vm.ascent)) * scale));
const descent_px: i32 = @intFromFloat(@round(@as(f32, @floatFromInt(vm.descent)) * scale));
const cell_h: u16 = @intCast(@max(ascent_px - descent_px, 1));
const m_glyph = tt.codepointGlyphIndex('M');
const m_hmetrics = tt.glyphHMetrics(m_glyph);
const cell_w_f: f32 = @as(f32, @floatFromInt(m_hmetrics.advance_width)) * scale;
const cell_w: u16 = @intFromFloat(@ceil(cell_w_f));
try self.font_data.append(self.allocator, data);
return Font{
.tt = tt, // TrueType holds a slice into `data` which is now owned by self.font_data
.cell_size = .{ .x = cell_w, .y = cell_h },
.scale = scale,
.ascent_px = ascent_px,
};
}
pub fn render(
self: *const Self,
font: Font,
codepoint: u21,
kind: GlyphKind,
staging_buf: []u8,
) void {
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
const alloc = arena.allocator();
const tt = font.tt orelse return;
var pixels = std.ArrayListUnmanaged(u8){};
const glyph = tt.codepointGlyphIndex(codepoint);
const dims = tt.glyphBitmap(alloc, &pixels, glyph, font.scale, font.scale) catch return;
if (dims.width == 0 or dims.height == 0) return;
const buf_w: i32 = switch (kind) {
.single => @intCast(font.cell_size.x),
.left, .right => @as(i32, @intCast(font.cell_size.x)) * 2,
};
const buf_h: i32 = @intCast(font.cell_size.y);
const x_offset: i32 = switch (kind) {
.single, .left => 0,
.right => @intCast(font.cell_size.x),
};
for (0..dims.height) |row| {
const dst_y: i32 = font.ascent_px + @as(i32, dims.off_y) + @as(i32, @intCast(row));
if (dst_y < 0 or dst_y >= buf_h) continue;
for (0..dims.width) |col| {
const dst_x: i32 = x_offset + @as(i32, dims.off_x) + @as(i32, @intCast(col));
if (dst_x < 0 or dst_x >= buf_w) continue;
const src_idx = row * dims.width + col;
const dst_idx: usize = @intCast(dst_y * buf_w + dst_x);
if (src_idx < pixels.items.len and dst_idx < staging_buf.len) {
staging_buf[dst_idx] = pixels.items[src_idx];
}
}
}
}