- fillRect: disable runtime safety for hot path (bounds pre-validated) - drawGlyphBitmap: disable runtime safety for hot path - Context: add burst detection (isSelectionBurstActive, markNavigationEvent) - PanelFrameConfig: add burst_sensitive field - PanelFrameResult: new struct for frame drawing results Note: burst_sensitive mechanism available but not actively used (causes visual flickering when suppressing panel content) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
591 lines
19 KiB
Zig
591 lines
19 KiB
Zig
//! TTF Font Support
|
|
//!
|
|
//! TrueType font loading and rendering support.
|
|
//! Uses zcatttf library for parsing and rasterization.
|
|
//!
|
|
//! Features:
|
|
//! - Load TTF files from memory or file
|
|
//! - Rasterize glyphs at any size with antialiasing
|
|
//! - Glyph caching for performance
|
|
|
|
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
const Style = @import("../core/style.zig");
|
|
const Layout = @import("../core/layout.zig");
|
|
const Framebuffer = @import("framebuffer.zig").Framebuffer;
|
|
|
|
// Use zcatttf library for TTF parsing and rasterization
|
|
const zcatttf = @import("zcatttf");
|
|
|
|
const Color = Style.Color;
|
|
const Rect = Layout.Rect;
|
|
|
|
// =============================================================================
|
|
// TTF Data Types (compatibility layer)
|
|
// =============================================================================
|
|
|
|
/// Glyph metrics
|
|
pub const GlyphMetrics = struct {
|
|
/// Width of the glyph bitmap
|
|
width: u16 = 0,
|
|
/// Height of the glyph bitmap
|
|
height: u16 = 0,
|
|
/// X bearing (left side bearing)
|
|
bearing_x: i16 = 0,
|
|
/// Y bearing (top side bearing from baseline)
|
|
bearing_y: i16 = 0,
|
|
/// Advance width to next character
|
|
advance: u16 = 0,
|
|
};
|
|
|
|
/// Cached glyph
|
|
const CachedGlyph = struct {
|
|
/// Bitmap data (alpha values 0-255)
|
|
bitmap: []u8,
|
|
/// Metrics
|
|
metrics: GlyphMetrics,
|
|
/// Character code
|
|
codepoint: u32,
|
|
};
|
|
|
|
/// Font metrics
|
|
pub const FontMetrics = struct {
|
|
/// Ascent (above baseline)
|
|
ascent: i16 = 0,
|
|
/// Descent (below baseline, typically negative)
|
|
descent: i16 = 0,
|
|
/// Line gap
|
|
line_gap: i16 = 0,
|
|
/// Units per em
|
|
units_per_em: u16 = 2048,
|
|
};
|
|
|
|
// =============================================================================
|
|
// TTF Font (wrapper around zcatttf)
|
|
// =============================================================================
|
|
|
|
/// TrueType font
|
|
pub const TtfFont = struct {
|
|
allocator: Allocator,
|
|
|
|
/// zcatttf font instance
|
|
font: zcatttf.Font,
|
|
|
|
/// Raw font data (kept for reference)
|
|
data: []const u8,
|
|
/// Whether we own the data
|
|
owns_data: bool = false,
|
|
|
|
/// Font metrics (cached)
|
|
metrics: FontMetrics = .{},
|
|
|
|
/// Number of glyphs
|
|
num_glyphs: u16 = 0,
|
|
|
|
/// Glyph cache (for rendered glyphs)
|
|
glyph_cache: std.AutoHashMap(u64, CachedGlyph),
|
|
|
|
/// Current render size
|
|
render_size: u16 = 16,
|
|
|
|
/// Scale factor for current size
|
|
scale: f32 = 1.0,
|
|
|
|
// Legacy fields for compatibility (populated from zcatttf)
|
|
cmap_offset: u32 = 0,
|
|
glyf_offset: u32 = 0,
|
|
loca_offset: u32 = 0,
|
|
index_to_loc_format: i16 = 0,
|
|
|
|
const Self = @This();
|
|
|
|
/// Load font from file
|
|
pub fn loadFromFile(allocator: Allocator, path: []const u8) !Self {
|
|
const file = try std.fs.cwd().openFile(path, .{});
|
|
defer file.close();
|
|
|
|
const stat = try file.stat();
|
|
const data = try allocator.alloc(u8, stat.size);
|
|
|
|
const bytes_read = try file.readAll(data);
|
|
if (bytes_read != stat.size) {
|
|
allocator.free(data);
|
|
return error.IncompleteRead;
|
|
}
|
|
|
|
var ttf = try initFromMemory(allocator, data);
|
|
ttf.owns_data = true;
|
|
return ttf;
|
|
}
|
|
|
|
/// Initialize font from memory
|
|
pub fn initFromMemory(allocator: Allocator, data: []const u8) !Self {
|
|
// Initialize zcatttf font
|
|
var font = try zcatttf.Font.init(allocator, data);
|
|
|
|
// Get vertical metrics
|
|
const vmetrics = font.getVMetrics();
|
|
|
|
var self = Self{
|
|
.allocator = allocator,
|
|
.font = font,
|
|
.data = data,
|
|
.glyph_cache = std.AutoHashMap(u64, CachedGlyph).init(allocator),
|
|
.num_glyphs = font.getNumGlyphs(),
|
|
.metrics = FontMetrics{
|
|
.ascent = vmetrics.ascender,
|
|
.descent = vmetrics.descender,
|
|
.line_gap = vmetrics.line_gap,
|
|
.units_per_em = font.getUnitsPerEm(),
|
|
},
|
|
};
|
|
|
|
self.setSize(16);
|
|
return self;
|
|
}
|
|
|
|
/// Initialize from embedded font (DroidSans)
|
|
pub fn initEmbedded(allocator: Allocator) !Self {
|
|
const embedded = @import("embedded_font.zig");
|
|
return initFromMemory(allocator, embedded.font_data);
|
|
}
|
|
|
|
/// Deinitialize font
|
|
pub fn deinit(self: *Self) void {
|
|
// Free cached glyphs
|
|
var it = self.glyph_cache.iterator();
|
|
while (it.next()) |entry| {
|
|
self.allocator.free(entry.value_ptr.bitmap);
|
|
}
|
|
self.glyph_cache.deinit();
|
|
|
|
// Deinit zcatttf font
|
|
self.font.deinit();
|
|
|
|
// Free data if we own it
|
|
if (self.owns_data) {
|
|
self.allocator.free(@constCast(self.data));
|
|
}
|
|
}
|
|
|
|
/// Set render size
|
|
pub fn setSize(self: *Self, size: u16) void {
|
|
self.render_size = size;
|
|
self.scale = @as(f32, @floatFromInt(size)) / @as(f32, @floatFromInt(self.metrics.units_per_em));
|
|
self.font.setPixelHeight(@floatFromInt(size));
|
|
}
|
|
|
|
/// Get glyph index for codepoint
|
|
pub fn getGlyphIndex(self: Self, codepoint: u32) u16 {
|
|
return self.font.getGlyphIndex(@intCast(codepoint));
|
|
}
|
|
|
|
/// Get horizontal metrics for glyph
|
|
pub fn getHMetrics(self: Self, glyph_index: u16) struct { advance: u16, lsb: i16 } {
|
|
const hm = self.font.getScaledHMetrics(glyph_index);
|
|
return .{
|
|
.advance = @intFromFloat(hm.advance_width),
|
|
.lsb = @intFromFloat(hm.left_side_bearing),
|
|
};
|
|
}
|
|
|
|
/// Get glyph metrics (scaled)
|
|
pub fn getGlyphMetrics(self: Self, codepoint: u32) GlyphMetrics {
|
|
const glyph_index = self.getGlyphIndex(codepoint);
|
|
const h_metrics = self.getHMetrics(glyph_index);
|
|
|
|
return GlyphMetrics{
|
|
.advance = h_metrics.advance,
|
|
.bearing_x = h_metrics.lsb,
|
|
};
|
|
}
|
|
|
|
/// Get text width
|
|
pub fn textWidth(self: Self, text: []const u8) u32 {
|
|
var width: u32 = 0;
|
|
var i: usize = 0;
|
|
while (i < text.len) {
|
|
const codepoint = decodeUtf8(text[i..]) orelse {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
const metrics = self.getGlyphMetrics(codepoint.char);
|
|
width += metrics.advance;
|
|
i += codepoint.len;
|
|
}
|
|
return width;
|
|
}
|
|
|
|
/// Get line height
|
|
pub fn lineHeight(self: Self) u32 {
|
|
const vm = self.font.getScaledVMetrics();
|
|
return @intFromFloat(vm.ascender - vm.descender + vm.line_gap);
|
|
}
|
|
|
|
/// Get ascent (scaled)
|
|
pub fn ascent(self: Self) i32 {
|
|
const vm = self.font.getScaledVMetrics();
|
|
return @intFromFloat(vm.ascender);
|
|
}
|
|
|
|
/// Get descent (scaled)
|
|
pub fn descent(self: Self) i32 {
|
|
const vm = self.font.getScaledVMetrics();
|
|
return @intFromFloat(vm.descender);
|
|
}
|
|
|
|
/// Draw text using TTF font with real glyph rasterization
|
|
pub fn drawText(
|
|
self: *Self,
|
|
fb: *Framebuffer,
|
|
x: i32,
|
|
y: i32,
|
|
text: []const u8,
|
|
color: Color,
|
|
clip: Rect,
|
|
) void {
|
|
var cx = x;
|
|
const baseline_y = y + self.ascent();
|
|
|
|
var i: usize = 0;
|
|
while (i < text.len) {
|
|
// Decode UTF-8
|
|
const decoded = decodeUtf8(text[i..]) orelse {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
const codepoint = decoded.char;
|
|
i += decoded.len;
|
|
|
|
if (codepoint == '\n') continue;
|
|
if (codepoint == ' ') {
|
|
const metrics = self.getGlyphMetrics(codepoint);
|
|
cx += @intCast(metrics.advance);
|
|
continue;
|
|
}
|
|
|
|
// Try to get cached glyph or rasterize
|
|
const cache_key = makeCacheKey(codepoint, self.render_size);
|
|
|
|
if (self.glyph_cache.get(cache_key)) |cached| {
|
|
// Draw cached glyph
|
|
self.drawGlyphBitmap(fb, cx, baseline_y, cached, color, clip);
|
|
cx += @intCast(cached.metrics.advance);
|
|
} else {
|
|
// Rasterize using zcatttf
|
|
const glyph_index = self.getGlyphIndex(codepoint);
|
|
if (glyph_index == 0) continue;
|
|
|
|
if (self.font.rasterizeGlyph(glyph_index)) |bitmap| {
|
|
var bitmap_mut = bitmap;
|
|
defer bitmap_mut.deinit();
|
|
|
|
// Copy bitmap data for cache
|
|
const bitmap_data = self.allocator.alloc(u8, bitmap.data.len) catch continue;
|
|
@memcpy(bitmap_data, bitmap.data);
|
|
|
|
const cached_glyph = CachedGlyph{
|
|
.bitmap = bitmap_data,
|
|
.metrics = GlyphMetrics{
|
|
.width = @intCast(bitmap.width),
|
|
.height = @intCast(bitmap.height),
|
|
.bearing_x = @intCast(bitmap.x_offset),
|
|
.bearing_y = @intCast(bitmap.y_offset),
|
|
.advance = @intFromFloat(bitmap.advance_width),
|
|
},
|
|
.codepoint = codepoint,
|
|
};
|
|
|
|
self.glyph_cache.put(cache_key, cached_glyph) catch {};
|
|
self.drawGlyphBitmap(fb, cx, baseline_y, cached_glyph, color, clip);
|
|
cx += @intCast(cached_glyph.metrics.advance);
|
|
} else |_| {
|
|
// Rasterization failed, skip character
|
|
const metrics = self.getGlyphMetrics(codepoint);
|
|
cx += @intCast(metrics.advance);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Draw a cached glyph bitmap with alpha blending
|
|
/// Optimized: pre-calculate visible region, direct pixel access
|
|
fn drawGlyphBitmap(
|
|
self: Self,
|
|
fb: *Framebuffer,
|
|
x: i32,
|
|
baseline_y: i32,
|
|
glyph: CachedGlyph,
|
|
color: Color,
|
|
clip: Rect,
|
|
) void {
|
|
@setRuntimeSafety(false); // Hot path: bounds validated in visible region calculation
|
|
_ = self;
|
|
|
|
const width: u32 = glyph.metrics.width;
|
|
const height: u32 = glyph.metrics.height;
|
|
if (width == 0 or height == 0) return;
|
|
|
|
// Calculate glyph position
|
|
const glyph_x = x + glyph.metrics.bearing_x;
|
|
const glyph_y = baseline_y - glyph.metrics.bearing_y;
|
|
|
|
// Early exit: entire glyph outside clip or framebuffer
|
|
const glyph_right = glyph_x + @as(i32, @intCast(width));
|
|
const glyph_bottom = glyph_y + @as(i32, @intCast(height));
|
|
const clip_right = clip.x + @as(i32, @intCast(clip.w));
|
|
const clip_bottom = clip.y + @as(i32, @intCast(clip.h));
|
|
|
|
if (glyph_right <= clip.x or glyph_x >= clip_right) return;
|
|
if (glyph_bottom <= clip.y or glyph_y >= clip_bottom) return;
|
|
if (glyph_right <= 0 or glyph_x >= @as(i32, @intCast(fb.width))) return;
|
|
if (glyph_bottom <= 0 or glyph_y >= @as(i32, @intCast(fb.height))) return;
|
|
|
|
// Calculate visible region (intersection of glyph, clip, and framebuffer)
|
|
const vis_x0 = @max(0, @max(glyph_x, clip.x));
|
|
const vis_y0 = @max(0, @max(glyph_y, clip.y));
|
|
const vis_x1 = @min(@as(i32, @intCast(fb.width)), @min(glyph_right, clip_right));
|
|
const vis_y1 = @min(@as(i32, @intCast(fb.height)), @min(glyph_bottom, clip_bottom));
|
|
|
|
if (vis_x0 >= vis_x1 or vis_y0 >= vis_y1) return;
|
|
|
|
// Precompute color for blending (u32 to avoid per-pixel struct creation)
|
|
const color_r: u32 = color.r;
|
|
const color_g: u32 = color.g;
|
|
const color_b: u32 = color.b;
|
|
const color_packed = color.toABGR();
|
|
|
|
// Draw only visible region with direct pixel access
|
|
var screen_y = vis_y0;
|
|
while (screen_y < vis_y1) : (screen_y += 1) {
|
|
const glyph_py: u32 = @intCast(screen_y - glyph_y);
|
|
const dst_row: u32 = @intCast(screen_y);
|
|
const dst_row_start = dst_row * fb.width;
|
|
const src_row_start = glyph_py * width;
|
|
|
|
var screen_x = vis_x0;
|
|
while (screen_x < vis_x1) : (screen_x += 1) {
|
|
const glyph_px: u32 = @intCast(screen_x - glyph_x);
|
|
const alpha = glyph.bitmap[src_row_start + glyph_px];
|
|
if (alpha == 0) continue;
|
|
|
|
const dst_idx = dst_row_start + @as(u32, @intCast(screen_x));
|
|
|
|
if (alpha == 255) {
|
|
// Fully opaque: direct write
|
|
fb.pixels[dst_idx] = color_packed;
|
|
} else {
|
|
// Alpha blend with direct u32 math
|
|
const bg = fb.pixels[dst_idx];
|
|
const bg_r: u32 = bg & 0xFF;
|
|
const bg_g: u32 = (bg >> 8) & 0xFF;
|
|
const bg_b: u32 = (bg >> 16) & 0xFF;
|
|
|
|
const a: u32 = alpha;
|
|
const inv_a: u32 = 255 - a;
|
|
|
|
const out_r: u8 = @intCast((color_r * a + bg_r * inv_a) / 255);
|
|
const out_g: u8 = @intCast((color_g * a + bg_g * inv_a) / 255);
|
|
const out_b: u8 = @intCast((color_b * a + bg_b * inv_a) / 255);
|
|
|
|
fb.pixels[dst_idx] = @as(u32, out_r) | (@as(u32, out_g) << 8) | (@as(u32, out_b) << 16) | (0xFF << 24);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/// Create cache key from codepoint and size
|
|
fn makeCacheKey(codepoint: u32, size: u16) u64 {
|
|
return (@as(u64, codepoint) << 16) | @as(u64, size);
|
|
}
|
|
|
|
/// Blend foreground color with background using alpha
|
|
fn blendColors(fg: Color, bg: Color, alpha: u8) Color {
|
|
const a = @as(u16, alpha);
|
|
const inv_a = 255 - a;
|
|
|
|
return Color{
|
|
.r = @intCast((@as(u16, fg.r) * a + @as(u16, bg.r) * inv_a) / 255),
|
|
.g = @intCast((@as(u16, fg.g) * a + @as(u16, bg.g) * inv_a) / 255),
|
|
.b = @intCast((@as(u16, fg.b) * a + @as(u16, bg.b) * inv_a) / 255),
|
|
.a = 255,
|
|
};
|
|
}
|
|
|
|
/// Decode UTF-8 codepoint from byte slice
|
|
fn decodeUtf8(bytes: []const u8) ?struct { char: u32, len: usize } {
|
|
if (bytes.len == 0) return null;
|
|
|
|
const b0 = bytes[0];
|
|
|
|
// ASCII (0xxxxxxx)
|
|
if (b0 < 0x80) {
|
|
return .{ .char = b0, .len = 1 };
|
|
}
|
|
|
|
// 2-byte sequence (110xxxxx 10xxxxxx)
|
|
if (b0 >= 0xC0 and b0 < 0xE0) {
|
|
if (bytes.len < 2) return null;
|
|
const b1 = bytes[1];
|
|
if ((b1 & 0xC0) != 0x80) return null;
|
|
const char = (@as(u32, b0 & 0x1F) << 6) | @as(u32, b1 & 0x3F);
|
|
return .{ .char = char, .len = 2 };
|
|
}
|
|
|
|
// 3-byte sequence (1110xxxx 10xxxxxx 10xxxxxx)
|
|
if (b0 >= 0xE0 and b0 < 0xF0) {
|
|
if (bytes.len < 3) return null;
|
|
const b1 = bytes[1];
|
|
const b2 = bytes[2];
|
|
if ((b1 & 0xC0) != 0x80 or (b2 & 0xC0) != 0x80) return null;
|
|
const char = (@as(u32, b0 & 0x0F) << 12) | (@as(u32, b1 & 0x3F) << 6) | @as(u32, b2 & 0x3F);
|
|
return .{ .char = char, .len = 3 };
|
|
}
|
|
|
|
// 4-byte sequence (11110xxx 10xxxxxx 10xxxxxx 10xxxxxx)
|
|
if (b0 >= 0xF0 and b0 < 0xF8) {
|
|
if (bytes.len < 4) return null;
|
|
const b1 = bytes[1];
|
|
const b2 = bytes[2];
|
|
const b3 = bytes[3];
|
|
if ((b1 & 0xC0) != 0x80 or (b2 & 0xC0) != 0x80 or (b3 & 0xC0) != 0x80) return null;
|
|
const char = (@as(u32, b0 & 0x07) << 18) | (@as(u32, b1 & 0x3F) << 12) | (@as(u32, b2 & 0x3F) << 6) | @as(u32, b3 & 0x3F);
|
|
return .{ .char = char, .len = 4 };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Font Interface - Unified API for both bitmap and TTF fonts
|
|
// =============================================================================
|
|
|
|
/// Font type tag
|
|
pub const FontType = enum {
|
|
bitmap,
|
|
ttf,
|
|
};
|
|
|
|
/// Unified font reference
|
|
pub const FontRef = union(FontType) {
|
|
bitmap: *const @import("font.zig").Font,
|
|
ttf: *TtfFont,
|
|
|
|
pub fn textWidth(self: FontRef, text: []const u8) u32 {
|
|
return switch (self) {
|
|
.bitmap => |f| f.textWidth(text),
|
|
.ttf => |f| f.textWidth(text),
|
|
};
|
|
}
|
|
|
|
pub fn charHeight(self: FontRef) u32 {
|
|
return switch (self) {
|
|
.bitmap => |f| f.charHeight(),
|
|
.ttf => |f| f.lineHeight(),
|
|
};
|
|
}
|
|
|
|
pub fn drawText(
|
|
self: FontRef,
|
|
fb: *Framebuffer,
|
|
x: i32,
|
|
y: i32,
|
|
text: []const u8,
|
|
color: Color,
|
|
clip: Rect,
|
|
) void {
|
|
switch (self) {
|
|
.bitmap => |f| f.drawText(fb, x, y, text, color, clip),
|
|
.ttf => |f| @constCast(f).drawText(fb, x, y, text, color, clip),
|
|
}
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// Legacy exports for compatibility
|
|
// =============================================================================
|
|
|
|
/// Rasterized glyph bitmap (legacy, for code that uses this directly)
|
|
pub const GlyphBitmap = struct {
|
|
data: []u8,
|
|
width: u32,
|
|
height: u32,
|
|
bearing_x: i32,
|
|
bearing_y: i32,
|
|
allocator: Allocator,
|
|
|
|
pub fn deinit(self: *GlyphBitmap) void {
|
|
self.allocator.free(self.data);
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "TTF types" {
|
|
const metrics = GlyphMetrics{
|
|
.width = 10,
|
|
.height = 12,
|
|
.bearing_x = 1,
|
|
.bearing_y = 10,
|
|
.advance = 8,
|
|
};
|
|
|
|
try std.testing.expectEqual(@as(u16, 10), metrics.width);
|
|
try std.testing.expectEqual(@as(u16, 8), metrics.advance);
|
|
}
|
|
|
|
test "FontRef bitmap" {
|
|
const bitmap_font = @import("font.zig");
|
|
const font_ref = FontRef{ .bitmap = &bitmap_font.default_font };
|
|
|
|
try std.testing.expectEqual(@as(u32, 40), font_ref.textWidth("Hello"));
|
|
try std.testing.expectEqual(@as(u32, 8), font_ref.charHeight());
|
|
}
|
|
|
|
test "decodeUtf8" {
|
|
// ASCII
|
|
const ascii = decodeUtf8("A").?;
|
|
try std.testing.expectEqual(@as(u32, 'A'), ascii.char);
|
|
try std.testing.expectEqual(@as(usize, 1), ascii.len);
|
|
|
|
// 2-byte (é = 0xC3 0xA9)
|
|
const e_acute = decodeUtf8("\xC3\xA9").?;
|
|
try std.testing.expectEqual(@as(u32, 0xE9), e_acute.char);
|
|
try std.testing.expectEqual(@as(usize, 2), e_acute.len);
|
|
|
|
// 3-byte (€ = 0xE2 0x82 0xAC)
|
|
const euro = decodeUtf8("\xE2\x82\xAC").?;
|
|
try std.testing.expectEqual(@as(u32, 0x20AC), euro.char);
|
|
try std.testing.expectEqual(@as(usize, 3), euro.len);
|
|
}
|
|
|
|
test "TtfFont with zcatttf" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
// Try to load DroidSans from system
|
|
var font = TtfFont.loadFromFile(allocator, "/usr/share/fonts/google-droid-sans-fonts/DroidSans.ttf") catch {
|
|
// Font not available on this system, skip test
|
|
return;
|
|
};
|
|
defer font.deinit();
|
|
|
|
// Verify font was loaded
|
|
try std.testing.expect(font.num_glyphs > 0);
|
|
try std.testing.expect(font.metrics.units_per_em > 0);
|
|
|
|
// Get glyph index for 'A'
|
|
const glyph_index = font.getGlyphIndex('A');
|
|
try std.testing.expect(glyph_index > 0);
|
|
|
|
// Get metrics
|
|
const metrics = font.getGlyphMetrics('A');
|
|
try std.testing.expect(metrics.advance > 0);
|
|
|
|
// Test text width
|
|
const width = font.textWidth("Hello");
|
|
try std.testing.expect(width > 0);
|
|
}
|