Ligatures

This introduces a naive first pass at integrating ligatures. The basic
ligatures (such as "==" in some fonts) work great. Skin-toned emoji are
struggling a bit.

This isn't the most performant way to do this, either, and I plan on
improving that.
This commit is contained in:
Mitchell Hashimoto
2022-08-29 21:51:33 -07:00
parent d839257c04
commit 3231b84927
3 changed files with 147 additions and 52 deletions

View File

@ -37,6 +37,9 @@ layout (location = 5) in vec4 bg_color_in;
// the entire terminal grid in a single GPU pass.
layout (location = 6) in uint mode_in;
// The width in cells of this item.
layout (location = 7) in uint grid_width;
// The background or foreground color for the fragment, depending on
// whether this is a background or foreground pass.
flat out vec4 color;
@ -133,7 +136,7 @@ void main() {
// The "+ 3" here is to give some wiggle room for fonts that are
// BARELY over it.
vec2 glyph_size_downsampled = glyph_size;
if (glyph_size.x > (cell_size.x + 3)) {
if (glyph_size.y > cell_size.y + 2) {
glyph_size_downsampled.x = cell_size_scaled.x;
glyph_size_downsampled.y = glyph_size.y * (glyph_size_downsampled.x / glyph_size.x);
glyph_offset_calc.y = glyph_offset.y * (glyph_size_downsampled.x / glyph_size.x);

View File

@ -109,6 +109,9 @@ const GPUCell = struct {
/// uint mode
mode: GPUCellMode,
/// The width in grid cells that a rendering takes.
grid_width: u16 = 1,
};
const GPUCellMode = enum(u8) {
@ -224,6 +227,8 @@ pub fn init(
try vbobind.attributeAdvanced(5, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(GPUCell), offset);
offset += 4 * @sizeOf(u8);
try vbobind.attributeIAdvanced(6, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(GPUCell), offset);
offset += 1 * @sizeOf(u8);
try vbobind.attributeAdvanced(7, 1, gl.c.GL_UNSIGNED_SHORT, false, @sizeOf(GPUCell), offset);
try vbobind.enableAttribArray(0);
try vbobind.enableAttribArray(1);
try vbobind.enableAttribArray(2);
@ -231,6 +236,7 @@ pub fn init(
try vbobind.enableAttribArray(4);
try vbobind.enableAttribArray(5);
try vbobind.enableAttribArray(6);
try vbobind.enableAttribArray(7);
try vbobind.attributeDivisor(0, 1);
try vbobind.attributeDivisor(1, 1);
try vbobind.attributeDivisor(2, 1);
@ -238,6 +244,7 @@ pub fn init(
try vbobind.attributeDivisor(4, 1);
try vbobind.attributeDivisor(5, 1);
try vbobind.attributeDivisor(6, 1);
try vbobind.attributeDivisor(7, 1);
// Build our texture
const tex = try gl.Texture.create();
@ -341,17 +348,31 @@ pub fn rebuildCells(self: *Grid, term: *Terminal) !void {
// We've written no data to the GPU, refresh it all
self.gl_cells_written = 0;
// Create a text shaper we'll use for the screen
var shape_buf = try self.alloc.alloc(font.Shaper.Cell, term.screen.cols * 2);
defer self.alloc.free(shape_buf);
var shaper = try font.Shaper.init(&self.font_group, shape_buf);
defer shaper.deinit();
// Build each cell
var rowIter = term.screen.rowIterator(.viewport);
var y: usize = 0;
while (rowIter.next()) |row| {
defer y += 1;
var cellIter = row.cellIterator();
var x: usize = 0;
while (cellIter.next()) |cell| {
defer x += 1;
assert(try self.updateCell(term, cell, x, y));
// Split our row into runs and shape each one.
var iter = shaper.runIterator(row);
while (try iter.next(self.alloc)) |run| {
for (try shaper.shape(run)) |shaper_cell| {
assert(try self.updateCell(
term,
row[shaper_cell.x],
shaper_cell,
run,
shaper_cell.x,
y,
));
}
}
}
@ -398,6 +419,7 @@ fn addCursor(self: *Grid, term: *Terminal) void {
.mode = mode,
.grid_col = @intCast(u16, term.screen.cursor.x),
.grid_row = @intCast(u16, term.screen.cursor.y),
.grid_width = if (cell.attrs.wide) 2 else 1,
.fg_r = 0,
.fg_g = 0,
.fg_b = 0,
@ -417,6 +439,8 @@ pub fn updateCell(
self: *Grid,
term: *Terminal,
cell: terminal.Screen.Cell,
shaper_cell: font.Shaper.Cell,
shaper_run: font.Shaper.TextRun,
x: usize,
y: usize,
) !bool {
@ -471,9 +495,6 @@ pub fn updateCell(
break :colors res;
};
// If we are a trailing spacer, we never render anything.
if (cell.attrs.wide_spacer_tail) return true;
// Calculate the amount of space we need in the cells list.
const needed = needed: {
var i: usize = 0;
@ -496,6 +517,7 @@ pub fn updateCell(
.mode = mode,
.grid_col = @intCast(u16, x),
.grid_row = @intCast(u16, y),
.grid_width = shaper_cell.width,
.glyph_x = 0,
.glyph_y = 0,
.glyph_width = 0,
@ -515,37 +537,16 @@ pub fn updateCell(
// If the cell is empty then we draw nothing in the box.
if (!cell.empty()) {
// Determine our glyph styling
const style: font.Style = if (cell.attrs.bold)
.bold
else
.regular;
var mode: GPUCellMode = .fg;
// Get the glyph that we're going to use. We first try what the cell
// wants, then the Unicode replacement char, then finally a space.
const FontInfo = struct { index: font.Group.FontIndex, ch: u32 };
const font_info: FontInfo = font_info: {
var chars = [_]u32{ @intCast(u32, cell.char), 0xFFFD, ' ' };
for (chars) |char| {
if (try self.font_group.indexForCodepoint(self.alloc, style, char)) |idx| {
break :font_info FontInfo{
.index = idx,
.ch = char,
};
}
}
@panic("all fonts require at least space");
};
// Render
const face = self.font_group.group.faceFromIndex(font_info.index);
const glyph_index = face.glyphIndex(font_info.ch).?;
const glyph = try self.font_group.renderGlyph(self.alloc, font_info.index, glyph_index);
const face = self.font_group.group.faceFromIndex(shaper_run.font_index);
const glyph = try self.font_group.renderGlyph(
self.alloc,
shaper_run.font_index,
shaper_cell.glyph_index,
);
// If we're rendering a color font, we use the color atlas
var mode: GPUCellMode = .fg;
if (face.hasColor()) mode = .fg_color;
// If the cell is wide, we need to note that in the mode
@ -555,6 +556,7 @@ pub fn updateCell(
.mode = mode,
.grid_col = @intCast(u16, x),
.grid_row = @intCast(u16, y),
.grid_width = shaper_cell.width,
.glyph_x = glyph.atlas_x,
.glyph_y = glyph.atlas_y,
.glyph_width = glyph.width,
@ -580,6 +582,7 @@ pub fn updateCell(
.mode = mode,
.grid_col = @intCast(u16, x),
.grid_row = @intCast(u16, y),
.grid_width = shaper_cell.width,
.glyph_x = 0,
.glyph_y = 0,
.glyph_width = 0,

View File

@ -22,10 +22,16 @@ group: *GroupCache,
/// calls to prevent allocations.
hb_buf: harfbuzz.Buffer,
pub fn init(group: *GroupCache) !Shaper {
/// The shared memory used for shaping results.
cell_buf: []Cell,
/// 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(group: *GroupCache, cell_buf: []Cell) !Shaper {
return Shaper{
.group = group,
.hb_buf = try harfbuzz.Buffer.create(),
.cell_buf = cell_buf,
};
}
@ -44,27 +50,85 @@ pub fn runIterator(self: *Shaper, row: terminal.Screen.Row) RunIterator {
/// text run that was iterated since the text run does share state with the
/// Shaper struct.
///
/// NOTE: there is no return value here yet because its still WIP
pub fn shape(self: Shaper, run: TextRun) void {
/// The return value is only valid until the next shape call is called.
///
/// If there is not enough space in the cell buffer, an error is returned.
pub fn shape(self: *Shaper, run: TextRun) ![]Cell {
const face = self.group.group.faceFromIndex(run.font_index);
harfbuzz.shape(face.hb_font, self.hb_buf, null);
// If our buffer is empty, we short-circuit the rest of the work
// return nothing.
if (self.hb_buf.getLength() == 0) return self.cell_buf[0..0];
const info = self.hb_buf.getGlyphInfos();
const pos = self.hb_buf.getGlyphPositions() orelse return;
const pos = self.hb_buf.getGlyphPositions() orelse return error.HarfbuzzFailed;
// This is perhaps not true somewhere, but we currently assume it is true.
// If it isn't true, I'd like to catch it and learn more.
assert(info.len == pos.len);
// log.warn("info={} pos={}", .{ info.len, pos.len });
// for (info) |v, i| {
// log.warn("info {} = {}", .{ i, v });
// }
// Convert all our info/pos to cells and set it.
if (info.len > self.cell_buf.len) return error.OutOfMemory;
// log.debug("info={} pos={}", .{ info.len, pos.len });
// x is the column that we currently occupy. We start at the offset.
var x: u16 = run.offset;
for (info) |v, i| {
// The number of codepoints is used as the cell "width". If
// we're the last cell, this is remaining otherwise we use cluster numbers
// to detect since we set the cluster number to the column it
// originated.
const cp_width = if (i == info.len - 1)
run.max_cluster - v.cluster
else width: {
const next_cluster = info[i + 1].cluster;
break :width next_cluster - v.cluster;
};
self.cell_buf[i] = .{
.x = x,
.glyph_index = v.codepoint,
.width = if (cp_width > 2) 2 else @intCast(u8, cp_width),
};
// Increase x by the amount of codepoints we replaced so that
// we retain the grid.
x += @intCast(u16, cp_width);
// log.debug("i={} info={} pos={} cell={}", .{ i, v, pos[i], self.cell_buf[i] });
}
return self.cell_buf[0..info.len];
}
pub const Cell = struct {
/// The column that this cell occupies. Since a set of shaper cells is
/// always on the same line, only the X is stored. It is expected the
/// caller has access to the original screen cell.
x: u16,
/// The glyph index for this cell. The font index to use alongside
/// this cell is available in the text run.
glyph_index: u32,
/// The width that this cell consumes.
width: u8,
};
/// A single text run. A text run is only valid for one Shaper and
/// until the next run is created.
pub const TextRun = struct {
/// The offset in the row where this run started
offset: u16,
/// The total number of cells produced by this run.
cells: u16,
/// The maximum cluster value used
max_cluster: u16,
/// The font index to use for the glyphs of this run.
font_index: Group.FontIndex,
};
@ -85,6 +149,7 @@ pub const RunIterator = struct {
// Go through cell by cell and accumulate while we build our run.
var j: usize = self.i;
var max_cluster: usize = j;
while (j < self.row.lenCells()) : (j += 1) {
const cell = self.row.getCell(j);
@ -96,8 +161,19 @@ pub const RunIterator = struct {
else
.regular;
// Determine the font for this cell
const font_idx_opt = try self.shaper.group.indexForCodepoint(alloc, style, cell.char);
// Determine the font for this cell. We'll use fallbacks
// manually here to try replacement chars and then a space
// for unknown glyphs.
const font_idx_opt = (try self.shaper.group.indexForCodepoint(
alloc,
style,
cell.char,
)) orelse (try self.shaper.group.indexForCodepoint(
alloc,
style,
0xFFFD,
)) orelse
try self.shaper.group.indexForCodepoint(alloc, style, ' ');
const font_idx = font_idx_opt.?;
//log.warn("char={x} idx={}", .{ cell.char, font_idx });
if (j == self.i) current_font = font_idx;
@ -116,15 +192,22 @@ pub const RunIterator = struct {
self.shaper.hb_buf.add(cp, @intCast(u32, j));
}
}
max_cluster = j;
}
// Finalize our buffer
self.shaper.hb_buf.guessSegmentProperties();
// Move our cursor
self.i = j;
// Move our cursor. Must defer since we use self.i below.
defer self.i = j;
return TextRun{ .font_index = current_font };
return TextRun{
.offset = @intCast(u16, self.i),
.cells = @intCast(u16, j - self.i),
.max_cluster = @intCast(u16, max_cluster),
.font_index = current_font,
};
}
};
@ -194,7 +277,7 @@ test "shape" {
while (try it.next(alloc)) |run| {
count += 1;
try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength());
shaper.shape(run);
_ = try shaper.shape(run);
}
try testing.expectEqual(@as(usize, 1), count);
}
@ -204,11 +287,13 @@ const TestShaper = struct {
shaper: Shaper,
cache: *GroupCache,
lib: Library,
cell_buf: []Cell,
pub fn deinit(self: *TestShaper) void {
self.shaper.deinit();
self.cache.deinit(self.alloc);
self.alloc.destroy(self.cache);
self.alloc.free(self.cell_buf);
self.lib.deinit();
}
};
@ -230,7 +315,10 @@ fn testShaper(alloc: Allocator) !TestShaper {
try cache_ptr.group.addFace(alloc, .regular, try Face.init(lib, testFont, .{ .points = 12 }));
try cache_ptr.group.addFace(alloc, .regular, try Face.init(lib, testEmoji, .{ .points = 12 }));
var shaper = try init(cache_ptr);
var cell_buf = try alloc.alloc(Cell, 80);
errdefer alloc.free(cell_buf);
var shaper = try init(cache_ptr, cell_buf);
errdefer shaper.deinit();
return TestShaper{
@ -238,5 +326,6 @@ fn testShaper(alloc: Allocator) !TestShaper {
.shaper = shaper,
.cache = cache_ptr,
.lib = lib,
.cell_buf = cell_buf,
};
}