terminal: improve resize with reflow performance (~6x faster) (#13537)

Improves the time to resize mixed content w/ wrapping 120x80, 10k lines
of scrollback by about ~6x.

I'm working on deferred resize in another branch, but it still follows
roughly the same logic so I instead decided to shift course and look at
the existing full-pagelist resize+reflow and found many places to
improve while keeping understanding.

The optimizations here match the general patterns of other recent
optimizations: cache some stuff, reuse some pages, bring in our
`page.Mask` helper and add vectorized ops. Nothing exotic we haven't
been doing recently.

Also note the Neovim project brought this up as a noticeable issue and I
believe this will help mitigate their issues until we get proper
deferred reflow in.

**LLM notes:** The optimizations were produced with Fable 5 using a
profile-driven approach (macOS `sample` plus disassembly-level
attribution of the hot loops at each step). I then requested each be
split into its own measurable commit, reviewed each in isolation, and
modified most of the commit messages. This PR message is hand-written.
This commit is contained in:
Mitchell Hashimoto
2026-08-01 06:10:41 -07:00
committed by GitHub
5 changed files with 876 additions and 34 deletions

View File

@@ -0,0 +1,357 @@
//! This benchmark tests the performance of Terminal.resize, with a
//! primary focus on column resizes that reflow soft-wrapped text.
//! Resize happens on the IO thread while holding the terminal lock,
//! so a slow resize directly translates into dropped input and a
//! frozen-feeling UI while the user drags the window edge (which
//! produces a rapid stream of resizes).
//!
//! The terminal is populated once during setup (synthetic fill and/or
//! a data file replayed through the VT stream) and then each step
//! ping-pongs the terminal between two sizes. A full cycle returns the
//! terminal to its original dimensions so the state reaches a steady
//! state after the first cycle and every iteration performs
//! equivalent work.
const TerminalResize = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const terminalpkg = @import("../terminal/main.zig");
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const Terminal = terminalpkg.Terminal;
const global = @import("../global.zig");
const log = std.log.scoped(.@"terminal-resize-bench");
opts: Options,
alloc: Allocator,
terminal: Terminal,
pub const Options = struct {
/// The resize pattern to benchmark. See Mode.
mode: Mode = .cols,
/// Multiplier on the number of resize cycles each step runs. This
/// is useful to make a benchmark run long enough for profiling.
loops: u32 = 1,
/// The initial size of the terminal. This is also the size that
/// every resize cycle returns to.
@"terminal-rows": u16 = 80,
@"terminal-cols": u16 = 120,
/// The dimensions to resize to for the cols/rows/both modes. If
/// unset, they default to half of the respective terminal
/// dimension, which forces every soft-wrapped line to rewrap.
@"resize-cols": ?u16 = null,
@"resize-rows": ?u16 = null,
/// The number of synthetic lines written to the terminal during
/// setup. The content is deterministic: a mix of short lines,
/// soft-wrapped long lines, blank lines, styled cells, and wide
/// characters, since all of these hit different reflow paths.
/// Set to 0 to only use `data`.
@"fill-lines": u32 = 10_000,
/// The maximum scrollback size in bytes. Defaults to the Ghostty
/// application default. Reflow cost scales with the amount of
/// scrollback, not just the visible screen.
@"scrollback-bytes": usize = 50_000_000,
/// The data to read as a filepath. If this is "-" then
/// we will read stdin. If this is unset, only the synthetic fill
/// is used. The data is streamed into the terminal during setup
/// (not part of the benchmark) to build the screen contents that
/// get resized.
data: ?[]const u8 = null,
};
pub const Mode = enum {
/// Resize to the current dimensions. This measures the early-exit
/// path (mode updates, pixel geometry) and acts as a baseline.
noop,
/// Alternate the column count between `terminal-cols` and
/// `resize-cols`. With wraparound enabled (the default), this
/// reflows text in both directions: shrinking wraps long lines
/// and growing unwraps them. This is the primary reflow benchmark.
cols,
/// Like `cols`, but with wraparound mode disabled so the resize
/// does not reflow. Useful as a baseline to isolate the cost of
/// reflow itself from the rest of the resize.
@"cols-no-reflow",
/// Alternate the row count between `terminal-rows` and
/// `resize-rows`. Column count is unchanged so no text reflows;
/// this measures growing/trimming rows against scrollback.
rows,
/// Alternate both dimensions at once, like a diagonal window drag.
both,
};
pub fn create(
alloc: Allocator,
opts: Options,
) !*TerminalResize {
const ptr = try alloc.create(TerminalResize);
errdefer alloc.destroy(ptr);
ptr.* = .{
.opts = opts,
.alloc = alloc,
.terminal = try .init(global.io(), alloc, .{
.rows = opts.@"terminal-rows",
.cols = opts.@"terminal-cols",
.max_scrollback_bytes = opts.@"scrollback-bytes",
}),
};
return ptr;
}
pub fn destroy(self: *TerminalResize, alloc: Allocator) void {
self.terminal.deinit(alloc);
alloc.destroy(self);
}
pub fn benchmark(self: *TerminalResize) Benchmark {
return .init(self, .{
.stepFn = switch (self.opts.mode) {
.noop => stepNoop,
.cols, .@"cols-no-reflow" => stepCols,
.rows => stepRows,
.both => stepBoth,
},
.setupFn = setup,
});
}
/// The column count used by the cols/both modes.
fn targetCols(self: *const TerminalResize) u16 {
return self.opts.@"resize-cols" orelse
@max(1, self.opts.@"terminal-cols" / 2);
}
/// The row count used by the rows/both modes.
fn targetRows(self: *const TerminalResize) u16 {
return self.opts.@"resize-rows" orelse
@max(1, self.opts.@"terminal-rows" / 2);
}
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalResize = @ptrCast(@alignCast(ptr));
// Always reset our terminal state. Note this doesn't resize, but
// create initializes (and steps return) the terminal to the
// requested dimensions so we're always at terminal-rows/cols here.
self.terminal.fullReset();
assert(self.terminal.cols == self.opts.@"terminal-cols");
assert(self.terminal.rows == self.opts.@"terminal-rows");
// Fill with synthetic content first, then replay the data file on
// top if given. Both go through the VT stream so soft wraps,
// styles, etc. are all set exactly as they would be in a real
// session.
self.fill();
try self.replayData();
// Reflow only happens when wraparound mode is set (it is by
// default). We only disable it after filling so the fill itself
// still soft-wraps identically in every mode.
if (self.opts.mode == .@"cols-no-reflow") {
self.terminal.modes.set(.wraparound, false);
}
}
/// Write deterministic synthetic content to the terminal. The goal is
/// content that is representative of a real session so that reflow
/// touches its interesting paths: soft-wrapped lines (must rewrap),
/// short lines (copied as-is), blank lines, styled cells (styles must
/// be moved across pages), and wide characters (can't be split at the
/// wrap column).
fn fill(self: *TerminalResize) void {
if (self.opts.@"fill-lines" == 0) return;
var s = self.terminal.vtStream();
defer s.deinit();
var prng: std.Random.DefaultPrng = .init(0xB3);
const rand = prng.random();
const cols: usize = self.terminal.cols;
var line_buf: [8192]u8 = undefined;
for (0..self.opts.@"fill-lines") |i| {
// Periodically toggle a background style so reflow has to
// carry styled cells into new pages.
if (i % 64 == 0) s.nextSlice("\x1b[48;2;20;40;60m");
if (i % 64 == 32) s.nextSlice("\x1b[m");
// A small portion of lines are blank.
if (i % 16 == 15) {
s.nextSlice("\r\n");
continue;
}
// Line lengths between ~25% and ~250% of the terminal width
// so we get a mix of short lines and soft-wrapped lines.
const min = @max(1, cols / 4);
const max = @min(line_buf.len, cols * 5 / 2);
const len = min + rand.uintLessThan(usize, max - min);
var j: usize = 0;
while (j < len) {
// Sprinkle wide characters into every 8th line.
if (i % 8 == 7 and j % 16 == 8 and j + 3 <= len) {
line_buf[j..][0..3].* = "".*;
j += 3;
continue;
}
// Words of ASCII separated by spaces.
line_buf[j] = if (j % 8 == 7)
' '
else
rand.intRangeAtMost(u8, 'a', 'z');
j += 1;
}
s.nextSlice(line_buf[0..len]);
s.nextSlice("\r\n");
}
}
/// Stream the data file (if any) into the terminal.
fn replayData(self: *TerminalResize) Benchmark.Error!void {
const data_f: std.Io.File = (options.dataFile(
self.opts.data,
) catch |err| {
log.warn("error opening data file err={}", .{err});
return error.BenchmarkFailed;
}) orelse return;
defer data_f.close(global.io());
var stream = self.terminal.vtStream();
defer stream.deinit();
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = data_f.reader(global.io(), &read_buf);
const r = &f_reader.interface;
var buf: [4096]u8 = undefined;
while (true) {
const n = r.readSliceShort(&buf) catch {
log.warn("error reading data file err={?}", .{f_reader.err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
stream.nextSlice(buf[0..n]);
}
}
fn resizeTerminal(
self: *TerminalResize,
cols: u16,
rows: u16,
) Benchmark.Error!void {
self.terminal.resize(self.alloc, .{
.cols = cols,
.rows = rows,
// Realistic cell pixel geometry: real apprt resizes always
// carry it, and it exercises the pixel dimension updates.
.cell_size_px = .{ .width = 10, .height = 20 },
}) catch |err| {
log.warn("error resizing terminal err={}", .{err});
return error.BenchmarkFailed;
};
std.mem.doNotOptimizeAway(&self.terminal);
}
fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalResize = @ptrCast(@alignCast(ptr));
const cols = self.opts.@"terminal-cols";
const rows = self.opts.@"terminal-rows";
// We loop because it's so fast (a few ns) that a single resize
// doesn't properly capture our speeds.
for (0..50_000_000 * @as(u64, self.opts.loops)) |_| {
try self.resizeTerminal(cols, rows);
}
}
fn stepCols(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalResize = @ptrCast(@alignCast(ptr));
const cols = self.opts.@"terminal-cols";
const rows = self.opts.@"terminal-rows";
const target = self.targetCols();
// Per-cycle cost differs by orders of magnitude between the two
// modes (a reflow resize walks the entire scrollback; a non-reflow
// resize doesn't), so pick a cycle count that makes each run long
// enough to measure well above process startup and setup cost.
const cycles: u64 = switch (self.opts.mode) {
.cols => 25,
.@"cols-no-reflow" => 1_500,
else => unreachable,
};
// Each cycle shrinks (rewrapping long lines) and grows back
// (unwrapping them), ending at the original size.
for (0..cycles * @as(u64, self.opts.loops)) |_| {
try self.resizeTerminal(target, rows);
try self.resizeTerminal(cols, rows);
}
}
fn stepRows(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalResize = @ptrCast(@alignCast(ptr));
const cols = self.opts.@"terminal-cols";
const rows = self.opts.@"terminal-rows";
const target = self.targetRows();
// Row-only resizes don't reflow so they're much cheaper (tens of
// ns); loop a lot more so the measurement isn't dominated by
// process overhead.
for (0..2_500_000 * @as(u64, self.opts.loops)) |_| {
try self.resizeTerminal(cols, target);
try self.resizeTerminal(cols, rows);
}
}
fn stepBoth(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalResize = @ptrCast(@alignCast(ptr));
const cols = self.opts.@"terminal-cols";
const rows = self.opts.@"terminal-rows";
const target_cols = self.targetCols();
const target_rows = self.targetRows();
for (0..25 * @as(u64, self.opts.loops)) |_| {
try self.resizeTerminal(target_cols, target_rows);
try self.resizeTerminal(cols, rows);
}
}
test TerminalResize {
const testing = std.testing;
const alloc = testing.allocator;
// Small dimensions and fill so this is fast in debug builds while
// still exercising real reflow in both directions.
const impl: *TerminalResize = try .create(alloc, .{
.mode = .cols,
.@"terminal-rows" = 10,
.@"terminal-cols" = 20,
.@"fill-lines" = 50,
});
defer impl.destroy(alloc);
const bench = impl.benchmark();
_ = try bench.run(.once);
}

View File

@@ -15,6 +15,7 @@ pub const Action = enum {
@"scrollback-compression",
@"screen-clone",
@"terminal-parser",
@"terminal-resize",
@"terminal-stream",
@"is-symbol",
@"osc-parser",
@@ -38,6 +39,7 @@ pub const Action = enum {
.@"codepoint-width" => @import("CodepointWidth.zig"),
.@"grapheme-break" => @import("GraphemeBreak.zig"),
.@"terminal-parser" => @import("TerminalParser.zig"),
.@"terminal-resize" => @import("TerminalResize.zig"),
.@"is-symbol" => @import("IsSymbol.zig"),
.@"osc-parser" => @import("OscParser.zig"),
};

View File

@@ -7,6 +7,7 @@ pub const GraphemeBreak = @import("GraphemeBreak.zig");
pub const HyperlinkMap = @import("HyperlinkMap.zig");
pub const ScreenClone = @import("ScreenClone.zig");
pub const TerminalParser = @import("TerminalParser.zig");
pub const TerminalResize = @import("TerminalResize.zig");
pub const IsSymbol = @import("IsSymbol.zig");
pub const PageCompression = @import("PageCompression.zig");
pub const ScrollbackCompression = @import("ScrollbackCompression.zig");

View File

@@ -9,6 +9,7 @@ const build_options = @import("terminal_options");
const Allocator = std.mem.Allocator;
const assert = @import("../quirks.zig").inlineAssert;
const fastmem = @import("../fastmem.zig");
const simd = @import("../simd/main.zig");
const tripwire = @import("../tripwire.zig");
const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList;
const color = @import("color.zig");
@@ -402,6 +403,15 @@ page_size: usize,
/// in the state struct.
page_compression: IncrementalCompressionState = .{},
/// A node available for immediate reuse by createPage, bypassing the
/// memory pool round trip. This is only set during column reflow
/// (resizeCols), which destroys one source page for roughly every
/// destination page it creates: returning a page buffer to the pool
/// decommits it and taking one back recommits it, and that syscall
/// pair per page is a significant part of reflow cost. Always null
/// outside of an in-progress reflow.
recycle_node: ?*List.Node = null,
/// Limits for scrollback.
limits: Limits,
@@ -1416,6 +1426,14 @@ fn resizeCols(
}
}
// Reflowed source pages are stashed for reuse as destination
// pages (see recycle_node). Whether we succeed or fail, a stashed
// node must not outlive the reflow.
defer if (self.recycle_node) |node| {
self.recycle_node = null;
self.destroyNode(node);
};
// Set our new page as the only page. This orphans the existing pages
// in the list, but that's fine since we're gonna delete them anyway.
self.pages.first = first_rewritten_node;
@@ -1431,11 +1449,21 @@ fn resizeCols(
if (preserved_cursor) |c| c.tracked_pin else null,
);
// Once we're done reflowing a page, destroy it immediately.
// This frees memory and makes it more likely in memory
// constrained environments that the next reflow will work.
if (row.y == row.node.rows() - 1) {
self.destroyNode(row.node);
// Once we're done reflowing a page, we're done with it, so
// make it available for reuse (or destroy it). Making it
// immediately available frees memory and makes it more
// likely in memory constrained environments that the next
// reflow will work.
if (row.y == row.node.rows() - 1) destroy_node: {
if (self.recycle_node != null or
row.node.owned != .pool or
row.node.data != .resident)
{
self.destroyNode(row.node);
break :destroy_node;
}
self.recycle_node = row.node;
}
}
@@ -1522,6 +1550,37 @@ const ReflowCursor = struct {
/// This is the final row count of the reflowed pages.
total_rows: usize,
/// Memoizes the most recent source-to-destination style id
/// mapping. Styled cells come in long runs sharing the same style
/// so this lets writeCell bump the destination ref count directly
/// instead of performing a set lookup for every styled cell.
///
/// The destination id is only valid for the current destination
/// page, which is why this lives on the cursor: every destination
/// page change goes through init() which resets this.
style_cache: StyleCache,
/// Memoizes the capacity adjustment for new destination pages
/// (see reflowRow). It only depends on the source page so this is
/// keyed by the source page pointer, which reflow visits
/// sequentially and never revisits.
cap_memo: ?struct {
src_page: *const Page,
cap: Capacity,
},
const StyleCache = struct {
src_page: ?*const Page,
src_id: stylepkg.Id,
dst_id: stylepkg.Id,
const invalid: StyleCache = .{
.src_page = null,
.src_id = stylepkg.default_id,
.dst_id = stylepkg.default_id,
};
};
fn init(node: *List.Node) ReflowCursor {
const page = node.page();
const rows = page.rows.ptr(page.memory);
@@ -1537,6 +1596,9 @@ const ReflowCursor = struct {
// Initially whatever size our input node is.
.total_rows = node.rows(),
.style_cache = .invalid,
.cap_memo = null,
};
}
@@ -1566,12 +1628,21 @@ const ReflowCursor = struct {
if (cols_len == 0 and src_row.semantic_prompt != .none) cols_len = 1;
}
// Handle tracked pin adjustments.
// Handle tracked pin adjustments. We also note whether any
// tracked pin is on this row at all so that the per-cell loop
// below can skip pin scans entirely for the overwhelmingly
// common case of a row with no pins. Note we compare nodes
// rather than pages since a node owns exactly one page; this
// is cheaper and avoids `page()` restoring unrelated
// compressed nodes purely for a comparison.
var row_has_pins = false;
{
const pin_keys = list.tracked_pins.keys();
for (pin_keys) |p| {
if (p.node.page() != src_page or
p.y != src_y) continue;
if (p.node != row.node or p.y != src_y) continue;
// This row has pins
row_has_pins = true;
if (cursor_pin != null and p == cursor_pin.?) continue;
@@ -1593,7 +1664,7 @@ const ReflowCursor = struct {
// If the cursor is after blanks on the right, those cells are still
// before the next write and must reflow with it.
if (cursor_pin) |p| {
if (p.node.page() == src_page and p.y == src_y) {
if (p.node == row.node and p.y == src_y) {
cols_len = @max(cols_len, p.x + 1);
}
}
@@ -1610,18 +1681,16 @@ const ReflowCursor = struct {
// Inherit increased styles or grapheme bytes from the src page
// we're reflowing from for new pages.
const cap = src_page.capacity.adjust(
.{ .cols = self.page.size.cols },
) catch |err| err: {
comptime assert(@TypeOf(err) == error{OutOfMemory});
var cap = src_page.capacity;
cap.cols = self.page.size.cols;
// We're already a non-standard page. We don't want to
// inherit a massive set of rows, so cap it at our std size.
cap.rows = @min(src_page.size.rows, std_capacity.rows);
break :err cap;
};
//
// This only depends on the source page, which we process row
// by row, so memoize it: computing the adjustment requires a
// full page layout calculation which is much too expensive to
// do for every row.
const cap: Capacity = if (self.cap_memo) |memo| cap: {
if (memo.src_page == src_page) break :cap memo.cap;
// Source page changed, fall through to recompute.
break :cap self.computeAndMemoizeCap(src_page);
} else self.computeAndMemoizeCap(src_page);
// Our row isn't blank, write any new rows we deferred.
while (self.new_rows > 0) {
@@ -1640,11 +1709,30 @@ 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();
for (pin_keys) |p| {
if (p.node.page() != src_page or
if (p.node != row.node or
p.y != src_y or
p.x != x) continue;
@@ -1667,16 +1755,15 @@ const ReflowCursor = struct {
.skip_next => {
// Remap any tracked pins at the skipped position (x+1)
// since we won't process that cell in the loop.
const pin_keys = list.tracked_pins.keys();
for (pin_keys) |p| {
if (p.node.page() != src_page or
if (row_has_pins) for (list.tracked_pins.keys()) |p| {
if (p.node != row.node or
p.y != src_y or
p.x != x + 1) continue;
p.node = self.node;
p.x = self.x;
p.y = self.y;
}
};
x += 2;
},
@@ -1714,6 +1801,246 @@ const ReflowCursor = struct {
}
}
/// Compute and memoize the new-page capacity for the given source
/// page. See the call site in reflowRow for details.
fn computeAndMemoizeCap(
self: *ReflowCursor,
src_page: *const Page,
) Capacity {
const cap = src_page.capacity.adjust(
.{ .cols = self.page.size.cols },
) catch |err| err: {
comptime assert(@TypeOf(err) == error{OutOfMemory});
var cap = src_page.capacity;
cap.cols = self.page.size.cols;
// We're already a non-standard page. We don't want to
// inherit a massive set of rows, so cap it at our std size.
cap.rows = @min(src_page.size.rows, std_capacity.rows);
break :err cap;
};
self.cap_memo = .{ .src_page = src_page, .cap = cap };
return cap;
}
/// 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 group length for the vectorized bulk run scan below: the
/// SIMD lane count where the target supports it, otherwise a
/// plain unrolled group like other cell scans use (e.g. the
/// render state scans).
const bulk_group_len = simd.lanes(u64) orelse 8;
/// Masked compare helper covering every cell field that a run
/// must share to be copied by copyRun: given that the first cell
/// of a run passed the full bulkCopyable predicate, equality on
/// these fields implies the same for every subsequent cell, with
/// the same style.
///
/// Note this is slightly stricter than bulkCopyable (e.g. a
/// bg-color cell won't extend an unstyled text run even though it
/// is copyable): that only splits the copy into multiple runs,
/// which is still correct.
const BulkRunMask = pagepkg.Mask(pagepkg.Cell, &.{
"content_tag",
"style_id",
"wide",
"hyperlink",
}, bulk_group_len);
/// Masked compare helper for detecting the Kitty virtual
/// placeholder codepoint in text cells. Placeholders take the
/// slow path (they must set a row flag), so they terminate a run.
const PlaceholderMask = pagepkg.Mask(pagepkg.Cell, &.{
"content.codepoint.data",
}, bulk_group_len);
/// The length of the prefix of cells that can be copied at once
/// with copyRun: bulk-copyable cells sharing a single style. The
/// scan uses masked compares of the raw cell bits (see
/// BulkRunMask), which is significantly cheaper than the
/// field-wise predicate for this hot loop.
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);
// Only text cells can contain a placeholder; for bg color
// tags the content bits are a color, so we skip the check for
// those (the tag is part of BulkRunMask, making tags uniform
// per run).
const check_placeholder = build_options.kitty_graphics and
first.content_tag == .codepoint;
const placeholder_pattern = comptime pattern: {
// Never used without kitty graphics (check_placeholder
// is comptime-false), but it must still compile and the
// placeholder codepoint doesn't exist in that build.
if (!build_options.kitty_graphics) break :pattern 0;
break :pattern PlaceholderMask.pattern(.init(
kitty.graphics.unicode.placeholder,
));
};
var len: usize = 1;
// 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) : (len += 1) {
if (!BulkRunMask.eqlScalar(cells[len], run_pattern)) break;
if (check_placeholder and PlaceholderMask.eqlScalar(
cells[len],
placeholder_pattern,
)) 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
@@ -2030,6 +2357,23 @@ const ReflowCursor = struct {
// Copy style data.
if (cell.hasStyling()) style: {
// Fast path: styled cells come in long runs sharing the
// same style. If this source style was just mapped into
// the current destination page, bump the ref count
// directly and skip the set lookup. The destination id is
// guaranteed alive because a previously written cell in
// this page holds a reference, and the cache is reset
// whenever the destination page changes (see init).
if (self.style_cache.src_page == src_page and
self.style_cache.src_id == cell.style_id)
{
const id = self.style_cache.dst_id;
self.page.styles.use(self.page.memory, id);
self.page_row.styled = true;
self.page_cell.style_id = id;
break :style;
}
const style = src_page.styles.get(
src_page.memory,
cell.style_id,
@@ -2072,6 +2416,14 @@ const ReflowCursor = struct {
};
} orelse cell.style_id;
// Update our style cache with the latest style set so runs
// of cells with the same style are faster to write.
self.style_cache = .{
.src_page = src_page,
.src_id = cell.style_id,
.dst_id = id,
};
self.page_row.styled = true;
self.page_cell.style_id = id;
}
@@ -4005,6 +4357,45 @@ inline fn createPage(
opts: CreatePage,
) Allocator.Error!*List.Node {
// log.debug("create page cap={}", .{opts.cap});
// If we have a node available for recycling (only during reflow,
// see recycle_node), reuse it directly rather than going through
// the memory pool.
if (self.recycle_node) |node| recycle: {
// Only a standard pool-owned resident node can be rebuilt
// in place for a standard-size layout.
if (opts.exact_size) break :recycle;
if (node.owned != .pool) break :recycle;
if (node.data != .resident) break :recycle;
const layout = Page.layout(opts.cap);
if (layout.total_size > std_size) break :recycle;
self.recycle_node = null;
// The pool guarantees that buffers it hands out are zeroed.
// A pool-owned page dirties only its Page.memory prefix of
// the underlying standard-size item, so zeroing that prefix
// re-establishes the guarantee (this mirrors destroyNodeExt,
// minus the decommit).
const page = &node.data.resident;
const item: *align(std.heap.page_size_min) [std_size]u8 =
@ptrCast(@alignCast(page.memory.ptr));
@memset(page.memory, 0);
// Accounting: a pool-owned node always accounts for a full
// pool item in page_size, so destroying the node and
// creating a new pooled one is a net zero.
node.* = .{
.data = .{ .resident = .initBuf(.init(item), layout) },
.serial = self.page_serial,
.owned = .pool,
};
node.page().size.rows = 0;
self.page_serial += 1;
return node;
}
return try createPageExt(
&self.pool,
opts,

View File

@@ -2239,6 +2239,12 @@ pub const Cell = packed struct(u64) {
/// struct T, used for masked compares of raw backing-integer values
/// (e.g. `Row.Backing`, `Cell.Backing`). This is an implementation
/// detail of `Mask`, which is the public API built on top of this.
///
/// A field may be a dot-separated path (e.g. "content.codepoint.data")
/// to cover only a nested field of a packed struct or packed union
/// member. This allows a mask to be more precise than a whole
/// top-level field, e.g. covering the codepoint bits of a cell without
/// its padding.
fn fieldMask(
comptime T: type,
comptime fields: []const []const u8,
@@ -2246,16 +2252,33 @@ fn fieldMask(
// Backing int of the packed struct
const Int = @typeInfo(T).@"struct".backing_integer.?;
var mask: Int = 0;
inline for (fields) |field| {
comptime var mask: Int = 0;
inline for (fields) |path| {
// Walk the path to find the total bit offset and the type of
// the (possibly nested) field.
comptime var offset = 0;
comptime var Field = T;
comptime var it = std.mem.splitScalar(u8, path, '.');
inline while (comptime it.next()) |name| {
offset += switch (@typeInfo(Field)) {
.@"struct" => @bitOffsetOf(Field, name),
// Packed union members all share bit offset zero.
.@"union" => |u| offset: {
comptime assert(u.layout == .@"packed");
break :offset 0;
},
else => @compileError("invalid field path: " ++ path),
};
Field = @FieldType(Field, name);
}
// The type that fits all the bits we need to set.
const Ones = std.meta.Int(
.unsigned,
@bitSizeOf(@FieldType(T, field)),
);
const Ones = std.meta.Int(.unsigned, @bitSizeOf(Field));
// Mask out the ones
mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field);
mask |= @as(Int, std.math.maxInt(Ones)) << offset;
}
return mask;
@@ -2378,6 +2401,21 @@ pub fn Mask(
return pattern(v) == expected;
}
/// Returns true if any value in the group of group_len values
/// starting at index i has masked fields equal to the expected
/// pattern (see `pattern`). This is the "any" counterpart to
/// `eql`: use it to detect the presence of a specific value
/// within a group, e.g. a run scan that must stop when it
/// encounters a sentinel codepoint anywhere in the group.
pub inline fn eqlAny(
values: []const T,
i: usize,
expected: Backing,
) bool {
const masked = load(values, i) & @as(Group, @splat(mask));
return @reduce(.Or, masked == @as(Group, @splat(expected)));
}
/// Like `eql` but returns the number of leading values whose
/// masked fields equal the expected pattern, i.e. group_len if
/// the entire group matches. This is useful for early-exit run
@@ -2497,6 +2535,59 @@ test "Mask" {
try testing.expectEqual(M.strip(styled), M.strip(styled_other));
try testing.expect(M.strip(styled) != M.strip(styled2));
}
// eqlAny: presence of a matching value anywhere in the group
{
const expected = M.pattern(styled);
var cells: [4]Cell = .{ plain, plain, plain, plain };
try testing.expect(!M.eqlAny(&cells, 0, expected));
cells[2] = styled;
try testing.expect(M.eqlAny(&cells, 0, expected));
// Masked compare: same masked fields with a different
// codepoint still matches.
cells[2] = styled2;
try testing.expect(M.eqlAny(&cells, 0, expected));
}
}
test "Mask nested field path" {
// Mask only the codepoint data bits of the content field, not
// the padding next to it or any other field.
const M = Mask(Cell, &.{"content.codepoint.data"}, 4);
const a: Cell = .init('A');
var b: Cell = .init('A');
b.style_id = 5;
b.wide = .wide;
const c: Cell = .init('C');
// Same codepoint matches regardless of other fields.
const expected = M.pattern(a);
try testing.expect(M.eqlScalar(b, expected));
try testing.expect(!M.eqlScalar(c, expected));
// The mask must cover exactly the codepoint data bits.
const cp_offset = @bitOffsetOf(Cell, "content");
try testing.expectEqual(
@as(u64, std.math.maxInt(u21)) << cp_offset,
comptime fieldMask(Cell, &.{"content.codepoint.data"}),
);
// Group variants
{
var cells: [4]Cell = .{ a, b, a, b };
try testing.expect(M.eql(&cells, 0, expected));
try testing.expect(M.eqlAny(&cells, 0, expected));
cells[1] = c;
try testing.expect(!M.eql(&cells, 0, expected));
try testing.expect(M.eqlAny(&cells, 0, expected));
const none: [4]Cell = .{ c, c, c, c };
try testing.expect(!M.eqlAny(&none, 0, expected));
}
}
// Uncomment this when you want to do some math.