Merge pull request #1241 from mitchellh/monaspace-ligs

font: insert blank cells for multi-cell ligatures for styling
This commit is contained in:
Mitchell Hashimoto
2024-01-06 19:40:47 -08:00
committed by GitHub
7 changed files with 110 additions and 15 deletions

Binary file not shown.

View File

@ -37,7 +37,10 @@ pub const Cell = struct {
/// this cell is available in the text run. This glyph index is only
/// valid for a given GroupCache and FontIndex that was used to create
/// the runs.
glyph_index: u32,
///
/// If this is null then this is an empty cell. If there are styles
/// then those should be applied but there is no glyph to render.
glyph_index: ?u32,
};
/// Options for shapers.

View File

@ -230,6 +230,10 @@ pub const Shaper = struct {
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(

View File

@ -142,13 +142,13 @@ pub const Shaper = struct {
// Convert all our info/pos to cells and set it.
self.cell_buf.clearRetainingCapacity();
try self.cell_buf.ensureTotalCapacity(self.alloc, info.len);
for (info, pos) |info_v, pos_v| {
for (info, pos, 0..) |info_v, pos_v, i| {
// If our cluster changed then we've moved to a new cell.
if (info_v.cluster != cell_offset.cluster) cell_offset = .{
.cluster = info_v.cluster,
};
self.cell_buf.appendAssumeCapacity(.{
try self.cell_buf.append(self.alloc, .{
.x = @intCast(info_v.cluster),
.x_offset = @intCast(cell_offset.x),
.y_offset = @intCast(cell_offset.y),
@ -166,6 +166,43 @@ pub const Shaper = struct {
cell_offset.y += pos_v.y_advance;
}
// Determine the width of the cell. To do this, we have to
// find the next cluster that has been shaped. This tells us how
// many cells this glyph replaced (i.e. for ligatures). For example
// in some fonts "!=" turns into a single glyph from the component
// parts "!" and "=" so this cell width would be "2" despite
// only having a single glyph.
//
// Many fonts replace ligature cells with space so that this always
// is one (e.g. Fira Code, JetBrains Mono, etc). Some do not
// (e.g. Monaspace).
const cell_width = width: {
if (i + 1 < info.len) {
// We may have to go through multiple glyphs because
// multiple can be replaced. e.g. "==="
for (info[i + 1 ..]) |next_info_v| {
if (next_info_v.cluster != info_v.cluster) {
break :width next_info_v.cluster - info_v.cluster;
}
}
}
// If we reached the end then our width is our max cluster
// minus this one.
const max = run.offset + run.cells;
break :width max - info_v.cluster;
};
if (cell_width > 1) {
// To make the renderer implementations simpler, we convert
// the extra spaces for width to blank cells.
for (1..cell_width) |j| {
try self.cell_buf.append(self.alloc, .{
.x = @intCast(info_v.cluster + j),
.glyph_index = null,
});
}
}
// const i = self.cell_buf.items.len - 1;
// log.warn("i={} info={} pos={} cell={}", .{ i, info_v, pos_v, self.cell_buf.items[i] });
}
@ -334,7 +371,9 @@ test "shape inconsolata ligs" {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 1), cells.len);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expect(cells[0].glyph_index != null);
try testing.expect(cells[1].glyph_index == null);
}
try testing.expectEqual(@as(usize, 1), count);
}
@ -351,7 +390,38 @@ test "shape inconsolata ligs" {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 1), cells.len);
try testing.expectEqual(@as(usize, 3), cells.len);
try testing.expect(cells[0].glyph_index != null);
try testing.expect(cells[1].glyph_index == null);
try testing.expect(cells[2].glyph_index == null);
}
try testing.expectEqual(@as(usize, 1), count);
}
}
test "shape monaspace ligs" {
const testing = std.testing;
const alloc = testing.allocator;
var testdata = try testShaperWithFont(alloc, .monaspace_neon);
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, 3), cells.len);
try testing.expect(cells[0].glyph_index != null);
try testing.expect(cells[1].glyph_index == null);
try testing.expect(cells[2].glyph_index == null);
}
try testing.expectEqual(@as(usize, 1), count);
}
@ -376,7 +446,7 @@ test "shape emoji width" {
count += 1;
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 1), cells.len);
try testing.expectEqual(@as(usize, 2), cells.len);
}
try testing.expectEqual(@as(usize, 1), count);
}
@ -411,7 +481,9 @@ test "shape emoji width long" {
try testing.expectEqual(@as(u32, 4), shaper.hb_buf.getLength());
const cells = try shaper.shape(run);
try testing.expectEqual(@as(usize, 1), cells.len);
// screen.testWriteString isn't grapheme aware, otherwise this is two
try testing.expectEqual(@as(usize, 5), cells.len);
}
try testing.expectEqual(@as(usize, 1), count);
}
@ -574,9 +646,9 @@ test "shape box glyphs" {
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(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(u32, 0x2501), cells[1].glyph_index.?);
try testing.expectEqual(@as(u16, 1), cells[1].x);
}
try testing.expectEqual(@as(usize, 1), count);
@ -902,11 +974,23 @@ const TestShaper = struct {
}
};
const TestFont = enum {
inconsolata,
monaspace_neon,
};
/// Helper to return a fully initialized shaper.
fn testShaper(alloc: Allocator) !TestShaper {
const testFont = @import("../test.zig").fontRegular;
return try testShaperWithFont(alloc, .inconsolata);
}
fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
const testEmoji = @import("../test.zig").fontEmoji;
const testEmojiText = @import("../test.zig").fontEmojiText;
const testFont = switch (font_req) {
.inconsolata => @import("../test.zig").fontRegular,
.monaspace_neon => @import("../test.zig").fontMonaspaceNeon,
};
var lib = try Library.init();
errdefer lib.deinit();

View File

@ -15,3 +15,7 @@ pub const fontVariable = @embedFile("res/Lilex-VF.ttf");
/// Cozette is a unique font because it embeds some emoji characters
/// but has a text presentation.
pub const fontCozette = @embedFile("res/CozetteVector.ttf");
/// Monaspace has weird ligature behaviors we want to test in our shapers
/// so we embed it here.
pub const fontMonaspaceNeon = @embedFile("res/MonaspaceNeon-Regular.otf");

View File

@ -1796,12 +1796,12 @@ fn updateCell(
};
// If the cell has a character, draw it
if (cell.char > 0) {
if (cell.char > 0) fg: {
// Render
const glyph = try self.font_group.renderGlyph(
self.alloc,
shaper_run.font_index,
shaper_cell.glyph_index,
shaper_cell.glyph_index orelse break :fg,
.{
.grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken,

View File

@ -1504,12 +1504,12 @@ fn updateCell(
};
// If the cell has a character, draw it
if (cell.char > 0) {
if (cell.char > 0) fg: {
// Render
const glyph = try self.font_group.renderGlyph(
self.alloc,
shaper_run.font_index,
shaper_cell.glyph_index,
shaper_cell.glyph_index orelse break :fg,
.{
.grid_metrics = self.grid_metrics,
.thicken = self.config.font_thicken,