From e4b9fac3c9b2dc15fe49ee98505331489756b4d9 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 14 Aug 2026 22:39:48 -0400 Subject: [PATCH] Improve terminal rendering performance. --- src/Terminal.zig | 231 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 212 insertions(+), 19 deletions(-) diff --git a/src/Terminal.zig b/src/Terminal.zig index 04428e9..867ee87 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -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,