From d3aa7e17f53f0af5412f3aeb4f5904f8189656a1 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 3 Apr 2026 22:39:40 +0200 Subject: [PATCH] feat(gui): add freetype rasterizer backend --- build.zig | 46 ++- src/gui/gpu/gpu.zig | 9 + src/gui/rasterizer/combined.zig | 86 ++++++ src/gui/rasterizer/freetype.zig | 149 ++++++++++ src/gui/rasterizer/geometric.zig | 396 +++++++++++++++++++++++++ src/gui/rasterizer/truetype.zig | 477 ++----------------------------- src/gui/wio/app.zig | 35 ++- src/gui_config.zig | 9 +- 8 files changed, 751 insertions(+), 456 deletions(-) create mode 100644 src/gui/rasterizer/combined.zig create mode 100644 src/gui/rasterizer/freetype.zig create mode 100644 src/gui/rasterizer/geometric.zig diff --git a/build.zig b/build.zig index f73830e8..16287ad0 100644 --- a/build.zig +++ b/build.zig @@ -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; diff --git a/src/gui/gpu/gpu.zig b/src/gui/gpu/gpu.zig index f50c61d0..d741adbc 100644 --- a/src/gui/gpu/gpu.zig +++ b/src/gui/gpu/gpu.zig @@ -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; } diff --git a/src/gui/rasterizer/combined.zig b/src/gui/rasterizer/combined.zig new file mode 100644 index 00000000..b30a8da8 --- /dev/null +++ b/src/gui/rasterizer/combined.zig @@ -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), + } +} diff --git a/src/gui/rasterizer/freetype.zig b/src/gui/rasterizer/freetype.zig new file mode 100644 index 00000000..d1d86fca --- /dev/null +++ b/src/gui/rasterizer/freetype.zig @@ -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]; + } + } +} diff --git a/src/gui/rasterizer/geometric.zig b/src/gui/rasterizer/geometric.zig new file mode 100644 index 00000000..e3d021e5 --- /dev/null +++ b/src/gui/rasterizer/geometric.zig @@ -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+2580–U+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+2500–U+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; +} diff --git a/src/gui/rasterizer/truetype.zig b/src/gui/rasterizer/truetype.zig index 9ec784e4..e8ac9165 100644 --- a/src/gui/rasterizer/truetype.zig +++ b/src/gui/rasterizer/truetype.zig @@ -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+2580–U+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+2500–U+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; -} diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig index 6474f4b7..0b1e06bd 100644 --- a/src/gui/wio/app.zig +++ b/src/gui/wio/app.zig @@ -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. diff --git a/src/gui_config.zig b/src/gui_config.zig index 47205156..218e87d5 100644 --- a/src/gui_config.zig +++ b/src/gui_config.zig @@ -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, +};