src/gui/font.zig
Ref: Size: 24.4 KiB History
//! Ordered Fontconfig-selected monospace families, whole-cell HarfBuzz
//! shaping, and FreeType rasterization by face and glyph ID. No automatic
//! system fallback or colour faces.
const std = @import("std");
const atlas = @import("atlas.zig");
const quads = @import("quads.zig");
const c = @cImport({
@cInclude("fontconfig/fontconfig.h");
@cInclude("freetype2/freetype/freetype.h");
@cInclude("freetype2/freetype/ftsynth.h");
@cInclude("harfbuzz/hb.h");
@cInclude("harfbuzz/hb-ft.h");
});
/// Base font pixels are measured at 100% content scale. The returned size
/// is the bitmap resolution in framebuffer pixels, applied exactly once.
pub fn scaledPixels(base: u16, display_scale: f32) u16 {
const scale = if (std.math.isFinite(display_scale) and display_scale > 0) display_scale else 1;
const px = @round(@as(f32, @floatFromInt(base)) * scale);
return @intFromFloat(std.math.clamp(px, 1, 1024));
}
pub fn scaledPoints(points: f64, display_scale: f32) u16 {
const dpi: f64 = if (@import("builtin").os.tag == .macos) 72 else 96;
const scale = if (std.math.isFinite(display_scale) and display_scale > 0) @as(f64, display_scale) else 1;
return @intCast(std.math.clamp(@as(i64, @intFromFloat(@round(points * dpi / 72 * scale))), 1, 1024));
}
pub fn atlasWidth(px: u16) u16 {
return @intCast(@max(@as(u32, px) * 2, 1024));
}
pub const Variant = atlas.Variant;
pub const PositionedGlyph = struct { glyph_id: u32, x_advance: i32, y_advance: i32, x_offset: i32, y_offset: i32 };
pub const Run = struct {
glyphs: []PositionedGlyph,
pub fn deinit(self: *Run, alloc: std.mem.Allocator) void {
alloc.free(self.glyphs);
}
};
pub const Glyph = struct {
w: u16,
h: u16,
left: i16,
top: i16,
pixels: []u8,
pub fn deinit(self: *Glyph, alloc: std.mem.Allocator) void {
alloc.free(self.pixels);
}
};
const Handle = struct { face: c.FT_Face, hb: *c.hb_font_t, synth_bold: bool, synth_italic: bool };
pub const Face = struct {
lib: c.FT_Library,
handles: [4]Handle,
cell_w: u16,
cell_h: u16,
ascent: u16,
pixels: u16,
pub const Error = error{ NoFontconfig, NoMonospaceFace, FreetypeInit, FaceLoad, SizeSet, Shape, GlyphLoad, OutOfMemory };
fn handle(self: *Face, v: Variant) *Handle {
return &self.handles[@intFromEnum(v)];
}
fn match(buf: *[std.fs.max_path_bytes]u8, index: *c_int, family: [:0]const u8, want_bold: bool, want_italic: bool, synth_bold: *bool, synth_italic: *bool) Error![]const u8 {
const pat = c.FcPatternCreate() orelse return error.NoMonospaceFace;
defer c.FcPatternDestroy(pat);
_ = c.FcPatternAddString(pat, c.FC_FAMILY, @ptrCast(family.ptr));
_ = c.FcPatternAddInteger(pat, c.FC_SPACING, c.FC_MONO);
_ = c.FcPatternAddInteger(pat, c.FC_WEIGHT, if (want_bold) c.FC_WEIGHT_BOLD else c.FC_WEIGHT_REGULAR);
_ = c.FcPatternAddInteger(pat, c.FC_SLANT, if (want_italic) c.FC_SLANT_ITALIC else c.FC_SLANT_ROMAN);
_ = c.FcConfigSubstitute(null, pat, c.FcMatchPattern);
c.FcDefaultSubstitute(pat);
var result: c.FcResult = undefined;
const found = c.FcFontMatch(null, pat, &result) orelse return error.NoMonospaceFace;
defer c.FcPatternDestroy(found);
// Fontconfig treats family and spacing as preferences, so a successful
// match alone could silently replace a typo or select proportional text.
var spacing: c_int = 0;
if (c.FcPatternGetInteger(found, c.FC_SPACING, 0, &spacing) != c.FcResultMatch or
(spacing != c.FC_MONO and spacing != c.FC_CHARCELL)) return error.NoMonospaceFace;
if (!std.ascii.eqlIgnoreCase(family, "monospace")) {
var matched_family: [*c]c.FcChar8 = null;
var family_index: c_int = 0;
var exact = false;
while (c.FcPatternGetString(found, c.FC_FAMILY, family_index, &matched_family) == c.FcResultMatch) : (family_index += 1) {
if (std.ascii.eqlIgnoreCase(family, std.mem.span(@as([*:0]const u8, @ptrCast(matched_family))))) {
exact = true;
break;
}
}
if (!exact) return error.NoMonospaceFace;
}
var file: [*c]c.FcChar8 = null;
if (c.FcPatternGetString(found, c.FC_FILE, 0, &file) != c.FcResultMatch) return error.NoMonospaceFace;
if (c.FcPatternGetInteger(found, c.FC_INDEX, 0, index) != c.FcResultMatch) index.* = 0;
var weight: c_int = c.FC_WEIGHT_REGULAR;
var slant: c_int = c.FC_SLANT_ROMAN;
_ = c.FcPatternGetInteger(found, c.FC_WEIGHT, 0, &weight);
_ = c.FcPatternGetInteger(found, c.FC_SLANT, 0, &slant);
synth_bold.* = want_bold and weight < c.FC_WEIGHT_DEMIBOLD;
synth_italic.* = want_italic and slant == c.FC_SLANT_ROMAN;
const path = std.mem.span(@as([*:0]const u8, @ptrCast(file)));
if (path.len >= buf.len) return error.NoMonospaceFace;
@memcpy(buf[0..path.len], path);
buf[path.len] = 0;
return buf[0..path.len];
}
fn isMonospaced(ft: c.FT_Face) bool {
if (ft.*.face_flags & c.FT_FACE_FLAG_FIXED_WIDTH != 0) return true;
// Some monospace families (including Noto Sans Mono) omit the fixed
// pitch flag. Compare unscaled ASCII advances: pixel hinting at a small
// size could otherwise make a proportional face look fixed-width.
if (c.FT_Load_Char(ft, 'M', c.FT_LOAD_NO_SCALE) != 0) return false;
const advance = ft.*.glyph.*.metrics.horiAdvance;
if (advance <= 0) return false;
for (32..127) |cp| {
if (c.FT_Load_Char(ft, @intCast(cp), c.FT_LOAD_NO_SCALE) != 0 or
ft.*.glyph.*.metrics.horiAdvance != advance) return false;
}
return true;
}
pub fn open(px: u16) Error!Face {
return openFamily(px, "monospace");
}
pub fn openFamily(px: u16, family: []const u8) Error!Face {
var family_z: [256:0]u8 = undefined;
if (family.len == 0 or family.len >= family_z.len or std.mem.indexOfScalar(u8, family, 0) != null) return error.NoMonospaceFace;
@memcpy(family_z[0..family.len], family);
family_z[family.len] = 0;
if (c.FcInit() == c.FcFalse) return error.NoFontconfig;
var lib: c.FT_Library = null;
if (c.FT_Init_FreeType(&lib) != 0) return error.FreetypeInit;
errdefer _ = c.FT_Done_FreeType(lib);
var hs: [4]Handle = undefined;
var made: usize = 0;
errdefer for (hs[0..made]) |h| {
c.hb_font_destroy(h.hb);
_ = c.FT_Done_Face(h.face);
};
for (0..4) |i| {
const v: Variant = @enumFromInt(i);
const bold = v == .bold or v == .bold_italic;
const italic = v == .italic or v == .bold_italic;
var pathbuf: [std.fs.max_path_bytes]u8 = undefined;
var idx: c_int = 0;
var synth_bold = false;
var synth_italic = false;
const path = try match(&pathbuf, &idx, family_z[0..family.len :0], bold, italic, &synth_bold, &synth_italic);
var ft: c.FT_Face = null;
if (c.FT_New_Face(lib, @ptrCast(path.ptr), idx, &ft) != 0) return error.FaceLoad;
// A requested FC_SPACING may be copied into a match whose font did
// not declare spacing. Verify the loaded face instead of that hint.
if (!isMonospaced(ft)) {
_ = c.FT_Done_Face(ft);
return error.NoMonospaceFace;
}
if (c.FT_Set_Pixel_Sizes(ft, 0, px) != 0) {
_ = c.FT_Done_Face(ft);
return error.SizeSet;
}
const hb = c.hb_ft_font_create_referenced(ft) orelse {
_ = c.FT_Done_Face(ft);
return error.FaceLoad;
};
hs[i] = .{ .face = ft, .hb = hb, .synth_bold = synth_bold, .synth_italic = synth_italic };
made += 1;
}
const m = hs[0].face.*.size.*.metrics;
if (c.FT_Load_Char(hs[0].face, 'M', c.FT_LOAD_DEFAULT) != 0) return error.GlyphLoad;
const w = @max(@as(i64, @intCast((hs[0].face.*.glyph.*.advance.x + 63) >> 6)), 1);
const h = @max(@as(i64, @intCast((m.height + 63) >> 6)), 1);
const asc: i64 = @intCast((m.ascender + 63) >> 6);
return .{ .lib = lib, .handles = hs, .cell_w = @intCast(w), .cell_h = @intCast(h), .ascent = @intCast(std.math.clamp(asc, 1, h)), .pixels = px };
}
pub fn deinit(self: *Face) void {
for (&self.handles) |*h| {
c.hb_font_destroy(h.hb);
_ = c.FT_Done_Face(h.face);
}
_ = c.FT_Done_FreeType(self.lib);
}
pub fn shape(self: *Face, alloc: std.mem.Allocator, text: []const u8, v: Variant) Error!Run {
const b = c.hb_buffer_create() orelse return error.Shape;
defer c.hb_buffer_destroy(b);
c.hb_buffer_add_utf8(b, text.ptr, @intCast(text.len), 0, @intCast(text.len));
c.hb_buffer_guess_segment_properties(b);
c.hb_shape(self.handle(v).hb, b, null, 0);
var n: c_uint = 0;
const infos = c.hb_buffer_get_glyph_infos(b, &n);
const pos = c.hb_buffer_get_glyph_positions(b, &n);
const out = try alloc.alloc(PositionedGlyph, n);
for (out, 0..) |*g, i| g.* = .{ .glyph_id = infos[i].codepoint, .x_advance = pos[i].x_advance, .y_advance = pos[i].y_advance, .x_offset = pos[i].x_offset, .y_offset = pos[i].y_offset };
return .{ .glyphs = out };
}
pub fn hasCodepoint(self: *const Face, v: Variant, cp: u21) bool {
return c.FT_Get_Char_Index(self.handles[@intFromEnum(v)].face, cp) != 0;
}
pub fn renderGlyph(self: *Face, alloc: std.mem.Allocator, v: Variant, id: u32) Error!Glyph {
const h = self.handle(v);
if (c.FT_Load_Glyph(h.face, id, c.FT_LOAD_DEFAULT) != 0) return error.GlyphLoad;
if (h.synth_bold) c.FT_GlyphSlot_Embolden(h.face.*.glyph);
if (h.synth_italic) c.FT_GlyphSlot_Oblique(h.face.*.glyph);
if (c.FT_Render_Glyph(h.face.*.glyph, c.FT_RENDER_MODE_NORMAL) != 0) return error.GlyphLoad;
const s = h.face.*.glyph;
const bm = s.*.bitmap;
const w: u16 = @intCast(bm.width);
const rows: u16 = @intCast(bm.rows);
const pixels = try alloc.alloc(u8, @as(usize, w) * rows);
for (0..rows) |r| {
const pitch: usize = @intCast(@abs(bm.pitch));
const source_row = if (bm.pitch >= 0) r else @as(usize, rows) - 1 - r;
const src: [*]const u8 = @ptrCast(bm.buffer + pitch * source_row);
@memcpy(pixels[r * w .. r * w + w], src[0..w]);
}
return .{ .w = w, .h = rows, .left = @intCast(s.*.bitmap_left), .top = @intCast(s.*.bitmap_top), .pixels = pixels };
}
};
pub const default_families: []const [:0]const u8 = &.{"monospace"};
fn clusterFace(faces: anytype, text: []const u8, variant: Variant) u32 {
for (faces, 0..) |*face, face_id| {
var offset: usize = 0;
while (offset < text.len) {
const len = std.unicode.utf8ByteSequenceLength(text[offset]) catch break;
if (offset + len > text.len) break;
const cp = std.unicode.utf8Decode(text[offset .. offset + len]) catch break;
offset += len;
// Joiners and presentation selectors affect shaping but do not
// require standalone glyphs from the selected face.
if (cp == 0x200d or cp == 0xfe0e or cp == 0xfe0f) continue;
if (!face.hasCodepoint(variant, cp)) break;
} else return @intCast(face_id);
}
// Preserve the existing missing-glyph behavior when no configured family
// covers the cluster: HarfBuzz shapes it with the primary face's .notdef.
return 0;
}
/// Eagerly opened families in configured priority order. The primary face
/// owns terminal metrics; fallback metrics only position glyphs inside that
/// authoritative cell grid.
pub const FontSet = struct {
faces: []Face,
families: []const [:0]const u8,
pub fn open(alloc: std.mem.Allocator, px: u16, families: []const [:0]const u8, failed_family: ?*usize) Face.Error!FontSet {
if (families.len == 0) return error.NoMonospaceFace;
const faces = try alloc.alloc(Face, families.len);
var made: usize = 0;
errdefer {
for (faces[0..made]) |*loaded_face| loaded_face.deinit();
alloc.free(faces);
}
for (families, 0..) |family, i| {
if (failed_family) |failed| failed.* = i;
faces[i] = try Face.openFamily(px, family);
made += 1;
}
return .{ .faces = faces, .families = families };
}
pub fn openDefault(alloc: std.mem.Allocator, px: u16) Face.Error!FontSet {
return open(alloc, px, default_families, null);
}
pub fn deinit(self: *FontSet, alloc: std.mem.Allocator) void {
for (self.faces) |*loaded_face| loaded_face.deinit();
alloc.free(self.faces);
self.* = undefined;
}
pub fn primary(self: *FontSet) *Face {
return &self.faces[0];
}
fn face(self: *FontSet, face_id: u32) *Face {
return &self.faces[@intCast(face_id)];
}
fn faceForCluster(self: *FontSet, text: []const u8, variant: Variant) u32 {
return clusterFace(self.faces, text, variant);
}
};
/// Owns complete shaped-cluster keys and the positioned atlas runs they map
/// to. Call `prepare` for every visible cell before quad generation; after
/// that, `resolve` performs no insertion, so one frame uses one atlas size.
pub const GlyphCache = struct {
alloc: std.mem.Allocator,
fonts: *FontSet,
glyph_atlas: *atlas.Atlas,
runs: std.StringHashMapUnmanaged([]quads.PositionedGlyph) = .empty,
pub fn deinit(self: *GlyphCache) void {
var it = self.runs.iterator();
while (it.next()) |entry| {
self.alloc.free(entry.key_ptr.*);
self.alloc.free(entry.value_ptr.*);
}
self.runs.deinit(self.alloc);
}
/// Keep font-set and atlas addresses stable for the renderer. Build both
/// replacements first, so an allocation/font failure leaves the current
/// cache usable; glyph IDs and bitmap coordinates never cross sizes.
pub fn setPixelSize(self: *GlyphCache, px: u16) !bool {
if (self.fonts.primary().pixels == px) return false;
var next_fonts = try FontSet.open(self.alloc, px, self.fonts.families, null);
errdefer next_fonts.deinit(self.alloc);
var next_atlas = try atlas.Atlas.init(self.alloc, atlasWidth(px), 256);
next_atlas.dirty = true;
self.deinit();
self.fonts.deinit(self.alloc);
self.glyph_atlas.deinit(self.alloc);
self.fonts.* = next_fonts;
self.glyph_atlas.* = next_atlas;
self.runs = .empty;
return true;
}
fn key(self: *GlyphCache, text: []const u8, variant: Variant) ![]u8 {
const out = try self.alloc.alloc(u8, text.len + 1);
out[0] = @intFromEnum(variant);
@memcpy(out[1..], text);
return out;
}
pub fn prepare(self: *GlyphCache, text: []const u8, variant: Variant) !void {
var lookup_buf: [256]u8 = undefined;
if (text.len + 1 > lookup_buf.len) return error.ClusterTooLong;
lookup_buf[0] = @intFromEnum(variant);
@memcpy(lookup_buf[1 .. text.len + 1], text);
if (self.runs.contains(lookup_buf[0 .. text.len + 1])) return;
const face_id = self.fonts.faceForCluster(text, variant);
const selected = self.fonts.face(face_id);
var shaped = try selected.shape(self.alloc, text, variant);
defer shaped.deinit(self.alloc);
const placed = try self.alloc.alloc(quads.PositionedGlyph, shaped.glyphs.len);
errdefer self.alloc.free(placed);
for (shaped.glyphs, placed) |g, *p| {
const glyph_key: atlas.Key = .{ .face_id = face_id, .variant = variant, .glyph_id = g.glyph_id };
const entry = self.glyph_atlas.get(glyph_key) orelse blk: {
var bitmap = try selected.renderGlyph(self.alloc, variant, g.glyph_id);
defer bitmap.deinit(self.alloc);
break :blk try self.glyph_atlas.put(self.alloc, glyph_key, bitmap.w, bitmap.h, bitmap.left, bitmap.top, bitmap.pixels);
};
p.* = .{ .entry = entry, .x_advance = g.x_advance, .y_advance = g.y_advance, .x_offset = g.x_offset, .y_offset = g.y_offset };
}
const owned = try self.key(text, variant);
errdefer self.alloc.free(owned);
try self.runs.put(self.alloc, owned, placed);
}
pub fn resolve(ctx: *anyopaque, text: []const u8, variant: Variant) anyerror![]const quads.PositionedGlyph {
const self: *GlyphCache = @ptrCast(@alignCast(ctx));
var lookup_buf: [256]u8 = undefined;
if (text.len + 1 > lookup_buf.len) return error.ClusterTooLong;
lookup_buf[0] = @intFromEnum(variant);
@memcpy(lookup_buf[1 .. text.len + 1], text);
return self.runs.get(lookup_buf[0 .. text.len + 1]) orelse error.RunNotPrepared;
}
};
test "complete combining cluster shapes without truncation" {
var f = try Face.open(16);
defer f.deinit();
var r = try f.shape(std.testing.allocator, "e\xcc\x81", .regular);
defer r.deinit(std.testing.allocator);
try std.testing.expect(r.glyphs.len > 0);
}
test "cell width is the shaped monospace M advance" {
var f = try Face.open(16);
defer f.deinit();
var run = try f.shape(std.testing.allocator, "M", .regular);
defer run.deinit(std.testing.allocator);
try std.testing.expect(run.glyphs.len > 0);
var advance: i32 = 0;
for (run.glyphs) |g| advance += g.x_advance;
try std.testing.expectEqual(@as(i32, f.cell_w), @divTrunc(advance + 63, 64));
}
test "fallback selection keeps a complete cluster on the first covering face" {
const TestFace = struct {
codepoints: []const u21,
fn hasCodepoint(self: *const @This(), _: Variant, cp: u21) bool {
return std.mem.indexOfScalar(u21, self.codepoints, cp) != null;
}
};
const base = [_]u21{'e'};
const complete = [_]u21{ 'e', 0x301 };
var faces = [_]TestFace{
.{ .codepoints = &base },
.{ .codepoints = &complete },
.{ .codepoints = &complete },
};
try std.testing.expectEqual(@as(u32, 0), clusterFace(faces[0..], "e", .regular));
try std.testing.expectEqual(@as(u32, 1), clusterFace(faces[0..], "e\xcc\x81", .regular));
try std.testing.expectEqual(@as(u32, 1), clusterFace(faces[0..], "e\xe2\x80\x8d\xcc\x81\xef\xb8\x8f", .regular));
try std.testing.expectEqual(@as(u32, 0), clusterFace(faces[0..], "z", .regular));
}
test "installed Adwaita Mono follows CommitMono for a missing glyph" {
const alloc = std.testing.allocator;
const families: []const [:0]const u8 = &.{ "CommitMono Nerd Font Mono", "Adwaita Mono" };
var fonts = FontSet.open(alloc, 16, families, null) catch |err| switch (err) {
error.NoMonospaceFace => return error.SkipZigTest,
else => return err,
};
defer fonts.deinit(alloc);
const cp: u21 = 0x416;
if (fonts.faces[0].hasCodepoint(.regular, cp) or !fonts.faces[1].hasCodepoint(.regular, cp)) return error.SkipZigTest;
try std.testing.expectEqual(@as(u32, 1), fonts.faceForCluster("\xd0\x96", .regular));
}
test "cache owns complete styled cluster and preserves shaping through atlas growth" {
const alloc = std.testing.allocator;
var fonts = try FontSet.openDefault(alloc, 16);
defer fonts.deinit(alloc);
var glyph_atlas = try atlas.Atlas.init(alloc, 128, 1);
defer glyph_atlas.deinit(alloc);
var cache: GlyphCache = .{ .alloc = alloc, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
defer cache.deinit();
var source = [_]u8{ 'e', 0xcc, 0x81 };
var expected = try fonts.primary().shape(alloc, &source, .bold_italic);
defer expected.deinit(alloc);
try cache.prepare(&source, .bold_italic);
source[0] = 'x';
const cached = try GlyphCache.resolve(@ptrCast(&cache), "e\xcc\x81", .bold_italic);
try std.testing.expectEqual(expected.glyphs.len, cached.len);
try std.testing.expect(glyph_atlas.height > 1);
for (expected.glyphs, cached) |want, got| {
try std.testing.expectEqual(want.x_advance, got.x_advance);
try std.testing.expectEqual(want.y_advance, got.y_advance);
try std.testing.expectEqual(want.x_offset, got.x_offset);
try std.testing.expectEqual(want.y_offset, got.y_offset);
try std.testing.expectEqual(got.entry, glyph_atlas.get(.{ .face_id = 0, .variant = .bold_italic, .glyph_id = want.glyph_id }).?);
const v1 = @as(f32, @floatFromInt(got.entry.y + got.entry.h)) / @as(f32, @floatFromInt(glyph_atlas.height));
try std.testing.expect(v1 <= 1.0);
}
}
test "display scaling chooses rounded bounded framebuffer font pixels" {
try std.testing.expectEqual(@as(u16, 16), scaledPixels(16, 1));
try std.testing.expectEqual(@as(u16, 20), scaledPixels(16, 1.25));
try std.testing.expectEqual(@as(u16, 24), scaledPixels(16, 1.5));
try std.testing.expectEqual(@as(u16, 32), scaledPixels(16, 2));
try std.testing.expectEqual(@as(u16, 21), scaledPixels(17, 1.25));
try std.testing.expectEqual(@as(u16, 1), scaledPixels(1, 0.25));
try std.testing.expectEqual(@as(u16, 1024), scaledPixels(256, 8));
for ([_]f32{ 0, -1, std.math.nan(f32), std.math.inf(f32) }) |scale| {
try std.testing.expectEqual(@as(u16, 16), scaledPixels(16, scale));
}
}
test "point sizes retain fractions through display scaling" {
const macos = @import("builtin").os.tag == .macos;
try std.testing.expectEqual(@as(u16, if (macos) 13 else 17), scaledPoints(12.5, 1));
try std.testing.expectEqual(@as(u16, if (macos) 19 else 25), scaledPoints(12.5, 1.5));
try std.testing.expectEqual(@as(u16, if (macos) 25 else 33), scaledPoints(12.4, 2));
}
test "scale rebuild replaces glyph bitmaps and cached runs at stable resource addresses" {
const alloc = std.testing.allocator;
var fonts = try FontSet.openDefault(alloc, 16);
defer fonts.deinit(alloc);
var glyph_atlas = try atlas.Atlas.init(alloc, atlasWidth(16), 256);
defer glyph_atlas.deinit(alloc);
var cache: GlyphCache = .{ .alloc = alloc, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
defer cache.deinit();
try cache.prepare("M", .regular);
const small = (try GlyphCache.resolve(&cache, "M", .regular))[0].entry;
try std.testing.expect(!try cache.setPixelSize(16));
try std.testing.expectEqual(@as(usize, 1), cache.runs.count());
for ([_]u16{ 32, 20, 16 }) |px| {
try std.testing.expect(try cache.setPixelSize(px));
try std.testing.expectEqual(@as(u16, px), fonts.primary().pixels);
try std.testing.expectEqual(@as(usize, 0), cache.runs.count());
try std.testing.expectEqual(@as(u32, 0), glyph_atlas.entries.count());
try std.testing.expect(glyph_atlas.dirty);
try cache.prepare("M", .regular);
const glyph = (try GlyphCache.resolve(&cache, "M", .regular))[0].entry;
if (px == 32) {
try std.testing.expect(glyph.w > small.w);
try std.testing.expect(glyph.h > small.h);
} else if (px == 16) {
try std.testing.expectEqual(small.w, glyph.w);
try std.testing.expectEqual(small.h, glyph.h);
}
try std.testing.expectEqual(@as(*FontSet, &fonts), cache.fonts);
try std.testing.expectEqual(@as(*atlas.Atlas, &glyph_atlas), cache.glyph_atlas);
}
}
test "failed scale atlas allocation keeps the old font set and glyph cache usable" {
var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
const alloc = failing.allocator();
var fonts = try FontSet.openDefault(alloc, 16);
defer fonts.deinit(alloc);
var glyph_atlas = try atlas.Atlas.init(alloc, atlasWidth(16), 256);
defer glyph_atlas.deinit(alloc);
var cache: GlyphCache = .{ .alloc = alloc, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
defer cache.deinit();
try cache.prepare("M", .regular);
const previous = (try GlyphCache.resolve(&cache, "M", .regular))[0].entry;
const previous_pixels = glyph_atlas.pixels.ptr;
failing.fail_index = failing.alloc_index + 1;
try std.testing.expectError(error.OutOfMemory, cache.setPixelSize(32));
try std.testing.expectEqual(@as(u16, 16), fonts.primary().pixels);
try std.testing.expectEqual(previous_pixels, glyph_atlas.pixels.ptr);
try std.testing.expectEqual(previous, (try GlyphCache.resolve(&cache, "M", .regular))[0].entry);
failing.fail_index = std.math.maxInt(usize);
try cache.prepare("W", .regular);
try std.testing.expectEqual(@as(usize, 2), cache.runs.count());
}