Improve terminal rendering performance.

This commit is contained in:
Greyson Parrelli
2026-08-14 22:39:48 -04:00
parent 9df3d35422
commit e4b9fac3c9
+212 -19
View File
@@ -81,6 +81,30 @@ layout: ?*pango.Layout = null,
/// this tracks what was set on it, and a fresh layout has none of them.
font_idx: ?usize = null,
/// The loaded font behind each entry of `fonts`, resolved on first use and
/// held for as long as we use its scaled font: the scaled font belongs to it,
/// not to us, and dropping this reference would take that with it.
loaded: [4]?*pango.Font = @splat(null),
/// The Cairo scaled font behind each entry of `fonts`, resolved on first use.
/// Drawing glyphs through Cairo directly skips Pango's itemization and
/// shaping, which is almost the entire cost of a frame — see `drawGlyphRun`.
scaled: [4]?*cairo.ScaledFont = @splat(null),
/// Codepoint to glyph index, per font style. A terminal draws the same few
/// dozen characters over and over, so the map fills up almost immediately and
/// then never misses. A zero index means the font has no glyph for it, which
/// is the signal to fall back to Pango rather than draw a notdef box.
glyphs: [4]std.AutoHashMapUnmanaged(u21, c_ulong) = @splat(.empty),
/// Scratch buffer for the glyphs of a single run, positioned on the grid.
glyph_buf: std.ArrayListUnmanaged(cairo.Glyph) = .empty,
/// Whether every cell in the run so far can be drawn from `glyphs`. Cleared
/// by anything the fast path can't place on the grid by itself: a character
/// the font lacks, or a cell whose text is more than one codepoint.
run_fast: bool = true,
/// Cell geometry derived from the font metrics.
cell_w: f64 = 8,
cell_h: f64 = 16,
@@ -331,6 +355,9 @@ pub fn destroy(self: *Terminal) void {
self.session.destroy();
self.run_buf.deinit(self.alloc);
self.row_buf.deinit(self.alloc);
self.glyph_buf.deinit(self.alloc);
for (&self.glyphs) |*m| m.deinit(self.alloc);
for (self.loaded) |f| if (f) |font| font.as(gobject.Object).unref();
if (self.layout) |layout| layout.unref();
for (self.fonts) |d| d.free();
self.font.free();
@@ -1083,9 +1110,13 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void {
}
const look = looks[x];
const style = fontIndex(look.bold, look.italic);
const scaled = self.scaledFont(layout, style);
self.run_buf.clearRetainingCapacity();
self.glyph_buf.clearRetainingCapacity();
self.run_blank = true;
self.run_fast = scaled != null;
const run_start = x;
while (x < cells.len) : (x += 1) {
const cell = &cells[x];
@@ -1093,20 +1124,32 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void {
if (x != run_start and !look.sameRun(looks[x])) break;
try self.appendCell(pin, cell);
if (self.run_fast) try self.appendGlyph(
scaled.?,
style,
cell,
pad + @as(f64, @floatFromInt(x)) * self.cell_w,
row_top + self.ascent,
);
}
// A run of nothing but blanks puts no ink down, so the only reason
// to hand it to Pango is a decoration drawn across it.
// to draw it at all is a decoration across it.
const inked = !self.run_blank or look.underline or look.strikethrough;
if (self.run_buf.items.len > 0 and inked) {
try self.drawRun(
cr,
layout,
look,
pad + @as(f64, @floatFromInt(run_start)) * self.cell_w,
row_top,
x - run_start,
);
const origin_x = pad + @as(f64, @floatFromInt(run_start)) * self.cell_w;
if (self.run_fast) {
self.drawGlyphRun(cr, scaled.?, look, origin_x, row_top, x - run_start);
} else {
try self.drawRun(
cr,
layout,
look,
origin_x,
row_top,
x - run_start,
);
}
}
}
}
@@ -1155,6 +1198,37 @@ fn appendCell(self: *Terminal, pin: vt.Pin, cell: *const vt.Cell) !void {
}
}
/// Add a cell's glyph to the run being built, positioned on the grid rather
/// than after whatever came before it. Clears `run_fast` if this cell is one
/// the fast path can't speak for, which sends the whole run back to Pango.
fn appendGlyph(
self: *Terminal,
scaled: *cairo.ScaledFont,
style: usize,
cell: *const vt.Cell,
x: f64,
baseline: f64,
) !void {
// Only a lone codepoint can be placed by index alone. A grapheme has
// combining marks to position, and a background-only cell has no text.
if (cell.content_tag != .codepoint) {
if (cell.content_tag == .codepoint_grapheme) self.run_fast = false;
return;
}
const cp = cell.content.codepoint.data;
// Empty cells and spaces are holes in the run, not glyphs. Skipping them
// is what lets a blank stretch inside a run cost nothing to draw.
if (cp == 0 or cp == ' ') return;
const index = self.glyphIndex(scaled, style, cp) orelse {
self.run_fast = false;
return;
};
try self.glyph_buf.append(self.alloc, .{ .index = index, .x = x, .y = baseline });
}
fn appendCodepoint(self: *Terminal, cp: u21) !void {
var buf: [4]u8 = undefined;
const n = std.unicode.utf8Encode(cp, &buf) catch return;
@@ -1187,16 +1261,56 @@ fn drawRun(
// across the run. Their width is the columns the run covers, not the
// width Pango laid the text out to: asking Pango means forcing it to
// measure, and a run ending in a space would underline short of the cell.
if (look.underline or look.strikethrough) {
const run_w = @as(f64, @floatFromInt(cols)) * self.cell_w;
if (look.underline) {
cr.rectangle(x, y + self.ascent + 2, run_w, 1);
cr.fill();
}
if (look.strikethrough) {
cr.rectangle(x, y + self.ascent * 0.6, run_w, 1);
cr.fill();
}
self.drawDecorations(cr, look, x, y, cols);
}
/// Draw a run straight from cached glyph indices.
///
/// This is the whole point of the glyph cache: Pango re-itemizes and re-shapes
/// every run on every frame, and for a monospace grid both answers are already
/// known — one glyph per cell, at a column we can compute. Handing Cairo the
/// glyphs directly skips all of it, and placing each glyph on its own column
/// also stops advances accumulating rounding error across a long row.
fn drawGlyphRun(
self: *Terminal,
cr: *cairo.Context,
scaled: *cairo.ScaledFont,
look: Appearance,
x: f64,
y: f64,
cols: usize,
) void {
const r, const g, const b = look.fg.cairoRgb();
cr.setSourceRgb(r, g, b);
if (self.glyph_buf.items.len > 0) {
cr.setScaledFont(scaled);
cr.showGlyphs(self.glyph_buf.items.ptr, @intCast(self.glyph_buf.items.len));
}
self.drawDecorations(cr, look, x, y, cols);
}
/// Underline and strikethrough, drawn across the columns the run covers.
/// Shared by both draw paths so they can't disagree about where a line goes.
fn drawDecorations(
self: *Terminal,
cr: *cairo.Context,
look: Appearance,
x: f64,
y: f64,
cols: usize,
) void {
if (!look.underline and !look.strikethrough) return;
const run_w = @as(f64, @floatFromInt(cols)) * self.cell_w;
if (look.underline) {
cr.rectangle(x, y + self.ascent + 2, run_w, 1);
cr.fill();
}
if (look.strikethrough) {
cr.rectangle(x, y + self.ascent * 0.6, run_w, 1);
cr.fill();
}
}
@@ -1209,6 +1323,85 @@ fn setFont(self: *Terminal, layout: *pango.Layout, idx: usize) void {
self.font_idx = idx;
}
/// The Cairo scaled font for a style, loaded on first use. Resolved through
/// the layout's own context so it comes out with the same font options — and
/// so the same hinting and antialiasing — as the Pango path it stands in for.
fn scaledFont(self: *Terminal, layout: *pango.Layout, idx: usize) ?*cairo.ScaledFont {
if (self.scaled[idx]) |s| return s;
const font = layout.getContext().loadFont(self.fonts[idx]) orelse return null;
// Sound because of where the context came from: the layout was built by
// `pangocairo.createLayout`, so its font map is a PangoCairoFontMap and
// every font it hands out implements PangoCairoFont. A GObject interface
// pointer is the instance pointer, so this is the cast that check implies.
const cairo_font: *pangocairo.Font = @ptrCast(font);
const s = cairo_font.getScaledFont() orelse {
font.as(gobject.Object).unref();
return null;
};
self.loaded[idx] = font;
self.scaled[idx] = s;
return s;
}
/// The glyph index for a codepoint in a style, or null if the font has no
/// glyph for it and Pango has to handle the run instead.
fn glyphIndex(self: *Terminal, scaled: *cairo.ScaledFont, idx: usize, cp: u21) ?c_ulong {
const gop = self.glyphs[idx].getOrPut(self.alloc, cp) catch return null;
if (!gop.found_existing) gop.value_ptr.* = lookupGlyph(scaled, cp);
// Zero is .notdef in every font, so it is both "no glyph" and the cached
// form of "don't ask again".
return if (gop.value_ptr.* == 0) null else gop.value_ptr.*;
}
/// Declared here rather than used from the Cairo bindings: `num_glyphs` and
/// `num_clusters` are `int *` out-parameters in Cairo, and the generated
/// binding has them by value, so calling it as bound would have Cairo write
/// through whatever address the integer happened to be.
extern fn cairo_scaled_font_text_to_glyphs(
scaled_font: *cairo.ScaledFont,
x: f64,
y: f64,
utf8: [*]const u8,
utf8_len: c_int,
glyphs: *?[*]cairo.Glyph,
num_glyphs: *c_int,
clusters: ?*?[*]cairo.TextCluster,
num_clusters: ?*c_int,
cluster_flags: ?*cairo.TextClusterFlags,
) cairo.Status;
fn lookupGlyph(scaled: *cairo.ScaledFont, cp: u21) c_ulong {
var buf: [4]u8 = undefined;
const n = std.unicode.utf8Encode(cp, &buf) catch return 0;
var out: ?[*]cairo.Glyph = null;
var count: c_int = 0;
const status = cairo_scaled_font_text_to_glyphs(
scaled,
0,
0,
&buf,
@intCast(n),
&out,
&count,
null,
null,
null,
);
defer if (out) |g| cairo.Glyph.free(g);
if (status != .success) return 0;
const glyphs = out orelse return 0;
// More than one glyph means the character decomposed, which the grid
// can't place; treat it as uncovered and let Pango draw the run.
if (count != 1) return 0;
return glyphs[0].index;
}
fn drawCursor(
self: *Terminal,
cr: *cairo.Context,