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. // the entire terminal grid in a single GPU pass.
layout (location = 6) in uint mode_in; 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 // The background or foreground color for the fragment, depending on
// whether this is a background or foreground pass. // whether this is a background or foreground pass.
flat out vec4 color; flat out vec4 color;
@ -133,7 +136,7 @@ void main() {
// The "+ 3" here is to give some wiggle room for fonts that are // The "+ 3" here is to give some wiggle room for fonts that are
// BARELY over it. // BARELY over it.
vec2 glyph_size_downsampled = glyph_size; 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.x = cell_size_scaled.x;
glyph_size_downsampled.y = glyph_size.y * (glyph_size_downsampled.x / glyph_size.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); 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 /// uint mode
mode: GPUCellMode, mode: GPUCellMode,
/// The width in grid cells that a rendering takes.
grid_width: u16 = 1,
}; };
const GPUCellMode = enum(u8) { 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); try vbobind.attributeAdvanced(5, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(GPUCell), offset);
offset += 4 * @sizeOf(u8); offset += 4 * @sizeOf(u8);
try vbobind.attributeIAdvanced(6, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(GPUCell), offset); 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(0);
try vbobind.enableAttribArray(1); try vbobind.enableAttribArray(1);
try vbobind.enableAttribArray(2); try vbobind.enableAttribArray(2);
@ -231,6 +236,7 @@ pub fn init(
try vbobind.enableAttribArray(4); try vbobind.enableAttribArray(4);
try vbobind.enableAttribArray(5); try vbobind.enableAttribArray(5);
try vbobind.enableAttribArray(6); try vbobind.enableAttribArray(6);
try vbobind.enableAttribArray(7);
try vbobind.attributeDivisor(0, 1); try vbobind.attributeDivisor(0, 1);
try vbobind.attributeDivisor(1, 1); try vbobind.attributeDivisor(1, 1);
try vbobind.attributeDivisor(2, 1); try vbobind.attributeDivisor(2, 1);
@ -238,6 +244,7 @@ pub fn init(
try vbobind.attributeDivisor(4, 1); try vbobind.attributeDivisor(4, 1);
try vbobind.attributeDivisor(5, 1); try vbobind.attributeDivisor(5, 1);
try vbobind.attributeDivisor(6, 1); try vbobind.attributeDivisor(6, 1);
try vbobind.attributeDivisor(7, 1);
// Build our texture // Build our texture
const tex = try gl.Texture.create(); 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 // We've written no data to the GPU, refresh it all
self.gl_cells_written = 0; 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 // Build each cell
var rowIter = term.screen.rowIterator(.viewport); var rowIter = term.screen.rowIterator(.viewport);
var y: usize = 0; var y: usize = 0;
while (rowIter.next()) |row| { while (rowIter.next()) |row| {
defer y += 1; defer y += 1;
var cellIter = row.cellIterator(); // Split our row into runs and shape each one.
var x: usize = 0; var iter = shaper.runIterator(row);
while (cellIter.next()) |cell| { while (try iter.next(self.alloc)) |run| {
defer x += 1; for (try shaper.shape(run)) |shaper_cell| {
assert(try self.updateCell(term, cell, x, y)); 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, .mode = mode,
.grid_col = @intCast(u16, term.screen.cursor.x), .grid_col = @intCast(u16, term.screen.cursor.x),
.grid_row = @intCast(u16, term.screen.cursor.y), .grid_row = @intCast(u16, term.screen.cursor.y),
.grid_width = if (cell.attrs.wide) 2 else 1,
.fg_r = 0, .fg_r = 0,
.fg_g = 0, .fg_g = 0,
.fg_b = 0, .fg_b = 0,
@ -417,6 +439,8 @@ pub fn updateCell(
self: *Grid, self: *Grid,
term: *Terminal, term: *Terminal,
cell: terminal.Screen.Cell, cell: terminal.Screen.Cell,
shaper_cell: font.Shaper.Cell,
shaper_run: font.Shaper.TextRun,
x: usize, x: usize,
y: usize, y: usize,
) !bool { ) !bool {
@ -471,9 +495,6 @@ pub fn updateCell(
break :colors res; 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. // Calculate the amount of space we need in the cells list.
const needed = needed: { const needed = needed: {
var i: usize = 0; var i: usize = 0;
@ -496,6 +517,7 @@ pub fn updateCell(
.mode = mode, .mode = mode,
.grid_col = @intCast(u16, x), .grid_col = @intCast(u16, x),
.grid_row = @intCast(u16, y), .grid_row = @intCast(u16, y),
.grid_width = shaper_cell.width,
.glyph_x = 0, .glyph_x = 0,
.glyph_y = 0, .glyph_y = 0,
.glyph_width = 0, .glyph_width = 0,
@ -515,37 +537,16 @@ pub fn updateCell(
// If the cell is empty then we draw nothing in the box. // If the cell is empty then we draw nothing in the box.
if (!cell.empty()) { 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 // Render
const face = self.font_group.group.faceFromIndex(font_info.index); const face = self.font_group.group.faceFromIndex(shaper_run.font_index);
const glyph_index = face.glyphIndex(font_info.ch).?; const glyph = try self.font_group.renderGlyph(
const glyph = try self.font_group.renderGlyph(self.alloc, font_info.index, glyph_index); self.alloc,
shaper_run.font_index,
shaper_cell.glyph_index,
);
// If we're rendering a color font, we use the color atlas // If we're rendering a color font, we use the color atlas
var mode: GPUCellMode = .fg;
if (face.hasColor()) mode = .fg_color; if (face.hasColor()) mode = .fg_color;
// If the cell is wide, we need to note that in the mode // If the cell is wide, we need to note that in the mode
@ -555,6 +556,7 @@ pub fn updateCell(
.mode = mode, .mode = mode,
.grid_col = @intCast(u16, x), .grid_col = @intCast(u16, x),
.grid_row = @intCast(u16, y), .grid_row = @intCast(u16, y),
.grid_width = shaper_cell.width,
.glyph_x = glyph.atlas_x, .glyph_x = glyph.atlas_x,
.glyph_y = glyph.atlas_y, .glyph_y = glyph.atlas_y,
.glyph_width = glyph.width, .glyph_width = glyph.width,
@ -580,6 +582,7 @@ pub fn updateCell(
.mode = mode, .mode = mode,
.grid_col = @intCast(u16, x), .grid_col = @intCast(u16, x),
.grid_row = @intCast(u16, y), .grid_row = @intCast(u16, y),
.grid_width = shaper_cell.width,
.glyph_x = 0, .glyph_x = 0,
.glyph_y = 0, .glyph_y = 0,
.glyph_width = 0, .glyph_width = 0,

View File

@ -22,10 +22,16 @@ group: *GroupCache,
/// calls to prevent allocations. /// calls to prevent allocations.
hb_buf: harfbuzz.Buffer, 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{ return Shaper{
.group = group, .group = group,
.hb_buf = try harfbuzz.Buffer.create(), .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 /// text run that was iterated since the text run does share state with the
/// Shaper struct. /// Shaper struct.
/// ///
/// NOTE: there is no return value here yet because its still WIP /// The return value is only valid until the next shape call is called.
pub fn shape(self: Shaper, run: TextRun) void { ///
/// 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); const face = self.group.group.faceFromIndex(run.font_index);
harfbuzz.shape(face.hb_font, self.hb_buf, null); 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 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. // 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. // If it isn't true, I'd like to catch it and learn more.
assert(info.len == pos.len); assert(info.len == pos.len);
// log.warn("info={} pos={}", .{ info.len, pos.len }); // Convert all our info/pos to cells and set it.
// for (info) |v, i| { if (info.len > self.cell_buf.len) return error.OutOfMemory;
// log.warn("info {} = {}", .{ i, v }); // 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 /// A single text run. A text run is only valid for one Shaper and
/// until the next run is created. /// until the next run is created.
pub const TextRun = struct { 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, font_index: Group.FontIndex,
}; };
@ -85,6 +149,7 @@ pub const RunIterator = struct {
// Go through cell by cell and accumulate while we build our run. // Go through cell by cell and accumulate while we build our run.
var j: usize = self.i; var j: usize = self.i;
var max_cluster: usize = j;
while (j < self.row.lenCells()) : (j += 1) { while (j < self.row.lenCells()) : (j += 1) {
const cell = self.row.getCell(j); const cell = self.row.getCell(j);
@ -96,8 +161,19 @@ pub const RunIterator = struct {
else else
.regular; .regular;
// Determine the font for this cell // Determine the font for this cell. We'll use fallbacks
const font_idx_opt = try self.shaper.group.indexForCodepoint(alloc, style, cell.char); // 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.?; const font_idx = font_idx_opt.?;
//log.warn("char={x} idx={}", .{ cell.char, font_idx }); //log.warn("char={x} idx={}", .{ cell.char, font_idx });
if (j == self.i) current_font = 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)); self.shaper.hb_buf.add(cp, @intCast(u32, j));
} }
} }
max_cluster = j;
} }
// Finalize our buffer // Finalize our buffer
self.shaper.hb_buf.guessSegmentProperties(); self.shaper.hb_buf.guessSegmentProperties();
// Move our cursor // Move our cursor. Must defer since we use self.i below.
self.i = j; 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| { while (try it.next(alloc)) |run| {
count += 1; count += 1;
try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength());
shaper.shape(run); _ = try shaper.shape(run);
} }
try testing.expectEqual(@as(usize, 1), count); try testing.expectEqual(@as(usize, 1), count);
} }
@ -204,11 +287,13 @@ const TestShaper = struct {
shaper: Shaper, shaper: Shaper,
cache: *GroupCache, cache: *GroupCache,
lib: Library, lib: Library,
cell_buf: []Cell,
pub fn deinit(self: *TestShaper) void { pub fn deinit(self: *TestShaper) void {
self.shaper.deinit(); self.shaper.deinit();
self.cache.deinit(self.alloc); self.cache.deinit(self.alloc);
self.alloc.destroy(self.cache); self.alloc.destroy(self.cache);
self.alloc.free(self.cell_buf);
self.lib.deinit(); 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, testFont, .{ .points = 12 }));
try cache_ptr.group.addFace(alloc, .regular, try Face.init(lib, testEmoji, .{ .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(); errdefer shaper.deinit();
return TestShaper{ return TestShaper{
@ -238,5 +326,6 @@ fn testShaper(alloc: Allocator) !TestShaper {
.shaper = shaper, .shaper = shaper,
.cache = cache_ptr, .cache = cache_ptr,
.lib = lib, .lib = lib,
.cell_buf = cell_buf,
}; };
} }