ghostty/src/font/shaper/coretext.zig
Mitchell Hashimoto f447e6f9df font: insert blank cells for multi-cell ligatures for styling
Up to this point, every font I've experienced with ligatures has
replaced the codepoints that were replaced for combining with a space.
For example, if a font has a ligature for "!=" to turn it into a glyph,
it'd shape to `[not equal glyph, space]`, so it'd still take up two
cells, allowing us to style both.

Monaspace, however, does not do this. It turns "!=" into `[not equal
glyph]` so styles like backgrounds, underlines, etc. were not extending.

This commit detects multi-cell glyphs and inserts synthetic blank cells
so that styling returns. I decided to do this via synthetic blank cells
instead of introducing a `cell_width` to the shaper result because this
simplifies the renderers to assume each shaper cell is one cell. We can
change this later if we need to.

Annoyingly, this does make the shaper slightly slower for EVERYONE to
accomodate one known font that behaves this way. I haven't benchmarked
it but my belief is that the performance impact will be negligible
because to figure out cell width we're only accessing subsequent cells
so they're likely to be in the CPU cache and also 99% of cells are going
to be width 1.
2024-01-06 19:22:25 -08:00

1031 lines
34 KiB
Zig

const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const harfbuzz = @import("harfbuzz");
const macos = @import("macos");
const trace = @import("tracy").trace;
const font = @import("../main.zig");
const Face = font.Face;
const DeferredFace = font.DeferredFace;
const Group = font.Group;
const GroupCache = font.GroupCache;
const Library = font.Library;
const Style = font.Style;
const Presentation = font.Presentation;
const terminal = @import("../../terminal/main.zig");
const log = std.log.scoped(.font_shaper);
/// Shaper that uses CoreText.
///
/// WARNING: This is not ready for production usage. This is why this shaper
/// can't be configured at build-time without modifying the source. There are
/// a couple major missing features (quirks mode, font features) and I haven't
/// very carefully audited all my memory management.
///
/// The purpose of this shaper is to keep us honest with our other shapers
/// and to help us find bugs in our other shapers.
pub const Shaper = struct {
/// The allocated used for the feature list and cell buf.
alloc: Allocator,
/// The string used for shaping the current run.
codepoints: CodepointList = .{},
/// The font features we want to use. The hardcoded features are always
/// set first.
features: FeatureList = .{},
/// The shared memory used for shaping results.
cell_buf: CellBuf,
const CellBuf = std.ArrayListUnmanaged(font.shape.Cell);
const CodepointList = std.ArrayListUnmanaged(Codepoint);
const Codepoint = struct {
codepoint: u32,
cluster: u32,
};
const FeatureList = std.ArrayListUnmanaged(Feature);
const Feature = struct {
key: *macos.foundation.String,
value: *macos.foundation.Number,
pub fn init(name_raw: []const u8) !Feature {
const name = if (name_raw[0] == '-') name_raw[1..] else name_raw;
const value_num: c_int = if (name_raw[0] == '-') 0 else 1;
var key = try macos.foundation.String.createWithBytes(name, .utf8, false);
errdefer key.release();
var value = try macos.foundation.Number.create(.int, &value_num);
defer value.release();
return .{ .key = key, .value = value };
}
pub fn deinit(self: Feature) void {
self.key.release();
self.value.release();
}
};
// These features are hardcoded to always be on by default. Users
// can turn them off by setting the features to "-liga" for example.
const hardcoded_features = [_][]const u8{ "dlig", "liga" };
/// The cell_buf argument is the buffer to use for storing shaped results.
/// This should be at least the number of columns in the terminal.
pub fn init(alloc: Allocator, opts: font.shape.Options) !Shaper {
var feats: FeatureList = .{};
errdefer {
for (feats.items) |feature| feature.deinit();
feats.deinit(alloc);
}
for (hardcoded_features) |name| {
const feat = try Feature.init(name);
errdefer feat.deinit();
try feats.append(alloc, feat);
}
for (opts.features) |name| {
const feat = try Feature.init(name);
errdefer feat.deinit();
try feats.append(alloc, feat);
}
return Shaper{
.alloc = alloc,
.cell_buf = .{},
.features = feats,
};
}
pub fn deinit(self: *Shaper) void {
self.cell_buf.deinit(self.alloc);
self.codepoints.deinit(self.alloc);
for (self.features.items) |feature| feature.deinit();
self.features.deinit(self.alloc);
}
pub fn runIterator(
self: *Shaper,
group: *GroupCache,
row: terminal.Screen.Row,
selection: ?terminal.Selection,
cursor_x: ?usize,
) font.shape.RunIterator {
return .{
.hooks = .{ .shaper = self },
.group = group,
.row = row,
.selection = selection,
.cursor_x = cursor_x,
};
}
pub fn shape(self: *Shaper, run: font.shape.TextRun) ![]const font.shape.Cell {
// TODO: quirks fonts
// TODO: font features
// Special fonts aren't shaped and their codepoint == glyph so we
// can just return the codepoints as-is.
if (run.font_index.special() != null) {
self.cell_buf.clearRetainingCapacity();
try self.cell_buf.ensureTotalCapacity(self.alloc, self.codepoints.items.len);
for (self.codepoints.items) |entry| {
self.cell_buf.appendAssumeCapacity(.{
.x = @intCast(entry.cluster),
.glyph_index = @intCast(entry.codepoint),
});
}
return self.cell_buf.items;
}
// Create an arena for any Zig-based allocations we do
var arena = std.heap.ArenaAllocator.init(self.alloc);
defer arena.deinit();
const alloc = arena.allocator();
// Build up our string contents
const str = str: {
const str = try macos.foundation.MutableString.create(0);
errdefer str.release();
for (self.codepoints.items) |entry| {
var unichars: [2]u16 = undefined;
const pair = macos.foundation.stringGetSurrogatePairForLongCharacter(
entry.codepoint,
&unichars,
);
const len: usize = if (pair) 2 else 1;
str.appendCharacters(unichars[0..len]);
// log.warn("append codepoint={} unichar_len={}", .{ cp, len });
}
break :str str;
};
defer str.release();
// Get our font and use that get the attributes to set for the
// attributed string so the whole string uses the same font.
const attr_dict = dict: {
const face = try run.group.group.faceFromIndex(run.font_index);
var keys = [_]?*const anyopaque{macos.text.StringAttribute.font.key()};
var values = [_]?*const anyopaque{face.font};
break :dict try macos.foundation.Dictionary.create(&keys, &values);
};
defer attr_dict.release();
// Create an attributed string from our string
const attr_str = try macos.foundation.AttributedString.create(
str.string(),
attr_dict,
);
defer attr_str.release();
// We should always have one run because we do our own run splitting.
const line = try macos.text.Line.createWithAttributedString(attr_str);
defer line.release();
const runs = line.getGlyphRuns();
assert(runs.getCount() == 1);
const ctrun = runs.getValueAtIndex(macos.text.Run, 0);
// Get our glyphs and positions
const glyphs = try ctrun.getGlyphs(alloc);
const positions = try ctrun.getPositions(alloc);
const advances = try ctrun.getAdvances(alloc);
const indices = try ctrun.getStringIndices(alloc);
assert(glyphs.len == positions.len);
assert(glyphs.len == advances.len);
assert(glyphs.len == indices.len);
// This keeps track of the current offsets within a single cell.
var cell_offset: struct {
cluster: u32 = 0,
x: f64 = 0,
y: f64 = 0,
} = .{};
self.cell_buf.clearRetainingCapacity();
try self.cell_buf.ensureTotalCapacity(self.alloc, glyphs.len);
for (glyphs, positions, advances, indices) |glyph, pos, advance, index| {
// Our cluster is also our cell X position. If the cluster changes
// then we need to reset our current cell offsets.
const cluster = self.codepoints.items[index].cluster;
if (cell_offset.cluster != cluster) cell_offset = .{
.cluster = cluster,
};
self.cell_buf.appendAssumeCapacity(.{
.x = @intCast(cluster),
.x_offset = @intFromFloat(@round(cell_offset.x)),
.y_offset = @intFromFloat(@round(cell_offset.y)),
.glyph_index = glyph,
});
// Add our advances to keep track of our current cell offsets.
// Advances apply to the NEXT cell.
cell_offset.x += advance.width;
cell_offset.y += advance.height;
// TODO: harfbuzz shaper has handling for inserting blank
// cells for multi-cell ligatures. Do we need to port that?
// Example: try Monaspace "===" with a background color.
_ = pos;
// const i = self.cell_buf.items.len - 1;
// log.warn(
// "i={} codepoint={} glyph={} pos={} advance={} index={} cluster={}",
// .{ i, self.codepoints.items[index].codepoint, glyph, pos, advance, index, cluster },
// );
}
//log.warn("-------------------------------", .{});
return self.cell_buf.items;
}
/// The hooks for RunIterator.
pub const RunIteratorHook = struct {
shaper: *Shaper,
pub fn prepare(self: *RunIteratorHook) !void {
self.shaper.codepoints.clearRetainingCapacity();
}
pub fn addCodepoint(self: RunIteratorHook, cp: u32, cluster: u32) !void {
try self.shaper.codepoints.append(self.shaper.alloc, .{
.codepoint = cp,
.cluster = cluster,
});
}
pub fn finalize(self: RunIteratorHook) !void {
_ = self;
}
};
};
test "run iterator" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
{
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 5, 0);
defer screen.deinit();
try screen.testWriteString("ABCD");
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |_| count += 1;
try testing.expectEqual(@as(usize, 1), count);
}
// Spaces should be part of a run
{
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
try screen.testWriteString("ABCD EFG");
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |_| count += 1;
try testing.expectEqual(@as(usize, 1), count);
}
{
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 5, 0);
defer screen.deinit();
try screen.testWriteString("A😃D");
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |_| count += 1;
try testing.expectEqual(@as(usize, 3), count);
}
}
test "run iterator: empty cells with background set" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
{
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 5, 0);
defer screen.deinit();
screen.cursor.pen.bg = try terminal.color.Name.cyan.default();
screen.cursor.pen.attrs.has_bg = true;
try screen.testWriteString("A");
// Get our first row
const row = screen.getRow(.{ .active = 0 });
row.getCellPtr(1).* = screen.cursor.pen;
row.getCellPtr(2).* = screen.cursor.pen;
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
// The run should have length 3 because of the two background
// cells.
try testing.expectEqual(@as(usize, 3), shaper.codepoints.items.len);
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 3), cells.len);
}
try testing.expectEqual(@as(usize, 1), count);
}
}
test "shape" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain
buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain
buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 1), count);
}
// test "shape inconsolata ligs" {
// const testing = std.testing;
// const alloc = testing.allocator;
//
// var testdata = try testShaper(alloc);
// defer testdata.deinit();
//
// {
// var screen = try terminal.Screen.init(alloc, 3, 5, 0);
// defer screen.deinit();
// try screen.testWriteString(">=");
//
// var shaper = &testdata.shaper;
// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
// var count: usize = 0;
// while (try it.next(alloc)) |run| {
// count += 1;
//
// const cells = try shaper.shape(run);
// try testing.expectEqual(@as(usize, 1), cells.len);
// }
// try testing.expectEqual(@as(usize, 1), count);
// }
//
// {
// var screen = try terminal.Screen.init(alloc, 3, 5, 0);
// defer screen.deinit();
// try screen.testWriteString("===");
//
// var shaper = &testdata.shaper;
// var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
// var count: usize = 0;
// while (try it.next(alloc)) |run| {
// count += 1;
//
// const cells = try shaper.shape(run);
// try testing.expectEqual(@as(usize, 1), cells.len);
// }
// try testing.expectEqual(@as(usize, 1), count);
// }
// }
test "shape emoji width" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
{
var screen = try terminal.Screen.init(alloc, 3, 5, 0);
defer screen.deinit();
try screen.testWriteString("👍");
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 1), cells.len);
}
try testing.expectEqual(@as(usize, 1), count);
}
}
test "shape emoji width long" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode(0x1F9D4, buf[buf_idx..]); // man: beard
buf_idx += try std.unicode.utf8Encode(0x1F3FB, buf[buf_idx..]); // light skin tone (Fitz 1-2)
buf_idx += try std.unicode.utf8Encode(0x200D, buf[buf_idx..]); // ZWJ
buf_idx += try std.unicode.utf8Encode(0x2642, buf[buf_idx..]); // male sign
buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // emoji representation
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 30, 0);
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 1), cells.len);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape variation selector VS15" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode(0x270C, buf[buf_idx..]); // Victory sign (default text)
buf_idx += try std.unicode.utf8Encode(0xFE0E, buf[buf_idx..]); // ZWJ to force text
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 1), cells.len);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape variation selector VS16" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode(0x270C, buf[buf_idx..]); // Victory sign (default text)
buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // ZWJ to force color
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 1), cells.len);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape with empty cells in between" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 30, 0);
defer screen.deinit();
try screen.testWriteString("A");
screen.cursor.x += 5;
try screen.testWriteString("B");
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 7), cells.len);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape Chinese characters" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode('n', buf[buf_idx..]); // Combining
buf_idx += try std.unicode.utf8Encode(0x0308, buf[buf_idx..]); // Combining
buf_idx += try std.unicode.utf8Encode(0x0308, buf[buf_idx..]);
buf_idx += try std.unicode.utf8Encode('a', buf[buf_idx..]);
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 30, 0);
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 4), cells.len);
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u16, 0), cells[1].x);
try testing.expectEqual(@as(u16, 0), cells[2].x);
try testing.expectEqual(@as(u16, 1), cells[3].x);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape box glyphs" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
// Setup the box font
testdata.cache.group.sprite = font.sprite.Face{
.width = 18,
.height = 36,
.thickness = 2,
};
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode(0x2500, buf[buf_idx..]); // horiz line
buf_idx += try std.unicode.utf8Encode(0x2501, buf[buf_idx..]); //
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
try screen.testWriteString(buf[0..buf_idx]);
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
//try testing.expectEqual(@as(u32, 2), shaper.hb_buf.getLength());
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u32, 0x2500), cells[0].glyph_index);
try testing.expectEqual(@as(u16, 0), cells[0].x);
try testing.expectEqual(@as(u32, 0x2501), cells[1].glyph_index);
try testing.expectEqual(@as(u16, 1), cells[1].x);
}
try testing.expectEqual(@as(usize, 1), count);
}
test "shape selection boundary" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
try screen.testWriteString("a1b2c3d4e5");
// Full line selection
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = screen.cols - 1, .y = 0 },
}, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 1), count);
}
// Offset x, goes to end of line selection
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{
.start = .{ .x = 2, .y = 0 },
.end = .{ .x = screen.cols - 1, .y = 0 },
}, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 2), count);
}
// Offset x, starts at beginning of line
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 3, .y = 0 },
}, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 2), count);
}
// Selection only subset of line
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{
.start = .{ .x = 1, .y = 0 },
.end = .{ .x = 3, .y = 0 },
}, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 3), count);
}
// Selection only one character
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), .{
.start = .{ .x = 1, .y = 0 },
.end = .{ .x = 1, .y = 0 },
}, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 3), count);
}
}
test "shape cursor boundary" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
try screen.testWriteString("a1b2c3d4e5");
// No cursor is full line
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 1), count);
}
// Cursor at index 0 is two runs
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 2), count);
}
// Cursor at index 1 is three runs
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 3), count);
}
// Cursor at last col is two runs
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 9);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 2), count);
}
}
test "shape cursor boundary and colored emoji" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
// Make a screen with some data
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
try screen.testWriteString("👍🏼");
// No cursor is full line
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 1), count);
}
// Cursor on emoji does not split it
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 0);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 1), count);
}
{
// Get our run iterator
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, 1);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 1), count);
}
}
test "shape cell attribute change" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaper(alloc);
defer testdata.deinit();
// Plain >= should shape into 1 run
{
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
try screen.testWriteString(">=");
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 1), count);
}
// Bold vs regular should split
{
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
try screen.testWriteString(">");
screen.cursor.pen.attrs.bold = true;
try screen.testWriteString("=");
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 2), count);
}
// Changing fg color should split
{
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
screen.cursor.pen.attrs.has_fg = true;
screen.cursor.pen.fg = .{ .r = 1, .g = 2, .b = 3 };
try screen.testWriteString(">");
screen.cursor.pen.fg = .{ .r = 3, .g = 2, .b = 1 };
try screen.testWriteString("=");
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 2), count);
}
// Changing bg color should split
{
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
screen.cursor.pen.attrs.has_bg = true;
screen.cursor.pen.bg = .{ .r = 1, .g = 2, .b = 3 };
try screen.testWriteString(">");
screen.cursor.pen.bg = .{ .r = 3, .g = 2, .b = 1 };
try screen.testWriteString("=");
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 2), count);
}
// Same bg color should not split
{
var screen = try terminal.Screen.init(alloc, 3, 10, 0);
defer screen.deinit();
screen.cursor.pen.attrs.has_bg = true;
screen.cursor.pen.bg = .{ .r = 1, .g = 2, .b = 3 };
try screen.testWriteString(">");
try screen.testWriteString("=");
var shaper = &testdata.shaper;
var it = shaper.runIterator(testdata.cache, screen.getRow(.{ .screen = 0 }), null, null);
var count: usize = 0;
while (try it.next(alloc)) |run| {
count += 1;
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 1), count);
}
}
const TestShaper = struct {
alloc: Allocator,
shaper: Shaper,
cache: *GroupCache,
lib: Library,
pub fn deinit(self: *TestShaper) void {
self.shaper.deinit();
self.cache.deinit(self.alloc);
self.alloc.destroy(self.cache);
self.lib.deinit();
}
};
/// Helper to return a fully initialized shaper.
fn testShaper(alloc: Allocator) !TestShaper {
const testFont = @import("../test.zig").fontRegular;
const testEmoji = @import("../test.zig").fontEmoji;
const testEmojiText = @import("../test.zig").fontEmojiText;
var lib = try Library.init();
errdefer lib.deinit();
var cache_ptr = try alloc.create(GroupCache);
errdefer alloc.destroy(cache_ptr);
cache_ptr.* = try GroupCache.init(alloc, try Group.init(
alloc,
lib,
.{ .points = 12 },
));
errdefer cache_ptr.*.deinit(alloc);
// Setup group
_ = try cache_ptr.group.addFace(.regular, .{ .loaded = try Face.init(
lib,
testFont,
.{ .size = .{ .points = 12 } },
) });
if (font.options.backend != .coretext) {
// Coretext doesn't support Noto's format
_ = try cache_ptr.group.addFace(.regular, .{ .loaded = try Face.init(
lib,
testEmoji,
.{ .size = .{ .points = 12 } },
) });
} else {
// On CoreText we want to load Apple Emoji, we should have it.
var disco = font.Discover.init();
defer disco.deinit();
var disco_it = try disco.discover(alloc, .{
.family = "Apple Color Emoji",
.size = 12,
.monospace = false,
});
defer disco_it.deinit();
var face = (try disco_it.next()).?;
errdefer face.deinit();
_ = try cache_ptr.group.addFace(.regular, .{ .deferred = face });
}
_ = try cache_ptr.group.addFace(.regular, .{ .loaded = try Face.init(
lib,
testEmojiText,
.{ .size = .{ .points = 12 } },
) });
var shaper = try Shaper.init(alloc, .{});
errdefer shaper.deinit();
return TestShaper{
.alloc = alloc,
.shaper = shaper,
.cache = cache_ptr,
.lib = lib,
};
}