mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 07:46:12 +03:00
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.
This commit is contained in:
BIN
src/font/res/MonaspaceNeon-Regular.otf
Normal file
BIN
src/font/res/MonaspaceNeon-Regular.otf
Normal file
Binary file not shown.
@ -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.
|
||||
|
@ -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(
|
||||
|
@ -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();
|
||||
|
@ -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");
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
Reference in New Issue
Block a user