a73x

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());
}