From 88ed6bebf4abfa8deb1b0cdd73dde4b07c7e0a3e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Aug 2026 13:24:20 -0700 Subject: [PATCH] libghostty: much faster wide-character reflow on resize Resizing a terminal whose buffer is heavy with wide characters (CJK, emoji) is now 3-5x faster on the reflow path. This was relatively simple work. We already have a bulk fast path for same-style cells. We previously omitted ANY wide characters from this. We relaxed this by making it work with complete wide pairs (wide followed by spacer tail). ### Benchmarks Resize dance (13 column resizes, 80 -> 40 -> 132 and back and forth again) over a ~2,000-row scrollback buffer. | Workload | Before | After | Speedup | |---|---|---|---| | cjk | 0.938 | 0.296 | 3.2x | | emoji | 0.875 | 0.191 | 4.6x | | mixed build-log | 0.306 | 0.155 | 2.0x | | grapheme | 2.449 | 1.831 | 1.3x | | ascii-short | 0.115 | 0.117 | 1.0x | | ascii-long | 0.109 | 0.112 | 1.0x | | latin | 0.093 | 0.095 | 1.0x | | sgr-truecolor | 0.939 | 0.965 | 1.0x | **AI usage:** Profiled, implemented, benchmarked, and written by Fable. Plan validated by me before doing it, I wrote all the comments/commits/blah. --- src/terminal/PageList.zig | 458 +++++++++++++++++++++++++++++++++++--- 1 file changed, 429 insertions(+), 29 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f68733f8c..672370af2 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1711,18 +1711,20 @@ const ReflowCursor = struct { // Fast path: bulk-copy a run of simple cells directly // into the destination row. The vast majority of cells - // are narrow, have no managed memory (graphemes, - // hyperlinks), and share a single style in long runs, so - // this avoids the per-cell state machine below for most - // of the work. Rows with tracked pins take the slow path - // so pin remapping behaves identically. + // are narrow cells or complete wide pairs, have no + // managed memory (graphemes, hyperlinks), and share a + // single style in long runs, so this avoids the + // per-cell state machine below for most of the work. + // Rows with tracked pins take the slow path so pin + // remapping behaves identically. if (!row_has_pins) { const max_run = @min( cols_len - x, @as(usize, self.page.size.cols) - self.x, ); - const run = bulkRunLength(cells[x..][0..max_run]); - if (run > 0 and self.copyRun(cells[x..][0..run], src_page)) { + const window = cells[x..][0..max_run]; + const run = bulkRunLength(window); + if (run > 0 and self.copyRun(window[0..run], src_page)) { x += run; continue; } @@ -1898,9 +1900,21 @@ const ReflowCursor = struct { fn bulkRunLength(cells: []const pagepkg.Cell) usize { if (cells.len == 0) return 0; const first = cells[0]; - if (!bulkCopyable(first)) return 0; - const run_pattern = BulkRunMask.pattern(first); + // A run may start on a bulk-copyable narrow cell or on a wide pair. + const pair_start = first.content_tag == .codepoint and + first.wide == .wide and + !first.hyperlink; + if (!pair_start and !bulkCopyable(first)) return 0; + + // Patterns for each cell shape admitted to the run. + var proto = first; + proto.wide = .narrow; + const narrow_pattern = BulkRunMask.pattern(proto); + proto.wide = .wide; + const wide_pattern = BulkRunMask.pattern(proto); + proto.wide = .spacer_tail; + const tail_pattern = BulkRunMask.pattern(proto); // Only text cells can contain a placeholder; for bg color // tags the content bits are a color, so we skip the check for @@ -1919,28 +1933,50 @@ const ReflowCursor = struct { )); }; - var len: usize = 1; + var len: usize = 0; + outer: while (true) { + // Vectorized scan: check whole groups of narrow cells at + // once. If a group fully matches, the run extends by the + // whole group; otherwise fall through to the scalar loop + // below, which finds the exact end of the run within it + // or continues through a wide pair. + while (cells.len - len >= bulk_group_len) { + if (!BulkRunMask.eql(cells, len, narrow_pattern)) break; + if (check_placeholder and PlaceholderMask.eqlAny( + cells, + len, + placeholder_pattern, + )) break; + len += bulk_group_len; + } - // Vectorized scan: check whole groups of cells at once. If a - // group fully matches, the run extends by the whole group; - // otherwise fall through to the scalar loop below, which - // finds the exact end of the run within it. - while (cells.len - len >= bulk_group_len) { - if (!BulkRunMask.eql(cells, len, run_pattern)) break; - if (check_placeholder and PlaceholderMask.eqlAny( - cells, - len, - placeholder_pattern, - )) break; - len += bulk_group_len; - } + while (len < cells.len) { + const cell = cells[len]; + if (BulkRunMask.eqlScalar(cell, narrow_pattern)) { + if (check_placeholder and PlaceholderMask.eqlScalar( + cell, + placeholder_pattern, + )) break :outer; + len += 1; + continue; + } - while (len < cells.len) : (len += 1) { - if (!BulkRunMask.eqlScalar(cells[len], run_pattern)) break; - if (check_placeholder and PlaceholderMask.eqlScalar( - cells[len], - placeholder_pattern, - )) break; + // Wide pairs are consumed atomically so the run + // (or window) end can never split a pair. + if (len + 1 < cells.len and + BulkRunMask.eqlScalar(cell, wide_pattern) and + BulkRunMask.eqlScalar(cells[len + 1], tail_pattern)) + { + len += 2; + // Re-enter the vectorized loop: narrow groups + // commonly follow a stretch of pairs. + continue :outer; + } + + break :outer; + } + + break; } return len; @@ -18207,6 +18243,370 @@ test "PageList resize reflow less cols to wrap a wide char" { } } +test "PageList resize reflow less cols wide char bulk run" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 8, .rows = 1, .max_size = 0 }); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + // A full row of wide character pairs so the reflow takes + // the bulk run path. + for (0..4) |i| { + { + const rac = page.getRowAndCell(i * 2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = @intCast(0x4E00 + i) } }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(i * 2 + 1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 0 } }, + .wide = .spacer_tail, + }; + } + } + } + + // Resize to exactly two pairs per row: runs end on the row + // boundary with no spacer heads needed. + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..4) |i| { + const y = i / 2; + const x = (i % 2) * 2; + { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(0x4E00 + i)), + rac.cell.content.codepoint.data, + ); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(x + 1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint.data); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } + + { + const rac = page.getRowAndCell(0, 0); + try testing.expect(rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + } + { + const rac = page.getRowAndCell(0, 1); + try testing.expect(!rac.row.wrap); + try testing.expect(rac.row.wrap_continuation); + } + } +} + +test "PageList resize reflow less cols wide char bulk run odd cols spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 8, .rows = 1, .max_size = 0 }); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..4) |i| { + { + const rac = page.getRowAndCell(i * 2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = @intCast(0x4E00 + i) } }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(i * 2 + 1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 0 } }, + .wide = .spacer_tail, + }; + } + } + } + + // Resize to an odd number of columns: the bulk run must stop a + // pair short of the row boundary and the slow path inserts a + // spacer head in the final column. + try s.resize(.{ .cols = 5, .reflow = true }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..4) |i| { + const y = i / 2; + const x = (i % 2) * 2; + { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(0x4E00 + i)), + rac.cell.content.codepoint.data, + ); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(x + 1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint.data); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } + + { + const rac = page.getRowAndCell(4, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint.data); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(4, 1); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(rac.row.wrap_continuation); + } + } +} + +test "PageList resize reflow less cols wide char bulk run mixed narrow round trip" { + const testing = std.testing; + const alloc = testing.allocator; + + // The emoji-in-prose shape: single wide pairs separated by + // narrow cells, which must all share a single bulk run. + const Shape = struct { cp: u21, wide: pagepkg.Cell.Wide }; + const shape: []const Shape = &.{ + .{ .cp = 0x4E00, .wide = .wide }, + .{ .cp = 0, .wide = .spacer_tail }, + .{ .cp = 'x', .wide = .narrow }, + .{ .cp = 0x4E01, .wide = .wide }, + .{ .cp = 0, .wide = .spacer_tail }, + .{ .cp = 'y', .wide = .narrow }, + .{ .cp = 0x4E02, .wide = .wide }, + .{ .cp = 0, .wide = .spacer_tail }, + .{ .cp = 'z', .wide = .narrow }, + }; + + var s = try init(alloc, .{ .cols = 9, .rows = 1, .max_size = 0 }); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + for (shape, 0..) |c, x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = c.cp } }, + .wide = c.wide, + }; + } + } + + // Shrink: the run ends exactly on the row boundary after the + // second narrow cell. + try s.resize(.{ .cols = 6, .reflow = true }); + try testing.expectEqual(@as(usize, 6), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + for (shape[0..6], 0..) |c, x| { + const rac = page.getRowAndCell(x, 0); + try testing.expectEqual(c.cp, rac.cell.content.codepoint.data); + try testing.expectEqual(c.wide, rac.cell.wide); + } + for (shape[6..], 0..) |c, x| { + const rac = page.getRowAndCell(x, 1); + try testing.expectEqual(c.cp, rac.cell.content.codepoint.data); + try testing.expectEqual(c.wide, rac.cell.wide); + } + try testing.expect(page.getRowAndCell(0, 0).row.wrap); + try testing.expect(page.getRowAndCell(0, 1).row.wrap_continuation); + } + + // Grow back: the wrapped rows must rejoin into the original + // single-row layout. + try s.resize(.{ .cols = 9, .reflow = true }); + try testing.expectEqual(@as(usize, 9), s.cols); + try testing.expectEqual(@as(usize, 1), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + for (shape, 0..) |c, x| { + const rac = page.getRowAndCell(x, 0); + try testing.expectEqual(c.cp, rac.cell.content.codepoint.data); + try testing.expectEqual(c.wide, rac.cell.wide); + } + try testing.expect(!page.getRowAndCell(0, 0).row.wrap); + } +} + +test "PageList resize reflow less cols wide char bulk run styled" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 8, .rows = 1, .max_size = 0 }); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + // Create a style + const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; + const style_id = try page.styles.add(page.memory, style); + + // Styled pairs: the tail shares the wide cell's style, as + // the print path writes them. + for (0..4) |i| { + { + const rac = page.getRowAndCell(i * 2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = @intCast(0x4E00 + i) } }, + .wide = .wide, + .style_id = style_id, + }; + page.styles.use(page.memory, style_id); + } + { + const rac = page.getRowAndCell(i * 2 + 1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 0 } }, + .wide = .spacer_tail, + .style_id = style_id, + }; + page.styles.use(page.memory, style_id); + } + } + + // We're over-counted by 1 because `add` implies `use`. + page.styles.release(page.memory, style_id); + } + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..2) |y| { + for (0..4) |x| { + const rac = page.getRowAndCell(x, y); + const style_id = rac.cell.style_id; + try testing.expect(style_id != 0); + + const style = page.styles.get(page.memory, style_id); + try testing.expect(style.flags.bold); + try testing.expect(rac.row.styled); + try testing.expectEqual( + if (x % 2 == 0) pagepkg.Cell.Wide.wide else pagepkg.Cell.Wide.spacer_tail, + rac.cell.wide, + ); + } + } + } +} + +test "PageList resize reflow less cols wide char bulk run degenerate spacer tail style" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 4, .rows = 1, .max_size = 0 }); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + // A styled wide cell whose tail does NOT share its style. + // This can't come from the print path, but the run scan must + // reject the pair (slow path) rather than rewrite the tail + // to the wide cell's style. + const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; + const style_id = try page.styles.add(page.memory, style); + + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 0x4E00 } }, + .wide = .wide, + .style_id = style_id, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 0 } }, + .wide = .spacer_tail, + }; + } + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 'x' } }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = true }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 1), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 0x4E00), rac.cell.content.codepoint.data); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + try testing.expect(rac.cell.style_id != 0); + const style = page.styles.get(page.memory, rac.cell.style_id); + try testing.expect(style.flags.bold); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + try testing.expectEqual(stylepkg.default_id, rac.cell.style_id); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint.data); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + } +} + test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a spacer head" { const testing = std.testing; const alloc = testing.allocator;