From 9df3d35422832c6c067088c9e60b63216bb325fd Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 14 Aug 2026 22:18:40 -0400 Subject: [PATCH] Some perf optimizations for blank screens. --- src/Terminal.zig | 153 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 125 insertions(+), 28 deletions(-) diff --git a/src/Terminal.zig b/src/Terminal.zig index 8e0c1de..04428e9 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -65,6 +65,22 @@ session: *Session, area: *gtk.DrawingArea, font: *pango.FontDescription, +/// The font description for each combination of bold and italic, indexed by +/// `fontIndex`. Pango re-itemizes a layout whenever its description changes, +/// so the four we can actually ask for are built once and swapped between +/// rather than mutating one in place per run. +fonts: [4]*pango.FontDescription, + +/// The Pango layout runs are drawn through, kept across frames. Building one +/// per frame throws away Pango's internal state along with it; a live one only +/// has to be re-pointed at the current Cairo context. +layout: ?*pango.Layout = null, + +/// Which of `fonts` the layout currently holds, so an unchanged style between +/// runs doesn't re-set it. Reset per frame — the layout outlives the frame but +/// this tracks what was set on it, and a fresh layout has none of them. +font_idx: ?usize = null, + /// Cell geometry derived from the font metrics. cell_w: f64 = 8, cell_h: f64 = 16, @@ -83,6 +99,18 @@ gesture: vt.SelectionGesture = .init, /// Scratch buffer for building the UTF-8 of a single text run. run_buf: std.ArrayListUnmanaged(u8) = .empty, +/// Whether the run built so far is blank — nothing but spaces and empty +/// cells. A blank run puts no ink on the surface, so it can skip Pango +/// entirely unless it carries a decoration, and most of a terminal row is +/// blank. +run_blank: bool = true, + +/// One row's cells resolved to their final appearance, rebuilt per row and +/// read by both render passes. Resolving a style means a lookup in the page's +/// style table, and doing it per pass per cell meant paying for the same +/// answer about three times over. +row_buf: std.ArrayListUnmanaged(Appearance) = .empty, + /// Called when the session's title changes, so the owner can retitle the tab. on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, on_exit: *const fn (ctx: ?*anyopaque) void, @@ -173,12 +201,24 @@ pub fn create( const area = gtk.DrawingArea.new(); const font = pango.FontDescription.fromString(font_spec); + errdefer font.free(); + + var fonts: [4]*pango.FontDescription = undefined; + var built: usize = 0; + errdefer for (fonts[0..built]) |d| d.free(); + while (built < fonts.len) : (built += 1) { + const d = font.copy() orelse return error.OutOfMemory; + d.setWeight(if (built & bold_bit != 0) .bold else .normal); + d.setStyle(if (built & italic_bit != 0) .italic else .normal); + fonts[built] = d; + } self.* = .{ .alloc = alloc, .session = undefined, .area = area, .font = font, + .fonts = fonts, .on_title = cbs.on_title, .on_exit = cbs.on_exit, .on_focus = cbs.on_focus, @@ -290,6 +330,9 @@ pub fn destroy(self: *Terminal) void { self.gesture.deinit(&self.session.term); self.session.destroy(); self.run_buf.deinit(self.alloc); + self.row_buf.deinit(self.alloc); + if (self.layout) |layout| layout.unref(); + for (self.fonts) |d| d.free(); self.font.free(); self.alloc.destroy(self); } @@ -857,6 +900,15 @@ fn onKeyPressed( // ------------------------------------------------------------------------- // Rendering +/// Bit positions into `fonts`, which is indexed by the two font attributes a +/// run can vary by. +const bold_bit: usize = 1; +const italic_bit: usize = 2; + +fn fontIndex(bold: bool, italic: bool) usize { + return (if (bold) bold_bit else 0) | (if (italic) italic_bit else 0); +} + /// A cell's appearance after resolving palette indices and SGR attributes. const Appearance = struct { fg: theme.Rgb, @@ -956,9 +1008,18 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void { else theme.fg(); - const layout = pangocairo.createLayout(cr); - defer layout.unref(); - layout.setFontDescription(self.font); + // The layout outlives the frame, but it is bound to the Cairo context it + // was made from, so a returning one has to be re-pointed at this frame's. + const layout = self.layout orelse layout: { + const l = pangocairo.createLayout(cr); + self.layout = l; + break :layout l; + }; + pangocairo.updateLayout(cr, layout); + + // Nothing is set on the layout yet this frame, and `updateLayout` may have + // invalidated what was. + self.font_idx = null; const bounds: ?SelectionBounds = bounds: { const sel = screen.selection orelse break :bounds null; @@ -984,15 +1045,18 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void { break :span b.span(p.screen.y, cells.len); } else null; + // Resolve the row once. Both passes below want the same answer for + // every cell, and working it out is the expensive half of drawing. + const looks = try self.rowAppearance(pin, cells, term, default_fg, default_bg, span); + // Pass 1: backgrounds. Drawn as one rect per run so that a wide // block of color doesn't turn into hundreds of tiny fills. var x: usize = 0; while (x < cells.len) { - const start_bg = styled(pin, &cells[x], term, default_fg, default_bg, inSpan(span, x)).bg; + const start_bg = looks[x].bg; var end = x + 1; while (end < cells.len) : (end += 1) { - const next = styled(pin, &cells[end], term, default_fg, default_bg, inSpan(span, end)).bg; - if (!std.meta.eql(start_bg, next)) break; + if (!std.meta.eql(start_bg, looks[end].bg)) break; } if (start_bg) |color| { @@ -1018,27 +1082,30 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void { continue; } - const look = styled(pin, &cells[x], term, default_fg, default_bg, inSpan(span, x)); + const look = looks[x]; self.run_buf.clearRetainingCapacity(); + self.run_blank = true; const run_start = x; while (x < cells.len) : (x += 1) { const cell = &cells[x]; if (cell.wide == .spacer_tail) continue; - - const cell_look = styled(pin, cell, term, default_fg, default_bg, inSpan(span, x)); - if (x != run_start and !look.sameRun(cell_look)) break; + if (x != run_start and !look.sameRun(looks[x])) break; try self.appendCell(pin, cell); } - if (self.run_buf.items.len > 0) { + // 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. + 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, ); } } @@ -1047,6 +1114,27 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void { self.drawCursor(cr, layout, term, default_bg); } +/// Resolve every cell in a row to its final appearance, into the reusable row +/// buffer. Returned as a slice of that buffer, valid until the next row. +fn rowAppearance( + self: *Terminal, + pin: vt.Pin, + cells: []const vt.Cell, + term: *vt.Terminal, + default_fg: theme.Rgb, + default_bg: theme.Rgb, + span: ?[2]usize, +) ![]const Appearance { + self.row_buf.clearRetainingCapacity(); + try self.row_buf.ensureTotalCapacity(self.alloc, cells.len); + for (cells, 0..) |*cell, x| { + self.row_buf.appendAssumeCapacity( + styled(pin, cell, term, default_fg, default_bg, inSpan(span, x)), + ); + } + return self.row_buf.items; +} + /// Append a cell's text to the current run. fn appendCell(self: *Terminal, pin: vt.Pin, cell: *const vt.Cell) !void { switch (cell.content_tag) { @@ -1070,6 +1158,7 @@ fn appendCell(self: *Terminal, pin: vt.Pin, cell: *const vt.Cell) !void { fn appendCodepoint(self: *Terminal, cp: u21) !void { var buf: [4]u8 = undefined; const n = std.unicode.utf8Encode(cp, &buf) catch return; + if (cp != ' ') self.run_blank = false; try self.run_buf.appendSlice(self.alloc, buf[0..n]); } @@ -1080,10 +1169,9 @@ fn drawRun( look: Appearance, x: f64, y: f64, + cols: usize, ) !void { - self.font.setWeight(if (look.bold) .bold else .normal); - self.font.setStyle(if (look.italic) .italic else .normal); - layout.setFontDescription(self.font); + self.setFont(layout, fontIndex(look.bold, look.italic)); // Pango wants a NUL-terminated pointer even though we pass the length. try self.run_buf.append(self.alloc, 0); @@ -1096,23 +1184,29 @@ fn drawRun( pangocairo.showLayout(cr, layout); // Pango has no notion of our grid, so decorations are drawn by hand - // across the exact width of the run. - const run_w = runWidth(layout); - 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(); + // 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(); + } } } -fn runWidth(layout: *pango.Layout) f64 { - var w: c_int = 0; - var h: c_int = 0; - layout.getPixelSize(&w, &h); - return @floatFromInt(w); +/// Point the layout at one of the prebuilt font descriptions, if it isn't +/// already. Setting a description invalidates the layout, so runs that share +/// a style shouldn't keep re-setting the same one. +fn setFont(self: *Terminal, layout: *pango.Layout, idx: usize) void { + if (self.font_idx == idx) return; + layout.setFontDescription(self.fonts[idx]); + self.font_idx = idx; } fn drawCursor( @@ -1154,6 +1248,9 @@ fn drawCursor( var buf: [5]u8 = @splat(0); const n = std.unicode.utf8Encode(cp, buf[0..4]) catch return; + // Explicitly regular: the layout still holds whichever font the + // last run left on it, which has nothing to do with this cell. + self.setFont(layout, fontIndex(false, false)); layout.setText(buf[0..n :0].ptr, @intCast(n)); const tr, const tg, const tb = default_bg.cairoRgb();