From c249b9de3496bc3e8c4128686ba64553e17409a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 31 Jul 2026 20:48:19 -0700 Subject: [PATCH] terminal: bulk-copy runs of simple cells during reflow Reflow copied every cell through a per-cell state machine (writeCell) that dispatches on content tag, wide property, grapheme, hyperlink, and style handling, and advances the destination cursor one cell at a time. The vast majority of cells in practice are narrow text or bg-color cells with no managed memory that share a single style across long runs. reflowRow now scans ahead for the run of such cells bounded by the remaining space in the destination row, copies the run with a single memcpy, and adjusts the style ref count once for the whole run via useMultiple. Wide characters, spacers, graphemes, hyperlinks, Kitty placeholders, and rows containing tracked pins all take the original per-cell path, and a style set failure falls back to writeCell which handles growing page capacity. 2.19x faster on ghostty-bench +terminal-resize --mode=cols (120x80 terminal, 10k-line scrollback, shrink/grow column reflow cycles). --- src/terminal/PageList.zig | 167 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index d1af5d2a7..1ace0613f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1673,6 +1673,25 @@ const ReflowCursor = struct { self.page_row.wrap_continuation = true; } + // 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. + 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)) { + x += run; + continue; + } + } + // Move any tracked pins from the source. if (row_has_pins) { const pin_keys = list.tracked_pins.keys(); @@ -1746,6 +1765,154 @@ const ReflowCursor = struct { } } + /// True if this cell can be copied verbatim as part of a bulk + /// run: a narrow plain-text or bg-color cell with no managed + /// memory (graphemes, hyperlinks) and no special reflow handling + /// (wide characters, spacers, Kitty virtual placeholders). For + /// these cells writeCell reduces to a copy of the raw cell plus + /// a style ref count adjustment. + inline fn bulkCopyable(cell: pagepkg.Cell) bool { + return switch (cell.content_tag) { + .codepoint => copyable: { + if (cell.wide != .narrow) break :copyable false; + if (cell.hyperlink) break :copyable false; + if (comptime build_options.kitty_graphics) { + // Placeholders must set a row flag, so they take + // the slow path. + if (cell.content.codepoint.data == + kitty.graphics.unicode.placeholder) + { + break :copyable false; + } + } + break :copyable true; + }, + + // Grapheme data must be cloned cell-by-cell. + .codepoint_grapheme => false, + + // These are guaranteed to have no style or grapheme data + // (see writeCell) so they are pure copies. The style + // check is defensive so that a bg cell can never join or + // extend a styled run. + .bg_color_palette, + .bg_color_rgb, + => cell.style_id == stylepkg.default_id, + }; + } + + /// The length of the prefix of cells that can be copied at once + /// with copyRun: bulk-copyable cells sharing a single style. + fn bulkRunLength(cells: []const pagepkg.Cell) usize { + if (cells.len == 0) return 0; + const first = cells[0]; + if (!bulkCopyable(first)) return 0; + + var len: usize = 1; + while (len < cells.len) : (len += 1) { + const cell = cells[len]; + if (!bulkCopyable(cell) or + cell.style_id != first.style_id) break; + } + + return len; + } + + /// Copy a run of bulk-copyable cells (see bulkCopyable) sharing + /// one style into the destination row at the current position, + /// then advance the cursor. The run must fit in the remaining + /// columns of the destination row. + /// + /// Returns false without any state change if the style could not + /// be mapped into the destination page without a capacity + /// change; the caller should fall back to writeCell which + /// handles growing capacity. + fn copyRun( + self: *ReflowCursor, + src_cells: []const pagepkg.Cell, + src_page: *const Page, + ) bool { + assert(!self.pending_wrap); + assert(src_cells.len >= 1); + assert(src_cells.len <= self.page.size.cols - self.x); + + const style_id = src_cells[0].style_id; + const n: u16 = @intCast(src_cells.len); + + // Resolve the destination style id for this run and take one + // reference per cell. This mirrors the per-cell style logic + // in writeCell, including the memoization (see StyleCache). + const dst_style_id: stylepkg.Id = if (style_id == stylepkg.default_id) + stylepkg.default_id + else dst: { + if (self.style_cache.src_page == @as(?*const Page, src_page) and + self.style_cache.src_id == style_id) + { + const id = self.style_cache.dst_id; + self.page.styles.useMultiple(self.page.memory, id, n); + break :dst id; + } + + const style = src_page.styles.get( + src_page.memory, + style_id, + ).*; + + // Any error here (set full or needs rehash) is handled + // by falling back to the slow path, which grows capacity. + // No state has been modified yet at this point. + const id = (self.page.styles.addWithId( + self.page.memory, + style, + style_id, + ) catch return false) orelse style_id; + + // addWithId took one reference, take the rest. + if (n > 1) self.page.styles.useMultiple( + self.page.memory, + id, + n - 1, + ); + + self.style_cache = .{ + .src_page = src_page, + .src_id = style_id, + .dst_id = id, + }; + + break :dst id; + }; + + // Copy the raw cell contents. + const dst_cells: []pagepkg.Cell = @as( + [*]pagepkg.Cell, + @ptrCast(self.page_cell), + )[0..src_cells.len]; + @memcpy(dst_cells, src_cells); + + // If the style resolved to a different id in the destination + // page then rewrite the copied cells to point at it. + if (dst_style_id != style_id) { + for (dst_cells) |*cell| cell.style_id = dst_style_id; + } + if (dst_style_id != stylepkg.default_id) self.page_row.styled = true; + + // Advance the cursor, matching what repeated cursorForward + // calls after each cell write would have done. + const cols = self.page.size.cols; + const cell_ptr: [*]pagepkg.Cell = @ptrCast(self.page_cell); + if (self.x + n == cols) { + self.x = cols - 1; + self.page_cell = @ptrCast(cell_ptr + n - 1); + self.pending_wrap = true; + } else { + self.x += n; + self.page_cell = @ptrCast(cell_ptr + n); + } + + return true; + } + /// Write a cell. On error, this will not unwrite the cell but /// the cell may be incomplete (but valid). For example, if the source /// cell is styled and we failed to allocate space for styles, the