ghostty/src/font/Group.zig
Mitchell Hashimoto 08bca077b2 search for unknown codepoints in any available font face
If an unknown codepoint is rendered, we now will query the OS for ANY
font that can satisfy the codepoint (rather than rendering `?`).
2022-11-17 16:09:33 -08:00

371 lines
13 KiB
Zig

//! A font group is a a set of multiple font faces of potentially different
//! styles that are used together to find glyphs. They usually share sizing
//! properties so that they can be used interchangably with each other in cases
//! a codepoint doesn't map cleanly. For example, if a user requests a bold
//! char and it doesn't exist we can fallback to a regular non-bold char so
//! we show SOMETHING.
const Group = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const font = @import("main.zig");
const Atlas = @import("../Atlas.zig");
const DeferredFace = @import("main.zig").DeferredFace;
const Face = @import("main.zig").Face;
const Library = @import("main.zig").Library;
const Glyph = @import("main.zig").Glyph;
const Style = @import("main.zig").Style;
const Presentation = @import("main.zig").Presentation;
const options = @import("main.zig").options;
const log = std.log.scoped(.font_group);
/// Packed array to map our styles to a set of faces.
// Note: this is not the most efficient way to store these, but there is
// usually only one font group for the entire process so this isn't the
// most important memory efficiency we can look for. This is totally opaque
// to the user so we can change this later.
const StyleArray = std.EnumArray(Style, std.ArrayListUnmanaged(DeferredFace));
/// The allocator for this group
alloc: Allocator,
/// The library being used for all the faces.
lib: Library,
/// The desired font size. All fonts in a group must share the same size.
size: font.face.DesiredSize,
/// The available faces we have. This shouldn't be modified manually.
/// Instead, use the functions available on Group.
faces: StyleArray,
/// If discovery is available, we'll look up fonts where we can't find
/// the codepoint.
discover: ?font.Discover = null,
pub fn init(
alloc: Allocator,
lib: Library,
size: font.face.DesiredSize,
) !Group {
var result = Group{ .alloc = alloc, .lib = lib, .size = size, .faces = undefined };
// Initialize all our styles to initially sized lists.
var i: usize = 0;
while (i < StyleArray.len) : (i += 1) {
result.faces.values[i] = .{};
try result.faces.values[i].ensureTotalCapacityPrecise(alloc, 2);
}
return result;
}
pub fn deinit(self: *Group) void {
var it = self.faces.iterator();
while (it.next()) |entry| {
for (entry.value.items) |*item| item.deinit();
entry.value.deinit(self.alloc);
}
if (font.Discover != void) {
if (self.discover) |*discover| discover.deinit();
}
}
/// Add a face to the list for the given style. This face will be added as
/// next in priority if others exist already, i.e. it'll be the _last_ to be
/// searched for a glyph in that list.
///
/// The group takes ownership of the face. The face will be deallocated when
/// the group is deallocated.
pub fn addFace(self: *Group, alloc: Allocator, style: Style, face: DeferredFace) !void {
try self.faces.getPtr(style).append(alloc, face);
}
/// Resize the fonts to the desired size.
pub fn setSize(self: *Group, size: font.face.DesiredSize) !void {
// Note: there are some issues here with partial failure. We don't
// currently handle it in any meaningful way if one face can resize
// but another can't.
// Resize all our faces that are loaded
var it = self.faces.iterator();
while (it.next()) |entry| {
for (entry.value.items) |*deferred| {
if (!deferred.loaded()) continue;
try deferred.face.?.setSize(size);
}
}
// Set our size for future loads
self.size = size;
}
/// This represents a specific font in the group.
pub const FontIndex = packed struct {
/// The number of bits we use for the index.
const idx_bits = 8 - @typeInfo(@typeInfo(Style).Enum.tag_type).Int.bits;
pub const IndexInt = @Type(.{ .Int = .{ .signedness = .unsigned, .bits = idx_bits } });
style: Style = .regular,
idx: IndexInt = 0,
/// Convert to int
pub fn int(self: FontIndex) u8 {
return @bitCast(u8, self);
}
test {
// We never want to take up more than a byte since font indexes are
// everywhere so if we increase the size of this we'll dramatically
// increase our memory usage.
try std.testing.expectEqual(@sizeOf(u8), @sizeOf(FontIndex));
}
};
/// Looks up the font that should be used for a specific codepoint.
/// The font index is valid as long as font faces aren't removed. This
/// isn't cached; it is expected that downstream users handle caching if
/// that is important.
///
/// Optionally, a presentation format can be specified. This presentation
/// format will be preferred but if it can't be found in this format,
/// any text format will be accepted. If presentation is null, any presentation
/// is allowed. This func will NOT determine the default presentation for
/// a code point.
pub fn indexForCodepoint(
self: *Group,
cp: u32,
style: Style,
p: ?Presentation,
) ?FontIndex {
// If we can find the exact value, then return that.
if (self.indexForCodepointExact(cp, style, p)) |value| return value;
// Try looking for another font that will satisfy this request.
if (font.Discover != void) {
if (self.discover) |*disco| discover: {
var disco_it = disco.discover(.{
.codepoint = cp,
.size = self.size.points,
.bold = style == .bold or style == .bold_italic,
.italic = style == .italic or style == .bold_italic,
}) catch break :discover;
defer disco_it.deinit();
if (disco_it.next() catch break :discover) |face| {
log.info("found codepoint 0x{x} in fallback face={s}", .{
cp,
face.name() catch "<error>",
});
self.addFace(self.alloc, style, face) catch break :discover;
if (self.indexForCodepointExact(cp, style, p)) |value| return value;
}
}
}
// If this is already regular, we're done falling back.
if (style == .regular and p == null) return null;
// For non-regular fonts, we fall back to regular.
return self.indexForCodepointExact(cp, .regular, null);
}
fn indexForCodepointExact(self: Group, cp: u32, style: Style, p: ?Presentation) ?FontIndex {
for (self.faces.get(style).items) |deferred, i| {
if (deferred.hasCodepoint(cp, p)) {
return FontIndex{
.style = style,
.idx = @intCast(FontIndex.IndexInt, i),
};
}
}
// Not found
return null;
}
/// Return the Face represented by a given FontIndex.
pub fn faceFromIndex(self: Group, index: FontIndex) !Face {
const deferred = &self.faces.get(index.style).items[@intCast(usize, index.idx)];
try deferred.load(self.lib, self.size);
return deferred.face.?;
}
/// Render a glyph by glyph index into the given font atlas and return
/// metadata about it.
///
/// This performs no caching, it is up to the caller to cache calls to this
/// if they want. This will also not resize the atlas if it is full.
///
/// IMPORTANT: this renders by /glyph index/ and not by /codepoint/. The caller
/// is expected to translate codepoints to glyph indexes in some way. The most
/// trivial way to do this is to get the Face and call glyphIndex. If you're
/// doing text shaping, the text shaping library (i.e. HarfBuzz) will automatically
/// determine glyph indexes for a text run.
pub fn renderGlyph(
self: Group,
alloc: Allocator,
atlas: *Atlas,
index: FontIndex,
glyph_index: u32,
max_height: ?u16,
) !Glyph {
const face = &self.faces.get(index.style).items[@intCast(usize, index.idx)];
try face.load(self.lib, self.size);
return try face.face.?.renderGlyph(alloc, atlas, glyph_index, max_height);
}
test {
const testing = std.testing;
const alloc = testing.allocator;
const testFont = @import("test.zig").fontRegular;
const testEmoji = @import("test.zig").fontEmoji;
const testEmojiText = @import("test.zig").fontEmojiText;
var atlas_greyscale = try Atlas.init(alloc, 512, .greyscale);
defer atlas_greyscale.deinit(alloc);
var lib = try Library.init();
defer lib.deinit();
var group = try init(alloc, lib, .{ .points = 12 });
defer group.deinit();
try group.addFace(alloc, .regular, DeferredFace.initLoaded(try Face.init(lib, testFont, .{ .points = 12 })));
try group.addFace(alloc, .regular, DeferredFace.initLoaded(try Face.init(lib, testEmoji, .{ .points = 12 })));
try group.addFace(alloc, .regular, DeferredFace.initLoaded(try Face.init(lib, testEmojiText, .{ .points = 12 })));
// Should find all visible ASCII
var i: u32 = 32;
while (i < 127) : (i += 1) {
const idx = group.indexForCodepoint(i, .regular, null).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@as(FontIndex.IndexInt, 0), idx.idx);
// Render it
const face = try group.faceFromIndex(idx);
const glyph_index = face.glyphIndex(i).?;
_ = try group.renderGlyph(
alloc,
&atlas_greyscale,
idx,
glyph_index,
null,
);
}
// Try emoji
{
const idx = group.indexForCodepoint('🥸', .regular, null).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@as(FontIndex.IndexInt, 1), idx.idx);
}
// Try text emoji
{
const idx = group.indexForCodepoint(0x270C, .regular, .text).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@as(FontIndex.IndexInt, 2), idx.idx);
}
{
const idx = group.indexForCodepoint(0x270C, .regular, .emoji).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@as(FontIndex.IndexInt, 1), idx.idx);
}
}
test "resize" {
const testing = std.testing;
const alloc = testing.allocator;
const testFont = @import("test.zig").fontRegular;
var atlas_greyscale = try Atlas.init(alloc, 512, .greyscale);
defer atlas_greyscale.deinit(alloc);
var lib = try Library.init();
defer lib.deinit();
var group = try init(alloc, lib, .{ .points = 12, .xdpi = 96, .ydpi = 96 });
defer group.deinit();
try group.addFace(alloc, .regular, DeferredFace.initLoaded(try Face.init(lib, testFont, .{ .points = 12, .xdpi = 96, .ydpi = 96 })));
// Load a letter
{
const idx = group.indexForCodepoint('A', .regular, null).?;
const face = try group.faceFromIndex(idx);
const glyph_index = face.glyphIndex('A').?;
const glyph = try group.renderGlyph(
alloc,
&atlas_greyscale,
idx,
glyph_index,
null,
);
try testing.expectEqual(@as(u32, 11), glyph.height);
}
// Resize
try group.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 });
{
const idx = group.indexForCodepoint('A', .regular, null).?;
const face = try group.faceFromIndex(idx);
const glyph_index = face.glyphIndex('A').?;
const glyph = try group.renderGlyph(
alloc,
&atlas_greyscale,
idx,
glyph_index,
null,
);
try testing.expectEqual(@as(u32, 21), glyph.height);
}
}
test "discover monospace with fontconfig and freetype" {
if (options.backend != .fontconfig_freetype) return error.SkipZigTest;
const testing = std.testing;
const alloc = testing.allocator;
const Discover = @import("main.zig").Discover;
// Search for fonts
var fc = Discover.init();
var it = try fc.discover(.{ .family = "monospace", .size = 12 });
defer it.deinit();
// Initialize the group with the deferred face
var lib = try Library.init();
defer lib.deinit();
var group = try init(alloc, lib, .{ .points = 12 });
defer group.deinit();
try group.addFace(alloc, .regular, (try it.next()).?);
// Should find all visible ASCII
var atlas_greyscale = try Atlas.init(alloc, 512, .greyscale);
defer atlas_greyscale.deinit(alloc);
var i: u32 = 32;
while (i < 127) : (i += 1) {
const idx = group.indexForCodepoint(i, .regular, null).?;
try testing.expectEqual(Style.regular, idx.style);
try testing.expectEqual(@as(FontIndex.IndexInt, 0), idx.idx);
// Render it
const face = try group.faceFromIndex(idx);
const glyph_index = face.glyphIndex(i).?;
_ = try group.renderGlyph(
alloc,
&atlas_greyscale,
idx,
glyph_index,
null,
);
}
}