mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 15:18:40 +00:00
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:
357
src/benchmark/TerminalResize.zig
Normal file
357
src/benchmark/TerminalResize.zig
Normal 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);
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user