feat(gui): add freetype rasterizer backend

This commit is contained in:
CJ van den Berg 2026-04-03 22:39:40 +02:00
parent 1901696b7b
commit d3aa7e17f5
Signed by: neurocyte
GPG key ID: 8EB1E1BB660E3FB9
8 changed files with 751 additions and 456 deletions

View file

@ -559,24 +559,58 @@ pub fn build_exe(
.optimize = optimize_deps,
}) orelse break :blk tui_renderer_mod;
const geometric_mod = b.createModule(.{
.root_source_file = b.path("src/gui/rasterizer/geometric.zig"),
});
const font_finder_mod = b.createModule(.{
.root_source_file = b.path("src/gui/rasterizer/font_finder.zig"),
.target = target,
});
if (target.result.os.tag == .linux) {
font_finder_mod.linkSystemLibrary("fontconfig", .{});
font_finder_mod.link_libc = true;
}
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 },
.{ .name = "geometric", .module = geometric_mod },
.{ .name = "font_finder", .module = font_finder_mod },
},
});
const freetype_rasterizer_mod = b.createModule(.{
.root_source_file = b.path("src/gui/rasterizer/freetype.zig"),
.target = target,
.imports = &.{
.{ .name = "xy", .module = gui_xy_mod },
.{ .name = "geometric", .module = geometric_mod },
.{ .name = "font_finder", .module = font_finder_mod },
},
});
freetype_rasterizer_mod.linkSystemLibrary("freetype2", .{});
freetype_rasterizer_mod.addIncludePath(.{ .cwd_relative = "/usr/include/freetype2" });
freetype_rasterizer_mod.link_libc = true;
const combined_rasterizer_mod = b.createModule(.{
.root_source_file = b.path("src/gui/rasterizer/combined.zig"),
.imports = &.{
.{ .name = "tt_rasterizer", .module = truetype_rasterizer_mod },
.{ .name = "ft_rasterizer", .module = freetype_rasterizer_mod },
.{ .name = "xy", .module = gui_xy_mod },
.{ .name = "gui_config", .module = gui_config_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 = truetype_rasterizer_mod },
.{ .name = "rasterizer", .module = combined_rasterizer_mod },
.{ .name = "xy", .module = gui_xy_mod },
.{ .name = "Cell", .module = gui_cell_mod },
.{ .name = "GlyphIndexCache", .module = gui_glyph_cache_mod },
@ -608,7 +642,7 @@ pub fn build_exe(
.{ .name = "app", .module = app_mod },
.{ .name = "tuirenderer", .module = tui_renderer_mod },
.{ .name = "vaxis", .module = vaxis_mod },
.{ .name = "rasterizer", .module = truetype_rasterizer_mod },
.{ .name = "rasterizer", .module = combined_rasterizer_mod },
},
});
break :blk mod;

View file

@ -13,6 +13,7 @@ const builtin_shader = @import("builtin.glsl.zig");
pub const Font = Rasterizer.Font;
pub const GlyphKind = Rasterizer.GlyphKind;
pub const RasterizerBackend = Rasterizer.Backend;
pub const Cell = gui_cell.Cell;
pub const Color = gui_cell.Rgba8;
const Rgba8 = gui_cell.Rgba8;
@ -99,6 +100,14 @@ pub fn loadFont(name: []const u8, size_px: u16) !Font {
return global.rasterizer.loadFont(name, size_px);
}
pub fn setRasterizerBackend(backend: RasterizerBackend) void {
global.rasterizer.setBackend(backend);
}
pub fn setFontWeight(font: *Font, w: u8) void {
Rasterizer.setFontWeight(font, w);
}
pub fn setBackground(color: Rgba8) void {
global.background = color;
}

View file

@ -0,0 +1,86 @@
/// Combined rasterizer wraps TrueType and FreeType backends with runtime switching.
/// Satisfies the GlyphRasterizer interface (see GlyphRasterizer.zig).
///
/// The active backend is changed via setBackend(). Callers must reload fonts
/// after switching (the Font struct's .backend tag must match the active backend).
const std = @import("std");
const XY = @import("xy").XY;
const TT = @import("tt_rasterizer");
const FT = @import("ft_rasterizer");
pub const GlyphKind = TT.GlyphKind;
pub const Fonts = struct {};
pub const font_finder = TT.font_finder;
pub const Backend = @import("gui_config").RasterizerBackend;
/// Backend-specific font data.
pub const BackendFont = union(Backend) {
truetype: TT.Font,
freetype: FT.Font,
};
/// Combined font handle. `cell_size` is hoisted to the top level so all
/// existing callers that do `font.cell_size.x / .y` continue to work without
/// change. Backend-specific data lives in `backend`.
pub const Font = struct {
cell_size: XY(u16),
backend: BackendFont,
};
/// Set emboldening weight on a font (0 = normal).
/// For TrueType: number of morphological dilation passes.
/// For FreeType: outline inflation at 32 units per weight step (0.5px each).
pub fn setFontWeight(font: *Font, w: u8) void {
switch (font.backend) {
.truetype => |*f| f.weight = w,
.freetype => |*f| f.weight_strength = @as(i64, w) * 32,
}
}
const Self = @This();
active: Backend = .truetype,
tt: TT,
ft: FT,
pub fn init(allocator: std.mem.Allocator) !Self {
const tt = try TT.init(allocator);
const ft = try FT.init(allocator);
return .{ .tt = tt, .ft = ft };
}
pub fn deinit(self: *Self) void {
self.tt.deinit();
self.ft.deinit();
}
pub fn setBackend(self: *Self, backend: Backend) void {
self.active = backend;
}
pub fn loadFont(self: *Self, name: []const u8, size_px: u16) !Font {
switch (self.active) {
.truetype => {
const f = try self.tt.loadFont(name, size_px);
return .{ .cell_size = f.cell_size, .backend = .{ .truetype = f } };
},
.freetype => {
const f = try self.ft.loadFont(name, size_px);
return .{ .cell_size = f.cell_size, .backend = .{ .freetype = f } };
},
}
}
pub fn render(
self: *const Self,
font: Font,
codepoint: u21,
kind: GlyphKind,
staging_buf: []u8,
) void {
switch (font.backend) {
.truetype => |f| self.tt.render(f, codepoint, kind, staging_buf),
.freetype => |f| self.ft.render(f, codepoint, @enumFromInt(@intFromEnum(kind)), staging_buf),
}
}

View file

@ -0,0 +1,149 @@
/// FreeType-based glyph rasterizer.
/// Satisfies the GlyphRasterizer interface (see GlyphRasterizer.zig).
///
/// Advantages over the TrueType rasterizer:
/// - FT_Outline_EmboldenXY provides high-quality synthetic bold by inflating
/// the glyph outline before rasterization, avoiding the ugly pixel-smearing
/// of post-rasterization morphological dilation.
/// - FreeType's hinting engine often produces crisper results at small sizes.
const std = @import("std");
const c = @cImport({
@cInclude("ft2build.h");
@cInclude("freetype/freetype.h");
@cInclude("freetype/ftoutln.h");
});
const XY = @import("xy").XY;
const geometric = @import("geometric");
pub const font_finder = @import("font_finder");
const Self = @This();
pub const GlyphKind = enum { single, left, right };
pub const Fonts = struct {};
pub const Font = struct {
cell_size: XY(u16) = .{ .x = 8, .y = 16 },
ascent_px: i32 = 0,
face: c.FT_Face = null,
/// Outline emboldening strength in 26.6 fixed-point pixels (64 = 1px).
/// 0 = no emboldening. Weight 1 32 (0.5px), weight 2 64 (1px), etc.
weight_strength: i64 = 0,
};
library: c.FT_Library,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) !Self {
var library: c.FT_Library = undefined;
if (c.FT_Init_FreeType(&library) != 0) return error.FreeTypeInitFailed;
return .{ .library = library, .allocator = allocator };
}
pub fn deinit(self: *Self) void {
_ = c.FT_Done_FreeType(self.library);
}
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 path_z = try self.allocator.dupeZ(u8, path);
defer self.allocator.free(path_z);
var face: c.FT_Face = undefined;
if (c.FT_New_Face(self.library, path_z.ptr, 0, &face) != 0)
return error.FaceLoadFailed;
errdefer _ = c.FT_Done_Face(face);
if (c.FT_Set_Pixel_Sizes(face, 0, size_px) != 0)
return error.SetSizeFailed;
// Derive cell metrics from the full block glyph (U+2588), same strategy
// as truetype.zig: the rendered bitmap defines exact cell dimensions.
var ascent_px: i32 = @intCast((face.*.size.*.metrics.ascender + 32) >> 6);
var cell_h: u16 = size_px;
if (c.FT_Load_Char(face, 0x2588, c.FT_LOAD_RENDER) == 0) {
const bm = face.*.glyph.*.bitmap;
if (bm.rows > 0) {
cell_h = @intCast(bm.rows);
ascent_px = face.*.glyph.*.bitmap_top;
}
}
// Cell width from advance of 'M'.
var cell_w: u16 = @max(1, size_px / 2);
if (c.FT_Load_Char(face, 'M', c.FT_LOAD_DEFAULT) == 0) {
const adv: i32 = @intCast((face.*.glyph.*.advance.x + 32) >> 6);
if (adv > 0) cell_w = @intCast(adv);
}
return .{
.cell_size = .{ .x = cell_w, .y = cell_h },
.ascent_px = ascent_px,
.face = face,
};
}
pub fn render(
self: *const Self,
font: Font,
codepoint: u21,
kind: GlyphKind,
staging_buf: []u8,
) void {
_ = self;
const buf_w: i32 = @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),
};
const cw: i32 = @intCast(font.cell_size.x);
const ch: i32 = @intCast(font.cell_size.y);
if (geometric.renderBlockElement(codepoint, staging_buf, buf_w, buf_h, x_offset, cw, ch)) return;
if (geometric.renderBoxDrawing(codepoint, staging_buf, buf_w, buf_h, x_offset, cw, ch)) return;
if (geometric.renderExtendedBlocks(codepoint, staging_buf, buf_w, buf_h, x_offset, cw, ch)) return;
const face = font.face orelse return;
// Load with FT_LOAD_NO_BITMAP so we always get an outline (needed for
// FT_Outline_EmboldenXY; also avoids embedded bitmap strikes which may
// not match our computed cell metrics).
const load_flags: c.FT_Int32 = c.FT_LOAD_DEFAULT | c.FT_LOAD_NO_BITMAP;
if (c.FT_Load_Char(face, codepoint, load_flags) != 0) return;
if (font.weight_strength > 0) {
const s: c.FT_Pos = @intCast(font.weight_strength);
_ = c.FT_Outline_EmboldenXY(&face.*.glyph.*.outline, s, s);
}
if (c.FT_Render_Glyph(face.*.glyph, c.FT_RENDER_MODE_NORMAL) != 0) return;
const bm = face.*.glyph.*.bitmap;
if (bm.rows == 0 or bm.width == 0) return;
if (bm.pitch <= 0) return; // skip bottom-up bitmaps (unusual for normal mode)
const pitch: u32 = @intCast(bm.pitch);
const off_x: i32 = face.*.glyph.*.bitmap_left;
const off_y: i32 = font.ascent_px - face.*.glyph.*.bitmap_top;
var row: u32 = 0;
while (row < bm.rows) : (row += 1) {
const dst_y = off_y + @as(i32, @intCast(row));
if (dst_y < 0 or dst_y >= buf_h) continue;
var col: u32 = 0;
while (col < bm.width) : (col += 1) {
const dst_x = x_offset + off_x + @as(i32, @intCast(col));
if (dst_x < 0 or dst_x >= buf_w) continue;
const src_idx = row * pitch + col;
const dst_idx: usize = @intCast(dst_y * buf_w + dst_x);
if (dst_idx < staging_buf.len)
staging_buf[dst_idx] = bm.buffer[src_idx];
}
}
}

View file

@ -0,0 +1,396 @@
/// Geometric rendering of Unicode block elements, box-drawing, and extended
/// block characters. These are rasterized as solid pixel fills rather than
/// through any font rasterizer, so they have no anti-aliased edges and tile
/// perfectly between adjacent cells.
///
/// All functions take the same parameter set:
/// cp Unicode codepoint
/// buf A8 staging buffer (width = buf_w, height = buf_h)
/// buf_w buffer row stride in pixels (always 2*cell_w for wide glyphs)
/// buf_h buffer height in pixels (= cell height)
/// x0 left edge of this cell within buf (0 for left/single, cell_w for right)
/// cw, ch cell width / height in pixels
///
/// Returns true if the codepoint was handled, false to fall through to the
/// font rasterizer.
/// Fill a solid rectangle [x0, x1) × [y0, y1) in the staging buffer.
pub fn fillRect(buf: []u8, buf_w: i32, buf_h: i32, x0: i32, y0: i32, x1: i32, y1: i32) void {
const cx0 = @max(0, x0);
const cy0 = @max(0, y0);
const cx1 = @min(buf_w, x1);
const cy1 = @min(buf_h, y1);
if (cx0 >= cx1 or cy0 >= cy1) return;
var y = cy0;
while (y < cy1) : (y += 1) {
var x = cx0;
while (x < cx1) : (x += 1) {
buf[@intCast(y * buf_w + x)] = 255;
}
}
}
/// Draw the stroke×stroke corner area for a rounded box-drawing corner ().
/// Fills pixels in [x_start..x_end, y_start..y_end] where distance from
/// (corner_fx, corner_fy) is >= r_clip.
fn drawRoundedCornerArea(
buf: []u8,
buf_w: i32,
x_start: i32,
y_start: i32,
x_end: i32,
y_end: i32,
corner_fx: f32,
corner_fy: f32,
r_clip: f32,
) void {
const r2 = r_clip * r_clip;
var cy: i32 = y_start;
while (cy < y_end) : (cy += 1) {
const dy: f32 = @as(f32, @floatFromInt(cy)) + 0.5 - corner_fy;
var cx: i32 = x_start;
while (cx < x_end) : (cx += 1) {
const dx: f32 = @as(f32, @floatFromInt(cx)) + 0.5 - corner_fx;
if (dx * dx + dy * dy >= r2) {
const idx = cy * buf_w + cx;
if (idx >= 0 and idx < @as(i32, @intCast(buf.len)))
buf[@intCast(idx)] = 255;
}
}
}
}
/// Render a block element character (U+2580U+259F) geometrically.
pub fn renderBlockElement(
cp: u21,
buf: []u8,
buf_w: i32,
buf_h: i32,
x0: i32,
cw: i32,
ch: i32,
) bool {
if (true) return false;
if (cp < 0x2580 or cp > 0x259F) return false;
const x1 = x0 + cw;
const half_w = @divTrunc(cw, 2);
const half_h = @divTrunc(ch, 2);
const mid_x = x0 + half_w;
switch (cp) {
0x2580 => fillRect(buf, buf_w, buf_h, x0, 0, x1, half_h), // upper half
0x2581 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch, 8), x1, ch), // lower 1/8
0x2582 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch, 4), x1, ch), // lower 1/4
0x2583 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch * 3, 8), x1, ch), // lower 3/8
0x2584 => fillRect(buf, buf_w, buf_h, x0, ch - half_h, x1, ch), // lower half
0x2585 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch * 5, 8), x1, ch), // lower 5/8
0x2586 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch * 3, 4), x1, ch), // lower 3/4
0x2587 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch * 7, 8), x1, ch), // lower 7/8
0x2588 => fillRect(buf, buf_w, buf_h, x0, 0, x1, ch), // full block
0x2589 => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw * 7, 8), ch), // left 7/8
0x258A => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw * 3, 4), ch), // left 3/4
0x258B => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw * 5, 8), ch), // left 5/8
0x258C => fillRect(buf, buf_w, buf_h, x0, 0, mid_x, ch), // left half
0x258D => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw * 3, 8), ch), // left 3/8
0x258E => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw, 4), ch), // left 1/4
0x258F => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw, 8), ch), // left 1/8
0x2590 => fillRect(buf, buf_w, buf_h, mid_x, 0, x1, ch), // right half
0x2591 => { // light shade
var y: i32 = 0;
while (y < ch) : (y += 1) {
var x = x0 + @mod(y, 2);
while (x < x1) : (x += 2) {
if (x >= 0 and x < buf_w and y >= 0 and y < buf_h)
buf[@intCast(y * buf_w + x)] = 255;
}
}
},
0x2592 => { // medium shade
var y: i32 = 0;
while (y < ch) : (y += 2) {
fillRect(buf, buf_w, buf_h, x0, y, x1, y + 1);
}
},
0x2593 => { // dark shade
fillRect(buf, buf_w, buf_h, x0, 0, x1, ch);
var y: i32 = 0;
while (y < ch) : (y += 2) {
var x = x0 + @mod(y, 2);
while (x < x1) : (x += 2) {
if (x >= 0 and x < buf_w and y >= 0 and y < buf_h)
buf[@intCast(y * buf_w + x)] = 0;
}
}
},
0x2594 => fillRect(buf, buf_w, buf_h, x0, 0, x1, @divTrunc(ch, 8)), // upper 1/8
0x2595 => fillRect(buf, buf_w, buf_h, x1 - @divTrunc(cw, 8), 0, x1, ch), // right 1/8
0x2596 => fillRect(buf, buf_w, buf_h, x0, half_h, mid_x, ch), // lower-left
0x2597 => fillRect(buf, buf_w, buf_h, mid_x, half_h, x1, ch), // lower-right
0x2598 => fillRect(buf, buf_w, buf_h, x0, 0, mid_x, half_h), // upper-left
0x2599 => {
fillRect(buf, buf_w, buf_h, x0, 0, mid_x, ch);
fillRect(buf, buf_w, buf_h, mid_x, half_h, x1, ch);
},
0x259A => {
fillRect(buf, buf_w, buf_h, x0, 0, mid_x, half_h);
fillRect(buf, buf_w, buf_h, mid_x, half_h, x1, ch);
},
0x259B => {
fillRect(buf, buf_w, buf_h, x0, 0, x1, half_h);
fillRect(buf, buf_w, buf_h, x0, half_h, mid_x, ch);
},
0x259C => {
fillRect(buf, buf_w, buf_h, x0, 0, x1, half_h);
fillRect(buf, buf_w, buf_h, mid_x, half_h, x1, ch);
},
0x259D => fillRect(buf, buf_w, buf_h, mid_x, 0, x1, half_h), // upper-right
0x259E => {
fillRect(buf, buf_w, buf_h, mid_x, 0, x1, half_h);
fillRect(buf, buf_w, buf_h, x0, half_h, mid_x, ch);
},
0x259F => {
fillRect(buf, buf_w, buf_h, mid_x, 0, x1, half_h);
fillRect(buf, buf_w, buf_h, x0, half_h, x1, ch);
},
else => return false,
}
return true;
}
/// Render a box-drawing character (U+2500U+257F) geometrically.
pub fn renderBoxDrawing(
cp: u21,
buf: []u8,
buf_w: i32,
buf_h: i32,
x0: i32,
cw: i32,
ch: i32,
) bool {
if (cp < 0x2500 or cp > 0x257F) return false;
const x1 = x0 + cw;
const stroke: i32 = @max(1, @divTrunc(cw, 8));
const hy0: i32 = @divTrunc(ch - stroke, 2);
const hy1: i32 = hy0 + stroke;
const vx0: i32 = x0 + @divTrunc(cw - stroke, 2);
const vx1: i32 = vx0 + stroke;
const doff: i32 = @max(stroke + 1, @divTrunc(cw, 4));
const doff_h: i32 = doff;
const doff_w: i32 = doff;
const dhy0t: i32 = @divTrunc(ch, 2) - doff_h;
const dhy1t: i32 = dhy0t + stroke;
const dhy0b: i32 = @divTrunc(ch, 2) + doff_h - stroke;
const dhy1b: i32 = dhy0b + stroke;
const dvx0l: i32 = x0 + @divTrunc(cw, 2) - doff_w;
const dvx1l: i32 = dvx0l + stroke;
const dvx0r: i32 = x0 + @divTrunc(cw, 2) + doff_w - stroke;
const dvx1r: i32 = dvx0r + stroke;
switch (cp) {
0x2500 => fillRect(buf, buf_w, buf_h, x0, hy0, x1, hy1),
0x2502 => fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch),
0x250C => {
fillRect(buf, buf_w, buf_h, vx0, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, hy0, vx1, ch);
},
0x2510 => {
fillRect(buf, buf_w, buf_h, x0, hy0, vx1, hy1);
fillRect(buf, buf_w, buf_h, vx0, hy0, vx1, ch);
},
0x2514 => {
fillRect(buf, buf_w, buf_h, vx0, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, hy1);
},
0x2518 => {
fillRect(buf, buf_w, buf_h, x0, hy0, vx1, hy1);
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, hy1);
},
0x251C => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch);
fillRect(buf, buf_w, buf_h, vx0, hy0, x1, hy1);
},
0x2524 => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch);
fillRect(buf, buf_w, buf_h, x0, hy0, vx1, hy1);
},
0x252C => {
fillRect(buf, buf_w, buf_h, x0, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, hy0, vx1, ch);
},
0x2534 => {
fillRect(buf, buf_w, buf_h, x0, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, hy1);
},
0x253C => {
fillRect(buf, buf_w, buf_h, x0, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch);
},
0x2550 => {
fillRect(buf, buf_w, buf_h, x0, dhy0t, x1, dhy1t);
fillRect(buf, buf_w, buf_h, x0, dhy0b, x1, dhy1b);
},
0x2551 => {
fillRect(buf, buf_w, buf_h, dvx0l, 0, dvx1l, ch);
fillRect(buf, buf_w, buf_h, dvx0r, 0, dvx1r, ch);
},
0x2554 => {
fillRect(buf, buf_w, buf_h, dvx0l, dhy0t, x1, dhy1t);
fillRect(buf, buf_w, buf_h, dvx0r, dhy0b, x1, dhy1b);
fillRect(buf, buf_w, buf_h, dvx0l, dhy0t, dvx1l, ch);
fillRect(buf, buf_w, buf_h, dvx0r, dhy0b, dvx1r, ch);
},
0x2557 => {
fillRect(buf, buf_w, buf_h, x0, dhy0t, dvx1r, dhy1t);
fillRect(buf, buf_w, buf_h, x0, dhy0b, dvx1l, dhy1b);
fillRect(buf, buf_w, buf_h, dvx0r, dhy0t, dvx1r, ch);
fillRect(buf, buf_w, buf_h, dvx0l, dhy0b, dvx1l, ch);
},
0x255A => {
fillRect(buf, buf_w, buf_h, dvx0l, dhy0t, x1, dhy1t);
fillRect(buf, buf_w, buf_h, dvx0r, dhy0b, x1, dhy1b);
fillRect(buf, buf_w, buf_h, dvx0l, 0, dvx1l, dhy1t);
fillRect(buf, buf_w, buf_h, dvx0r, 0, dvx1r, dhy1b);
},
0x255D => {
fillRect(buf, buf_w, buf_h, x0, dhy0t, dvx1r, dhy1t);
fillRect(buf, buf_w, buf_h, x0, dhy0b, dvx1l, dhy1b);
fillRect(buf, buf_w, buf_h, dvx0r, 0, dvx1r, dhy1t);
fillRect(buf, buf_w, buf_h, dvx0l, 0, dvx1l, dhy1b);
},
0x255E => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch);
fillRect(buf, buf_w, buf_h, vx0, dhy0t, x1, dhy1t);
fillRect(buf, buf_w, buf_h, vx0, dhy0b, x1, dhy1b);
},
0x2561 => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch);
fillRect(buf, buf_w, buf_h, x0, dhy0t, vx1, dhy1t);
fillRect(buf, buf_w, buf_h, x0, dhy0b, vx1, dhy1b);
},
0x2552 => {
fillRect(buf, buf_w, buf_h, vx0, dhy0t, vx1, ch);
fillRect(buf, buf_w, buf_h, vx0, dhy0t, x1, dhy1t);
fillRect(buf, buf_w, buf_h, vx0, dhy0b, x1, dhy1b);
},
0x2555 => {
fillRect(buf, buf_w, buf_h, vx0, dhy0t, vx1, ch);
fillRect(buf, buf_w, buf_h, x0, dhy0t, vx1, dhy1t);
fillRect(buf, buf_w, buf_h, x0, dhy0b, vx1, dhy1b);
},
0x2558 => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, dhy1b);
fillRect(buf, buf_w, buf_h, vx0, dhy0t, x1, dhy1t);
fillRect(buf, buf_w, buf_h, vx0, dhy0b, x1, dhy1b);
},
0x255B => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, dhy1b);
fillRect(buf, buf_w, buf_h, x0, dhy0t, vx1, dhy1t);
fillRect(buf, buf_w, buf_h, x0, dhy0b, vx1, dhy1b);
},
0x2553 => {
fillRect(buf, buf_w, buf_h, dvx0l, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, dvx0l, hy0, dvx1l, ch);
fillRect(buf, buf_w, buf_h, dvx0r, hy0, dvx1r, ch);
},
0x2556 => {
fillRect(buf, buf_w, buf_h, x0, hy0, dvx1r, hy1);
fillRect(buf, buf_w, buf_h, dvx0l, hy0, dvx1l, ch);
fillRect(buf, buf_w, buf_h, dvx0r, hy0, dvx1r, ch);
},
0x2559 => {
fillRect(buf, buf_w, buf_h, dvx0l, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, dvx0l, 0, dvx1l, hy1);
fillRect(buf, buf_w, buf_h, dvx0r, 0, dvx1r, hy1);
},
0x255C => {
fillRect(buf, buf_w, buf_h, x0, hy0, dvx1r, hy1);
fillRect(buf, buf_w, buf_h, dvx0l, 0, dvx1l, hy1);
fillRect(buf, buf_w, buf_h, dvx0r, 0, dvx1r, hy1);
},
0x256D => { // NW: down+right
const r_clip: f32 = @max(0.0, @as(f32, @floatFromInt(stroke)) * 0.5 - 0.5);
fillRect(buf, buf_w, buf_h, vx1, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, hy1, vx1, ch);
drawRoundedCornerArea(buf, buf_w, vx0, hy0, vx1, hy1, @floatFromInt(vx0), @floatFromInt(hy0), r_clip);
},
0x256E => { // NE: down+left
const r_clip: f32 = @max(0.0, @as(f32, @floatFromInt(stroke)) * 0.5 - 0.5);
fillRect(buf, buf_w, buf_h, x0, hy0, vx0, hy1);
fillRect(buf, buf_w, buf_h, vx0, hy1, vx1, ch);
drawRoundedCornerArea(buf, buf_w, vx0, hy0, vx1, hy1, @floatFromInt(vx1), @floatFromInt(hy0), r_clip);
},
0x256F => { // SE: up+left
const r_clip: f32 = @max(0.0, @as(f32, @floatFromInt(stroke)) * 0.5 - 0.5);
fillRect(buf, buf_w, buf_h, x0, hy0, vx0, hy1);
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, hy0);
drawRoundedCornerArea(buf, buf_w, vx0, hy0, vx1, hy1, @floatFromInt(vx1), @floatFromInt(hy1), r_clip);
},
0x2570 => { // SW: up+right
const r_clip: f32 = @max(0.0, @as(f32, @floatFromInt(stroke)) * 0.5 - 0.5);
fillRect(buf, buf_w, buf_h, vx1, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, hy0);
drawRoundedCornerArea(buf, buf_w, vx0, hy0, vx1, hy1, @floatFromInt(vx0), @floatFromInt(hy1), r_clip);
},
else => return false,
}
return true;
}
/// Render extended block characters used by WidgetStyle borders.
pub fn renderExtendedBlocks(
cp: u21,
buf: []u8,
buf_w: i32,
buf_h: i32,
x0: i32,
cw: i32,
ch: i32,
) bool {
const x1 = x0 + cw;
const mid_x = x0 + @divTrunc(cw, 2);
const qh = @divTrunc(ch, 4);
const th = @divTrunc(ch, 3);
switch (cp) {
0x1FB82 => fillRect(buf, buf_w, buf_h, x0, 0, x1, qh),
0x1FB02 => fillRect(buf, buf_w, buf_h, x0, 0, x1, th),
0x1FB2D => fillRect(buf, buf_w, buf_h, x0, ch - th, x1, ch),
0x1FB15 => {
fillRect(buf, buf_w, buf_h, x0, 0, x1, th);
fillRect(buf, buf_w, buf_h, x0, th, mid_x, ch);
},
0x1FB28 => {
fillRect(buf, buf_w, buf_h, x0, 0, x1, th);
fillRect(buf, buf_w, buf_h, mid_x, th, x1, ch);
},
0x1FB32 => {
fillRect(buf, buf_w, buf_h, x0, 0, mid_x, ch - th);
fillRect(buf, buf_w, buf_h, x0, ch - th, x1, ch);
},
0x1FB37 => {
fillRect(buf, buf_w, buf_h, mid_x, 0, x1, ch - th);
fillRect(buf, buf_w, buf_h, x0, ch - th, x1, ch);
},
0x1CD4A => {
fillRect(buf, buf_w, buf_h, x0, 0, x1, qh);
fillRect(buf, buf_w, buf_h, x0, qh, mid_x, ch);
},
0x1CD98 => {
fillRect(buf, buf_w, buf_h, x0, 0, x1, qh);
fillRect(buf, buf_w, buf_h, mid_x, qh, x1, ch);
},
0x1CDD5 => {
fillRect(buf, buf_w, buf_h, mid_x, 0, x1, ch - qh);
fillRect(buf, buf_w, buf_h, x0, ch - qh, x1, ch);
},
0x1CDC0 => {
fillRect(buf, buf_w, buf_h, x0, 0, mid_x, ch - qh);
fillRect(buf, buf_w, buf_h, x0, ch - qh, x1, ch);
},
else => return false,
}
return true;
}

View file

@ -1,7 +1,8 @@
const std = @import("std");
const TrueType = @import("TrueType");
const XY = @import("xy").XY;
pub const font_finder = @import("font_finder.zig");
pub const font_finder = @import("font_finder");
const geometric = @import("geometric");
const Self = @This();
@ -16,6 +17,9 @@ pub const Font = struct {
scale: f32 = 0,
ascent_px: i32 = 0,
tt: ?TrueType = null,
/// Synthetic boldness: number of 1-pixel dilation passes applied after
/// rasterization. 0 = no change, 1 = slightly bolder, 2 = bolder still.
weight: u8 = 0,
};
pub const Fonts = struct {};
@ -105,9 +109,9 @@ pub fn render(
// rasterized geometrically rather than through the TrueType anti-aliasing path.
// Anti-aliased edges produce partial-alpha pixels at cell boundaries, creating
// visible seams between adjacent cells when fg bg.
if (renderBlockElement(codepoint, staging_buf, buf_w, buf_h, x_offset, cw, ch)) return;
if (renderBoxDrawing(codepoint, staging_buf, buf_w, buf_h, x_offset, cw, ch)) return;
if (renderExtendedBlocks(codepoint, staging_buf, buf_w, buf_h, x_offset, cw, ch)) return;
if (geometric.renderBlockElement(codepoint, staging_buf, buf_w, buf_h, x_offset, cw, ch)) return;
if (geometric.renderBoxDrawing(codepoint, staging_buf, buf_w, buf_h, x_offset, cw, ch)) return;
if (geometric.renderExtendedBlocks(codepoint, staging_buf, buf_w, buf_h, x_offset, cw, ch)) return;
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
@ -122,6 +126,28 @@ pub fn render(
if (dims.width == 0 or dims.height == 0) return;
// Synthetic emboldening: morphological dilation. Each pass expands every
// lit pixel one step right and one step down, thickening all strokes by
// ~1px per pass without changing the glyph's bounding box.
for (0..font.weight) |_| {
// Dilate right: each pixel takes the max of itself and its left neighbour.
// Iterate right-to-left so we don't cascade within one pass.
for (0..dims.height) |row| {
var col: usize = dims.width - 1;
while (col > 0) : (col -= 1) {
const i = row * dims.width + col;
pixels.items[i] = @max(pixels.items[i], pixels.items[i - 1]);
}
}
// Dilate down: each pixel takes the max of itself and its upper neighbour.
for (1..dims.height) |row| {
for (0..dims.width) |col| {
const i = row * dims.width + col;
pixels.items[i] = @max(pixels.items[i], pixels.items[i - dims.width]);
}
}
}
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;
@ -139,446 +165,3 @@ pub fn render(
}
}
}
/// Fill a solid rectangle [x0, x1) × [y0, y1) in the staging buffer.
fn fillRect(buf: []u8, buf_w: i32, buf_h: i32, x0: i32, y0: i32, x1: i32, y1: i32) void {
const cx0 = @max(0, x0);
const cy0 = @max(0, y0);
const cx1 = @min(buf_w, x1);
const cy1 = @min(buf_h, y1);
if (cx0 >= cx1 or cy0 >= cy1) return;
var y = cy0;
while (y < cy1) : (y += 1) {
var x = cx0;
while (x < cx1) : (x += 1) {
buf[@intCast(y * buf_w + x)] = 255;
}
}
}
/// Render a block element character (U+2580U+259F) geometrically.
/// Returns true if the character was handled, false if it should fall through
/// to the normal TrueType rasterizer.
fn renderBlockElement(
cp: u21,
buf: []u8,
buf_w: i32,
buf_h: i32,
x0: i32,
cw: i32,
ch: i32,
) bool {
if (true) return false;
if (cp < 0x2580 or cp > 0x259F) return false;
const x1 = x0 + cw;
const half_w = @divTrunc(cw, 2);
const half_h = @divTrunc(ch, 2);
const mid_x = x0 + half_w;
switch (cp) {
0x2580 => fillRect(buf, buf_w, buf_h, x0, 0, x1, half_h), // upper half
0x2581 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch, 8), x1, ch), // lower 1/8
0x2582 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch, 4), x1, ch), // lower 1/4
0x2583 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch * 3, 8), x1, ch), // lower 3/8
0x2584 => fillRect(buf, buf_w, buf_h, x0, ch - half_h, x1, ch), // lower half
0x2585 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch * 5, 8), x1, ch), // lower 5/8
0x2586 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch * 3, 4), x1, ch), // lower 3/4
0x2587 => fillRect(buf, buf_w, buf_h, x0, ch - @divTrunc(ch * 7, 8), x1, ch), // lower 7/8
0x2588 => fillRect(buf, buf_w, buf_h, x0, 0, x1, ch), // full block
0x2589 => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw * 7, 8), ch), // left 7/8
0x258A => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw * 3, 4), ch), // left 3/4
0x258B => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw * 5, 8), ch), // left 5/8
0x258C => fillRect(buf, buf_w, buf_h, x0, 0, mid_x, ch), // left half
0x258D => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw * 3, 8), ch), // left 3/8
0x258E => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw, 4), ch), // left 1/4
0x258F => fillRect(buf, buf_w, buf_h, x0, 0, x0 + @divTrunc(cw, 8), ch), // left 1/8
0x2590 => fillRect(buf, buf_w, buf_h, mid_x, 0, x1, ch), // right half
0x2591 => { // light shade approximate with alternating pixels
var y: i32 = 0;
while (y < ch) : (y += 1) {
var x = x0 + @mod(y, 2);
while (x < x1) : (x += 2) {
if (x >= 0 and x < buf_w and y >= 0 and y < buf_h)
buf[@intCast(y * buf_w + x)] = 255;
}
}
},
0x2592 => { // medium shade alternate rows fully/empty
var y: i32 = 0;
while (y < ch) : (y += 2) {
fillRect(buf, buf_w, buf_h, x0, y, x1, y + 1);
}
},
0x2593 => { // dark shade fill all except alternating sparse pixels
fillRect(buf, buf_w, buf_h, x0, 0, x1, ch);
var y: i32 = 0;
while (y < ch) : (y += 2) {
var x = x0 + @mod(y, 2);
while (x < x1) : (x += 2) {
if (x >= 0 and x < buf_w and y >= 0 and y < buf_h)
buf[@intCast(y * buf_w + x)] = 0;
}
}
},
0x2594 => fillRect(buf, buf_w, buf_h, x0, 0, x1, @divTrunc(ch, 8)), // upper 1/8
0x2595 => fillRect(buf, buf_w, buf_h, x1 - @divTrunc(cw, 8), 0, x1, ch), // right 1/8
0x2596 => fillRect(buf, buf_w, buf_h, x0, half_h, mid_x, ch), // lower-left quadrant
0x2597 => fillRect(buf, buf_w, buf_h, mid_x, half_h, x1, ch), // lower-right quadrant
0x2598 => fillRect(buf, buf_w, buf_h, x0, 0, mid_x, half_h), // upper-left quadrant
0x2599 => { // upper-left + lower-left + lower-right
fillRect(buf, buf_w, buf_h, x0, 0, mid_x, ch);
fillRect(buf, buf_w, buf_h, mid_x, half_h, x1, ch);
},
0x259A => { // upper-left + lower-right (diagonal)
fillRect(buf, buf_w, buf_h, x0, 0, mid_x, half_h);
fillRect(buf, buf_w, buf_h, mid_x, half_h, x1, ch);
},
0x259B => { // upper-left + upper-right + lower-left
fillRect(buf, buf_w, buf_h, x0, 0, x1, half_h);
fillRect(buf, buf_w, buf_h, x0, half_h, mid_x, ch);
},
0x259C => { // upper-left + upper-right + lower-right
fillRect(buf, buf_w, buf_h, x0, 0, x1, half_h);
fillRect(buf, buf_w, buf_h, mid_x, half_h, x1, ch);
},
0x259D => fillRect(buf, buf_w, buf_h, mid_x, 0, x1, half_h), // upper-right quadrant
0x259E => { // upper-right + lower-left (diagonal)
fillRect(buf, buf_w, buf_h, mid_x, 0, x1, half_h);
fillRect(buf, buf_w, buf_h, x0, half_h, mid_x, ch);
},
0x259F => { // upper-right + lower-left + lower-right
fillRect(buf, buf_w, buf_h, mid_x, 0, x1, half_h);
fillRect(buf, buf_w, buf_h, x0, half_h, x1, ch);
},
else => return false,
}
return true;
}
/// Draw the stroke×stroke corner area for a rounded box-drawing corner ().
/// are identical to in structure (L-shaped strokes meeting at center)
/// but with the sharp outer corner vertex rounded off by a circular clip.
///
/// Fills pixels in [x_start..x_end, y_start..y_end] where distance from
/// (corner_fx, corner_fy) is >= r_clip. r_clip = max(0, stroke/2 - 0.5):
/// stroke=1 r_clip=0 all pixels filled (no visible rounding, sharp corner)
/// stroke=2 r_clip=0.5 clips the one diagonal corner pixel
/// stroke=3 r_clip=1.0 removes a 2-pixel notch, etc.
fn drawRoundedCornerArea(
buf: []u8,
buf_w: i32,
x_start: i32,
y_start: i32,
x_end: i32,
y_end: i32,
corner_fx: f32,
corner_fy: f32,
r_clip: f32,
) void {
const r2 = r_clip * r_clip;
var cy: i32 = y_start;
while (cy < y_end) : (cy += 1) {
const dy: f32 = @as(f32, @floatFromInt(cy)) + 0.5 - corner_fy;
var cx: i32 = x_start;
while (cx < x_end) : (cx += 1) {
const dx: f32 = @as(f32, @floatFromInt(cx)) + 0.5 - corner_fx;
if (dx * dx + dy * dy >= r2) {
const idx = cy * buf_w + cx;
if (idx >= 0 and idx < @as(i32, @intCast(buf.len)))
buf[@intCast(idx)] = 255;
}
}
}
}
/// Render a box-drawing character (U+2500U+257F) geometrically.
fn renderBoxDrawing(
cp: u21,
buf: []u8,
buf_w: i32,
buf_h: i32,
x0: i32,
cw: i32,
ch: i32,
) bool {
if (cp < 0x2500 or cp > 0x257F) return false;
const x1 = x0 + cw;
// Single-line stroke thickness: base on cell width so horizontal and vertical
// strokes appear equally thick (cells are typically ~2× taller than wide, so
// using ch/8 would make horizontal strokes twice as thick as vertical ones).
const stroke: i32 = @max(1, @divTrunc(cw, 8));
// Single-line center positions
const hy0: i32 = @divTrunc(ch - stroke, 2);
const hy1: i32 = hy0 + stroke;
const vx0: i32 = x0 + @divTrunc(cw - stroke, 2);
const vx1: i32 = vx0 + stroke;
// Double-line: two strokes offset from center by doff each side.
// Use cw-based spacing for both so horizontal and vertical double lines
// appear with the same visual gap regardless of cell aspect ratio.
const doff: i32 = @max(stroke + 1, @divTrunc(cw, 4));
const doff_h: i32 = doff;
const doff_w: i32 = doff;
// Horizontal double strokes (top = closer to top of cell):
const dhy0t: i32 = @divTrunc(ch, 2) - doff_h;
const dhy1t: i32 = dhy0t + stroke;
const dhy0b: i32 = @divTrunc(ch, 2) + doff_h - stroke;
const dhy1b: i32 = dhy0b + stroke;
// Vertical double strokes (left = closer to left of cell):
const dvx0l: i32 = x0 + @divTrunc(cw, 2) - doff_w;
const dvx1l: i32 = dvx0l + stroke;
const dvx0r: i32 = x0 + @divTrunc(cw, 2) + doff_w - stroke;
const dvx1r: i32 = dvx0r + stroke;
switch (cp) {
// light horizontal
0x2500 => fillRect(buf, buf_w, buf_h, x0, hy0, x1, hy1),
// light vertical
0x2502 => fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch),
// down+right (NW corner)
0x250C => {
fillRect(buf, buf_w, buf_h, vx0, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, hy0, vx1, ch);
},
// down+left (NE corner)
0x2510 => {
fillRect(buf, buf_w, buf_h, x0, hy0, vx1, hy1);
fillRect(buf, buf_w, buf_h, vx0, hy0, vx1, ch);
},
// up+right (SW corner)
0x2514 => {
fillRect(buf, buf_w, buf_h, vx0, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, hy1);
},
// up+left (SE corner)
0x2518 => {
fillRect(buf, buf_w, buf_h, x0, hy0, vx1, hy1);
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, hy1);
},
// vertical + right
0x251C => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch);
fillRect(buf, buf_w, buf_h, vx0, hy0, x1, hy1);
},
// vertical + left
0x2524 => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch);
fillRect(buf, buf_w, buf_h, x0, hy0, vx1, hy1);
},
// horizontal + down
0x252C => {
fillRect(buf, buf_w, buf_h, x0, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, hy0, vx1, ch);
},
// horizontal + up
0x2534 => {
fillRect(buf, buf_w, buf_h, x0, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, hy1);
},
// cross
0x253C => {
fillRect(buf, buf_w, buf_h, x0, hy0, x1, hy1);
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch);
},
// double horizontal
0x2550 => {
fillRect(buf, buf_w, buf_h, x0, dhy0t, x1, dhy1t);
fillRect(buf, buf_w, buf_h, x0, dhy0b, x1, dhy1b);
},
// double vertical
0x2551 => {
fillRect(buf, buf_w, buf_h, dvx0l, 0, dvx1l, ch);
fillRect(buf, buf_w, buf_h, dvx0r, 0, dvx1r, ch);
},
// double NW corner (down+right)
0x2554 => {
fillRect(buf, buf_w, buf_h, dvx0l, dhy0t, x1, dhy1t); // outer horiz
fillRect(buf, buf_w, buf_h, dvx0r, dhy0b, x1, dhy1b); // inner horiz
fillRect(buf, buf_w, buf_h, dvx0l, dhy0t, dvx1l, ch); // outer vert
fillRect(buf, buf_w, buf_h, dvx0r, dhy0b, dvx1r, ch); // inner vert
},
// double NE corner (down+left)
0x2557 => {
fillRect(buf, buf_w, buf_h, x0, dhy0t, dvx1r, dhy1t); // outer horiz
fillRect(buf, buf_w, buf_h, x0, dhy0b, dvx1l, dhy1b); // inner horiz
fillRect(buf, buf_w, buf_h, dvx0r, dhy0t, dvx1r, ch); // outer vert
fillRect(buf, buf_w, buf_h, dvx0l, dhy0b, dvx1l, ch); // inner vert
},
// double SW corner (up+right)
0x255A => {
fillRect(buf, buf_w, buf_h, dvx0l, dhy0t, x1, dhy1t); // outer horiz
fillRect(buf, buf_w, buf_h, dvx0r, dhy0b, x1, dhy1b); // inner horiz
fillRect(buf, buf_w, buf_h, dvx0l, 0, dvx1l, dhy1t); // outer vert
fillRect(buf, buf_w, buf_h, dvx0r, 0, dvx1r, dhy1b); // inner vert
},
// double SE corner (up+left)
0x255D => {
fillRect(buf, buf_w, buf_h, x0, dhy0t, dvx1r, dhy1t); // outer horiz
fillRect(buf, buf_w, buf_h, x0, dhy0b, dvx1l, dhy1b); // inner horiz
fillRect(buf, buf_w, buf_h, dvx0r, 0, dvx1r, dhy1t); // outer vert
fillRect(buf, buf_w, buf_h, dvx0l, 0, dvx1l, dhy1b); // inner vert
},
// single vert + double right
0x255E => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch);
fillRect(buf, buf_w, buf_h, vx0, dhy0t, x1, dhy1t);
fillRect(buf, buf_w, buf_h, vx0, dhy0b, x1, dhy1b);
},
// single vert + double left
0x2561 => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, ch);
fillRect(buf, buf_w, buf_h, x0, dhy0t, vx1, dhy1t);
fillRect(buf, buf_w, buf_h, x0, dhy0b, vx1, dhy1b);
},
// down single, right double
0x2552 => {
fillRect(buf, buf_w, buf_h, vx0, dhy0t, vx1, ch); // single vert
fillRect(buf, buf_w, buf_h, vx0, dhy0t, x1, dhy1t); // outer horiz
fillRect(buf, buf_w, buf_h, vx0, dhy0b, x1, dhy1b); // inner horiz
},
// down single, left double
0x2555 => {
fillRect(buf, buf_w, buf_h, vx0, dhy0t, vx1, ch); // single vert
fillRect(buf, buf_w, buf_h, x0, dhy0t, vx1, dhy1t); // outer horiz
fillRect(buf, buf_w, buf_h, x0, dhy0b, vx1, dhy1b); // inner horiz
},
// up single, right double
0x2558 => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, dhy1b); // single vert
fillRect(buf, buf_w, buf_h, vx0, dhy0t, x1, dhy1t); // outer horiz
fillRect(buf, buf_w, buf_h, vx0, dhy0b, x1, dhy1b); // inner horiz
},
// up single, left double
0x255B => {
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, dhy1b); // single vert
fillRect(buf, buf_w, buf_h, x0, dhy0t, vx1, dhy1t); // outer horiz
fillRect(buf, buf_w, buf_h, x0, dhy0b, vx1, dhy1b); // inner horiz
},
// down double, right single
0x2553 => {
fillRect(buf, buf_w, buf_h, dvx0l, hy0, x1, hy1); // single horiz
fillRect(buf, buf_w, buf_h, dvx0l, hy0, dvx1l, ch); // left double vert
fillRect(buf, buf_w, buf_h, dvx0r, hy0, dvx1r, ch); // right double vert
},
// down double, left single
0x2556 => {
fillRect(buf, buf_w, buf_h, x0, hy0, dvx1r, hy1); // single horiz
fillRect(buf, buf_w, buf_h, dvx0l, hy0, dvx1l, ch); // left double vert
fillRect(buf, buf_w, buf_h, dvx0r, hy0, dvx1r, ch); // right double vert
},
// up double, right single
0x2559 => {
fillRect(buf, buf_w, buf_h, dvx0l, hy0, x1, hy1); // single horiz
fillRect(buf, buf_w, buf_h, dvx0l, 0, dvx1l, hy1); // left double vert
fillRect(buf, buf_w, buf_h, dvx0r, 0, dvx1r, hy1); // right double vert
},
// up double, left single
0x255C => {
fillRect(buf, buf_w, buf_h, x0, hy0, dvx1r, hy1); // single horiz
fillRect(buf, buf_w, buf_h, dvx0l, 0, dvx1l, hy1); // left double vert
fillRect(buf, buf_w, buf_h, dvx0r, 0, dvx1r, hy1); // right double vert
},
// rounded corners: same L-shape as but with the outer
// corner vertex clipped by a circle. The corner area (vx0..vx1, hy0..hy1)
// is drawn pixel-by-pixel; everything else uses fillRect.
// r_clip = max(0, stroke/2 - 0.5): no rounding for 1px, small notch for 2px+.
0x256D => { // NW: down+right
const r_clip: f32 = @max(0.0, @as(f32, @floatFromInt(stroke)) * 0.5 - 0.5);
fillRect(buf, buf_w, buf_h, vx1, hy0, x1, hy1); // horizontal right of corner
fillRect(buf, buf_w, buf_h, vx0, hy1, vx1, ch); // vertical below corner
drawRoundedCornerArea(buf, buf_w, vx0, hy0, vx1, hy1, @floatFromInt(vx0), @floatFromInt(hy0), r_clip);
},
0x256E => { // NE: down+left
const r_clip: f32 = @max(0.0, @as(f32, @floatFromInt(stroke)) * 0.5 - 0.5);
fillRect(buf, buf_w, buf_h, x0, hy0, vx0, hy1); // horizontal left of corner
fillRect(buf, buf_w, buf_h, vx0, hy1, vx1, ch); // vertical below corner
drawRoundedCornerArea(buf, buf_w, vx0, hy0, vx1, hy1, @floatFromInt(vx1), @floatFromInt(hy0), r_clip);
},
0x256F => { // SE: up+left
const r_clip: f32 = @max(0.0, @as(f32, @floatFromInt(stroke)) * 0.5 - 0.5);
fillRect(buf, buf_w, buf_h, x0, hy0, vx0, hy1); // horizontal left of corner
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, hy0); // vertical above corner
drawRoundedCornerArea(buf, buf_w, vx0, hy0, vx1, hy1, @floatFromInt(vx1), @floatFromInt(hy1), r_clip);
},
0x2570 => { // SW: up+right
const r_clip: f32 = @max(0.0, @as(f32, @floatFromInt(stroke)) * 0.5 - 0.5);
fillRect(buf, buf_w, buf_h, vx1, hy0, x1, hy1); // horizontal right of corner
fillRect(buf, buf_w, buf_h, vx0, 0, vx1, hy0); // vertical above corner
drawRoundedCornerArea(buf, buf_w, vx0, hy0, vx1, hy1, @floatFromInt(vx0), @floatFromInt(hy1), r_clip);
},
else => return false,
}
return true;
}
/// Render extended block characters: U+1FB82 (upper quarter block), and the
/// specific sextant/octant corner characters used by WidgetStyle thick-box borders.
/// Each character is rendered with the geometric shape that tiles correctly with
/// its adjacent border characters (, , , , 🮂, ).
fn renderExtendedBlocks(
cp: u21,
buf: []u8,
buf_w: i32,
buf_h: i32,
x0: i32,
cw: i32,
ch: i32,
) bool {
const x1 = x0 + cw;
const mid_x = x0 + @divTrunc(cw, 2);
const qh = @divTrunc(ch, 4); // quarter height (for octant thick-box)
const th = @divTrunc(ch, 3); // third height (for sextant thick-box)
switch (cp) {
// 🮂 U+1FB82 upper one-quarter block (north edge of octant thick-box)
0x1FB82 => fillRect(buf, buf_w, buf_h, x0, 0, x1, qh),
// Sextant thick-box characters (WidgetStyle "thick box (sextant)")
// .n = 🬂, .s = 🬭, .nw = 🬕, .ne = 🬨, .sw = 🬲, .se = 🬷
// Edges connect to (left half) and (right half) for left/right walls.
0x1FB02 => fillRect(buf, buf_w, buf_h, x0, 0, x1, th), // 🬂 top third (N edge)
0x1FB2D => fillRect(buf, buf_w, buf_h, x0, ch - th, x1, ch), // 🬭 bottom third (S edge)
0x1FB15 => { // 🬕 NW corner: left-half + top-third
fillRect(buf, buf_w, buf_h, x0, 0, x1, th);
fillRect(buf, buf_w, buf_h, x0, th, mid_x, ch);
},
0x1FB28 => { // 🬨 NE corner: right-half + top-third
fillRect(buf, buf_w, buf_h, x0, 0, x1, th);
fillRect(buf, buf_w, buf_h, mid_x, th, x1, ch);
},
0x1FB32 => { // 🬲 SW corner: left-half + bottom-third
fillRect(buf, buf_w, buf_h, x0, 0, mid_x, ch - th);
fillRect(buf, buf_w, buf_h, x0, ch - th, x1, ch);
},
0x1FB37 => { // 🬷 SE corner: right-half + bottom-third
fillRect(buf, buf_w, buf_h, mid_x, 0, x1, ch - th);
fillRect(buf, buf_w, buf_h, x0, ch - th, x1, ch);
},
// Octant thick-box corner characters (WidgetStyle "thick box (octant)")
// .n = 🮂 (qh), .s = (qh), .w = (half), .e = (half)
0x1CD4A => { // 𜵊 NW corner: left-half + top-quarter
fillRect(buf, buf_w, buf_h, x0, 0, x1, qh);
fillRect(buf, buf_w, buf_h, x0, qh, mid_x, ch);
},
0x1CD98 => { // 𜶘 NE corner: right-half + top-quarter
fillRect(buf, buf_w, buf_h, x0, 0, x1, qh);
fillRect(buf, buf_w, buf_h, mid_x, qh, x1, ch);
},
0x1CDD5 => { // 𜷕 SE corner: right-half + bottom-quarter
fillRect(buf, buf_w, buf_h, mid_x, 0, x1, ch - qh);
fillRect(buf, buf_w, buf_h, x0, ch - qh, x1, ch);
},
0x1CDC0 => { // 𜷀 SW corner: left-half + bottom-quarter
fillRect(buf, buf_w, buf_h, x0, 0, mid_x, ch - qh);
fillRect(buf, buf_w, buf_h, x0, ch - qh, x1, ch);
},
else => return false,
}
return true;
}

View file

@ -41,6 +41,8 @@ var tui_pid: thespian.pid = undefined;
var font_size_px: u16 = 16;
var font_name_buf: [256]u8 = undefined;
var font_name_len: usize = 0;
var font_weight: u8 = 0;
var font_backend: gpu.RasterizerBackend = .freetype;
var font_dirty: std.atomic.Value(bool) = .init(true);
var stop_requested: std.atomic.Value(bool) = .init(false);
@ -72,7 +74,7 @@ var cursor_dirty: std.atomic.Value(bool) = .init(false);
var attention_pending: std.atomic.Value(bool) = .init(false);
// Current font written and read only from the wio thread (after gpu.init).
var wio_font: gpu.Font = .{ .cell_size = .{ .x = 8, .y = 16 } };
var wio_font: gpu.Font = .{ .cell_size = .{ .x = 8, .y = 16 }, .backend = .{ .freetype = .{} } };
// Public API (called from tui thread)
@ -186,6 +188,28 @@ pub fn setFontFace(name: []const u8) void {
requestRender();
}
pub fn setFontWeight(weight: u8) void {
font_weight = weight;
saveConfig();
font_dirty.store(true, .release);
requestRender();
}
pub fn getFontWeight() u8 {
return font_weight;
}
pub fn setRasterizerBackend(backend: gpu.RasterizerBackend) void {
font_backend = backend;
saveConfig();
font_dirty.store(true, .release);
requestRender();
}
pub fn getRasterizerBackend() gpu.RasterizerBackend {
return font_backend;
}
pub fn setWindowTitle(title: []const u8) void {
title_mutex.lock();
defer title_mutex.unlock();
@ -237,6 +261,8 @@ pub fn loadConfig() void {
root.write_config(conf, config_arena) catch
log.err("failed to write gui config file", .{});
font_size_px = conf.fontsize;
font_weight = conf.fontweight;
font_backend = conf.fontbackend;
const name = conf.fontface;
const copy_len = @min(name.len, font_name_buf.len);
@memcpy(font_name_buf[0..copy_len], name[0..copy_len]);
@ -246,6 +272,8 @@ pub fn loadConfig() void {
fn saveConfig() void {
var conf, _ = root.read_config(gui_config, config_arena);
conf.fontsize = @truncate(font_size_px);
conf.fontweight = font_weight;
conf.fontbackend = font_backend;
conf.fontface = getFontName();
root.write_config(conf, config_arena) catch
log.err("failed to write gui config file", .{});
@ -285,7 +313,10 @@ fn pixelToCellPos(pos: wio.Position) CellPos {
fn reloadFont() void {
const name = if (font_name_len > 0) font_name_buf[0..font_name_len] else "monospace";
const size_physical: u16 = @intFromFloat(@round(@as(f32, @floatFromInt(font_size_px)) * dpi_scale));
wio_font = gpu.loadFont(name, @max(size_physical, 4)) catch return;
gpu.setRasterizerBackend(font_backend);
var f = gpu.loadFont(name, @max(size_physical, 4)) catch return;
gpu.setFontWeight(&f, font_weight);
wio_font = f;
}
// Check dirty flag and reload if needed.

View file

@ -1,7 +1,14 @@
fontface: []const u8 = "Cascadia Code",
fontface: []const u8 = "IosevkaTerm Nerd Font Mono",
fontsize: u8 = 14,
fontweight: u8 = 0,
fontbackend: RasterizerBackend = .freetype,
initial_window_x: u16 = 1087,
initial_window_y: u16 = 1014,
include_files: []const u8 = "",
pub const RasterizerBackend = enum {
truetype,
freetype,
};