summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-11 17:07:45 -0300
committerGabriel Schneider <[email protected]>2026-08-12 13:04:32 -0300
commita28c3b71a917dcaae6ba4cd87b356eeab94462c4 (patch)
tree87f1016f28de479258cfc98ed54bda683ca27a95 /src/pardes.zig
parent24aff2afe3cc827fe1a920a044922aeacd1ff6a3 (diff)
downloadpardes-a28c3b71a917dcaae6ba4cd87b356eeab94462c4.tar.gz
pardes-a28c3b71a917dcaae6ba4cd87b356eeab94462c4.zip
terminal: memoize the motion surface instead of dumping the scrollback per key
Every keystroke in a shell pane rebuilt the motion surface from scratch: shellRows dumped ghostty's WHOLE history+active grid, split it, blanked the prompt rows and handed back slices into the scratch arena, which the next update threw away. A pane sitting on a multi-megabyte agent transcript paid an O(scrollback) dump per press of `j`, and paid it once or twice per key, since flatSurface then rebuilt the same rows joined by '\n' beside it. The dump is now memoized against the pane it was built for (term_pane.RowsCache on Pardes.shell_rows), gpa-owned rather than scratch-arena because the whole point is to outlive the update that built it. One entry, not a table: the surface is built for the pane the cursor is in, and a second pane asking would only double a multi-megabyte buffer for a slot it is about to lose again. A pane that is not the live one is answered from the arena as before. The lifetime rule is the part that would have rotted silently, so it is one rule and it is written down: `rows` is handed out to callers, so everything that notices the entry has gone bad — output arrived, the grid reflowed, the pane died, another pane wants the slot — only marks it `stale`, and the buffers are freed in exactly two places, `sweep` at the TOP of an update before any handler can be holding them, and `reset` when the editor goes away. Nothing frees mid-update. dropPane clears the pointer immediately though: a freed pane's address comes back from the allocator as a different pane, and an entry still naming it would answer for the wrong grid. Two things fall out of having the join already: - flatSurface returns the memo's `text` verbatim when the lines it was handed are the cached rows untouched, instead of rebuilding the join. - paneCursorLines returns `rows` directly when there is no edit buffer, where it used to copy the array one slice at a time to produce exactly what it was given. One bug on the way past, in the same function: an EMPTY edit buffer writes one line but modal.lineCount("") is 0, so `ls` was sized one short of what the loop writes — the same floor the paste site needs. Killing a whole line (`A<C-u>`, `d%`) on a buffer covering the last row made that a length of zero. And test/perf.zig grows the axis that would have caught this: a terminal scoreboard beside the file one, three scrollback fixtures (64 KiB, 1 MiB, 8 MiB — half the ceiling) against render / output / resize-rows / resize-cols / key-down / edit-char, sharing the existing text and JSON reports and the --base comparison. resize-cols and resize-rows are both there because a COLUMN change reflows every page in the list and a row change does not. Measured on that table: key-down is 142 / 630 / 636 us across the three fixtures — flat from 1 MiB to 8 MiB, which is the dump being gone, and render flat at ~110 us throughout. What remains of key-down's step at 1 MiB is the linear scan indexOf refuses to index for a terminal; that is now a ponytail waiver naming its own price (615 us against 140 us) and the threading through paneOff/panePos/paneLineStart it would cost, to be done the day 0.6 ms shows up next to something anybody can feel.
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig38
1 files changed, 37 insertions, 1 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index e9cc746d..f95b4e69 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4484,6 +4484,9 @@ pub const Pardes = struct {
surface: Surface = .{},
/// per-update scratch (paneCursorLines, selection text); reset each update
scratch: std.heap.ArenaAllocator,
+ /// the terminal motion surface, memoized against the pane it was built
+ /// for — see term_pane.RowsCache for the lifetime rule
+ shell_rows: term_pane.RowsCache = .{},
pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes {
const image_gpa = opts.image_allocator orelse gpa;
@@ -4563,6 +4566,7 @@ pub const Pardes = struct {
if (p.last_dump) |d| gpa.free(d);
if (p.chord_arg) |a| gpa.free(a);
if (p.pipe_wait) |*wait| wait.deinit(gpa);
+ p.shell_rows.reset(gpa);
p.scratch.deinit();
gpa.free(p.surface.cells);
if (p.surface.images.len > 0) gpa.free(p.surface.images);
@@ -4594,6 +4598,7 @@ pub const Pardes = struct {
pub fn deinitPane(p: *Pardes, pane: *Pane) void {
std.debug.assert(pane.payloadValid());
+ p.shell_rows.dropPane(pane);
if (pane.image) |*iv| {
p.image_gpa.free(iv.path);
if (iv.rgba.len > 0) p.image_gpa.free(iv.rgba);
@@ -4808,6 +4813,10 @@ pub const Pardes = struct {
}
pub fn update(p: *Pardes, ev: Event) void {
+ // Free a motion surface that went bad during the LAST update, before
+ // anything in this one can ask for it. Nothing frees it mid-update:
+ // handlers hand `rows` around for the length of a single update.
+ p.shell_rows.sweep(p.gpa);
// A transient message is exactly as old as your last input: touch the
// keyboard or the mouse and it is gone, on every pane, because a
// message is a report and you have just proved you are back. Only
@@ -4862,6 +4871,7 @@ pub const Pardes = struct {
const pane = p.panes[o.pane] orelse return;
pane.appendTtyReplay(o.bytes);
pane.stream.nextSlice(o.bytes);
+ p.shell_rows.markStale(pane);
// follow new output to the bottom only in tty mode; in
// normal/insert the user is reading scrollback and the view
// holds still while output streams in. Tag edits hijack
@@ -6048,14 +6058,23 @@ pub const Pardes = struct {
return .{ .lines = ls, .row0 = 0 };
}
const rows = try term_pane.shellRows(p, pane);
+ // No edit buffer: the loop below would copy `rows` into a second array
+ // one slice at a time and return exactly what it was given. On a
+ // scrollback of any size that copy is the keystroke.
+ if (pane.ovl == null) return .{ .lines = rows, .row0 = 0 };
// the buffer may sit below the dump's last row (the grid's trailing
// blanks are trimmed off), so run the loop to whichever ends later
var last = rows.len;
if (pane.ovl) |o| last = @max(last, @as(usize, @intCast(@max(0, o.row + o.rows))));
var count = last;
if (pane.ovl) |o| {
+ // The split below writes ONE line for an empty buffer, but
+ // modal.lineCount("") is 0 — the same floor `n - 1` needs at the
+ // paste site. Without it, killing a whole line (`A<C-u>`, `d%`)
+ // sizes `ls` one short of what the loop writes, and on a buffer
+ // covering the last row that is a length of zero.
if (o.row >= 0 and @as(usize, @intCast(o.row)) < last)
- count = count - @min(@as(usize, @intCast(o.rows)), last - @as(usize, @intCast(o.row))) + modal.lineCount(o.text);
+ count = count - @min(@as(usize, @intCast(o.rows)), last - @as(usize, @intCast(o.row))) + @max(1, modal.lineCount(o.text));
}
const ls = try arena.alloc([]const u8, count);
var n: usize = 0;
@@ -6097,6 +6116,13 @@ pub const Pardes = struct {
fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 {
if (pane.file) |f| return f.content;
if (hasPdf(pane)) return p.ensurePdfText(pane);
+ // The memo already holds these rows joined by '\n' — that is what its
+ // `text` IS. The identity check is the same argument hxOff makes below:
+ // `lines` came out of the cache untouched (no edit buffer stood in for
+ // any row), so the join cannot have drifted from it.
+ const c = &p.shell_rows;
+ if (!c.stale and c.pane == pane and
+ pl.lines.ptr == c.rows.ptr and pl.lines.len == c.rows.len) return c.text;
const arena = p.scratch.allocator();
var total: usize = if (pl.lines.len > 0) pl.lines.len - 1 else 0;
for (pl.lines) |ln| total += ln.len;
@@ -6127,6 +6153,15 @@ pub const Pardes = struct {
/// differential and 561 parity cases say so; the arithmetic below is
/// hxOff/hxPos transcribed, with line i's terminator read off idx[i+1]-1
/// instead of searched for.
+ ///
+ /// ponytail: a terminal's motion surface is NOT one of the things with no
+ /// index any more — shell_rows.rows are slices into shell_rows.text, so
+ /// their offsets ARE the line starts, for free. Claiming them here needs
+ /// `p`, which means threading it through paneOff/panePos/paneLineStart and
+ /// their callers (paneRange, setPaneRange, the three motion helpers). Left
+ /// undone deliberately: measured, that is a terminal key-down of 615us
+ /// against 140us, and 0.6ms is not a keystroke anybody feels. Do it the
+ /// day one of those scans shows up next to something that matters.
fn indexOf(pane: *Pane, text: []const u8) ?[]const usize {
const f = if (pane.file) |*ff| ff else return null;
if (text.ptr != f.content.ptr or text.len != f.content.len) return null;
@@ -12563,6 +12598,7 @@ pub const Pardes = struct {
// the size so bodyText renders the right number of rows
if (pane.isTerminal()) {
pane.vt.resize(p.gpa, .{ .cols = cols, .rows = rows }) catch {};
+ p.shell_rows.markStale(pane); // reflow moved every row
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
}
pane.cols = cols;