diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-31 07:32:15 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:08 -0300 |
| commit | 560ae0a63f7b5d354bad3ba13dd8ab26b5f0a870 (patch) | |
| tree | db2762cc2bc7bdeaae187e15cfc68a33ef2d1c48 /src/pardes.zig | |
| parent | 5bf8d6dd077517270377e5d8551108ecf252374f (diff) | |
| download | pardes-560ae0a63f7b5d354bad3ba13dd8ab26b5f0a870.tar.gz pardes-560ae0a63f7b5d354bad3ba13dd8ab26b5f0a870.zip | |
motion, scrolling and redraw are flat in file size now
zig build perf drives the core directly — event, effects, one frame, no pty —
over four generated fixtures: 1k lines, 50k, 300k, and 400 lines of 8000
columns, because a file that is long and a file that is wide fail differently.
Every sample seeks somewhere else in the file first, since measuring at line 3
of a 300k-line file hides exactly the bug.
perf record said half the run was scanning for newlines from byte 0. So File
carries a line index, built on demand and invalidated in exactly ONE place —
setContent, the funnel every content swap already goes through. That killed the
scrollbar's per-frame line count (12.6% of the whole run by itself), scrollBy,
ensureCursorVisible, lastNavRow, the syntax window bounds and two O(scroll)
walks. normalKey computed max_line as a const at the top: two full passes over
the buffer on every keystroke of every kind, for three g/G branches. It is lazy
now. The modal primitives each walked the text twice for the same line.
And the visible window was re-parsed on every scrolled row — a third of a
megabyte per keypress on the wide fixture. The highlighted range is remembered,
a scroll inside it is free, and only a re-parse that FOLLOWS a scroll takes
slack: doing it unconditionally made typing 2.1x slower, since every character
paid for a band it could never amortise.
One j on a 19 MB file: 37.8ms -> 266us. Render: 4.1ms -> 77us. Open costs 1.25x
more for the one extra pass, which buys 54x on every frame after, and 8 bytes
per line of memory.
Left standing, measured and named: edit-char is 14ms on 19MB because content is
immutable and every keystroke copies the buffer. A third of that is the index
rebuild, which could be a shift if setContent knew the edit offset; the rest
wants a rope. bodyText's double copy and Surface.print's per-cell decode never
rose above 2% of the profile afterwards, so they were left alone.
No golden moved.
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 148 |
1 files changed, 117 insertions, 31 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 947c1c96..ced4c2f9 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -728,6 +728,12 @@ pub const File = struct { /// resolve like anywhere else. output: ?output_pane.Output = null, scroll: usize = 0, + /// THE LINE INDEX: `line_starts[i]` is the byte offset where line i begins, + /// and `line_starts.len` IS the line count. Empty means "not built yet" — + /// a real index always holds at least one entry, because a file always has + /// at least one line. Built and read only through file_pane.lineIndex / + /// nlines / lineStart; see the invalidation contract on lineIndex. + line_starts: []usize = &.{}, /// one syntax.Syn byte per content byte in the highlighted window highlights: []u8 = &.{}, highlight_start: usize = 0, @@ -761,6 +767,12 @@ pub const Image = struct { pub const Pane = struct { vt: ghostty_vt.Terminal, stream: ghostty_vt.TerminalStream, + /// The same allocator Pardes holds. A pane already owns heap (its content, + /// its emulator, its undo stacks) and Pardes frees all of it; this is here + /// so the pane methods that need the file's LINE INDEX — scrollBy, + /// ensureCursorVisible, lastNavRow — can build it. The alternative was + /// threading an allocator through ensureCursorVisible's 33 call sites. + gpa: std.mem.Allocator, /// WHICH pane this is, for anything that outlives the pane: slots are /// REUSED (freeSlot hands back the lowest free one), so a remembered id /// alone can silently come to mean an unrelated pane. Handed out by @@ -920,7 +932,7 @@ pub const Pane = struct { fn scrollBy(pane: *Pane, delta: i32) void { if (pane.file) |*f| { - const max: i64 = @intCast(modal.lineCount(f.content) -| 1); + const max: i64 = @intCast(file_pane.nlines(pane.gpa, f) -| 1); const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max); const next: usize = @intCast(n); if (next != f.scroll) { @@ -945,7 +957,7 @@ pub const Pane = struct { // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp); // terminals overshoot harmlessly — the vt clamps at the live bottom var to = pane.cur_row + margin; - if (pane.file) |f| to = @min(to, @as(i32, @intCast(modal.lineCount(f.content) -| 1))); + if (pane.file) |*f| to = @min(to, @as(i32, @intCast(file_pane.nlines(pane.gpa, f) -| 1))); pane.scrollBy(@max(0, to - last)); } // the horizontal mirror, files only: keep scroll_off columns of @@ -963,7 +975,7 @@ pub const Pane = struct { /// the last navigable row: file -> last content line; terminal -> viewport bottom fn lastNavRow(pane: *Pane) i32 { - if (pane.file) |f| return @intCast(modal.lineCount(f.content) -| 1); + if (pane.file) |*f| return @intCast(file_pane.nlines(pane.gpa, f) -| 1); return pane.scroll() + @as(i32, pane.rows) - 1; } @@ -1243,6 +1255,7 @@ pub const Pardes = struct { }; p.gpa.free(f.path); p.gpa.free(f.content); + if (f.line_starts.len > 0) p.gpa.free(f.line_starts); if (f.highlights.len > 0) p.gpa.free(f.highlights); for (f.undo.items) |s| p.gpa.free(s.content); f.undo.deinit(p.gpa); @@ -1279,6 +1292,7 @@ pub const Pardes = struct { .max_scrollback = 16 * 1024 * 1024, }), .stream = undefined, + .gpa = p.gpa, .cols = p.screen_w, .rows = p.screen_h, }; @@ -1302,6 +1316,7 @@ pub const Pardes = struct { pane.* = .{ .vt = try ghostty_vt.Terminal.init(p.gpa, .{ .cols = 1, .rows = 1 }), .stream = undefined, + .gpa = p.gpa, .cols = p.screen_w, .rows = p.screen_h, }; @@ -2276,11 +2291,18 @@ pub const Pardes = struct { /// this surface as a tty case's final text.) pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines { const arena = p.scratch.allocator(); - if (pane.file) |f| { - var ls: std.ArrayList([]const u8) = .empty; - var it = std.mem.splitScalar(u8, f.content, '\n'); - while (it.next()) |ln| try ls.append(arena, ln); - return .{ .lines = ls.items, .row0 = 0 }; + if (pane.file) |*f| { + // straight off the line index: exact size (no ArrayList regrowth + // copying a 300k-entry table forward) and no second scan for the + // newlines the index already found. Identical to splitting the + // content — a file's line count is its newline count plus one. + const idx = try file_pane.lineIndex(pane.gpa, f); + const ls = try arena.alloc([]const u8, idx.len); + for (idx, 0..) |s, i| { + const e = if (i + 1 < idx.len) idx[i + 1] - 1 else f.content.len; + ls[i] = f.content[s..e]; + } + return .{ .lines = ls, .row0 = 0 }; } const rows = try term_pane.shellRows(p, pane); // the buffer may sit below the dump's last row (the grid's trailing @@ -2333,11 +2355,58 @@ pub const Pardes = struct { return buf.items; } + /// modal.hxOff and modal.hxPos, answered from the file's LINE INDEX when + /// `text` is that file's content — which is exactly what flatSurface hands + /// back for a file pane, and so what every motion in a document goes + /// through. The modal pair has to scan the buffer for the same two numbers + /// (a newline count and a walk to the line start), and on a multi-MB file + /// those scans were the bulk of a keystroke. + /// + /// The identity check is the whole safety argument: anything that is NOT + /// this file's content — a terminal's joined motion surface, an edit + /// buffer, a search haystack — falls through to the scan, where it is O(a + /// screenful) anyway. The two branches must agree exactly or the 481 + /// 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. + 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; + return file_pane.lineIndex(pane.gpa, f) catch null; + } + + fn paneOff(pane: *Pane, text: []const u8, c: modal.Cursor) usize { + const idx = indexOf(pane, text) orelse return modal.hxOff(text, c); + const row = @min(c.row, idx.len - 1); + const s = idx[row]; + const e = if (row + 1 < idx.len) idx[row + 1] - 1 else text.len; + return @min(s + c.col, e); + } + + /// modal.lineStartOffset, same contract (row past the end -> text.len) + fn paneLineStart(pane: *Pane, text: []const u8, row: usize) usize { + const idx = indexOf(pane, text) orelse return modal.lineStartOffset(text, row); + return if (row >= idx.len) text.len else idx[row]; + } + + fn panePos(pane: *Pane, text: []const u8, off: usize) modal.Cursor { + const idx = indexOf(pane, text) orelse return modal.hxPos(text, off); + const o = @min(off, text.len); + // the last line start at or before `o`; idx is sorted and idx[0] == 0, + // so upperBound never returns 0 + const row = std.sort.upperBound(usize, idx, o, struct { + fn cmp(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.cmp) - 1; + return .{ .row = row, .col = o - idx[row] }; + } + /// the current selection as a helix gap range over `text`, whose first /// line is absolute row `row0` (0 for the motion surface and for file /// content; a terminal's edit buffer starts wherever it was anchored) fn paneRange(pane: *Pane, text: []const u8, row0: i32) modal.HxRange { - const c = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }); + const c = paneOff(pane, text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }); if (pane.msel.active) { // legacy line selection (file-search results highlight): linewise const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); @@ -2381,8 +2450,10 @@ pub const Pardes = struct { var r = r0; if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 const off = rangeCells(text, r); - const cc = modal.hxPos(text, off.cur); - const ac = modal.hxPos(text, off.anc); + const cc = panePos(pane, text, off.cur); + // a bare block cursor has both cells on the same offset — the common + // case by far — and this conversion is not free even indexed + const ac = if (off.anc == off.cur) cc else panePos(pane, text, off.anc); pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0; pane.cur_col = @intCast(cc.col); pane.vsel = .{ @@ -2957,6 +3028,18 @@ pub const Pardes = struct { return .{ .row = s.row, .col = s.col }; } + /// The last line a goto may land on: helix skips the empty trailing line. + /// Called from the three `g`/`G` branches that need it and NOWHERE else — + /// it used to be a `const` at the top of normalKey, which meant every + /// keystroke of every kind paid a full count of the buffer's newlines. + /// "Does the buffer end in a newline" is the same question as the walk to + /// the last line start that stood here, and it is one byte instead of a + /// second pass. + fn maxLine(text: []const u8) usize { + const nl = modal.hxLineCount(text); + return if (text.len == 0 or text[text.len - 1] == '\n') nl -| 2 else nl - 1; + } + /// point-target motion: collapse there (extend in select mode) fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void { setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false); @@ -2984,8 +3067,15 @@ pub const Pardes = struct { /// j/k and friends: sticky goal column, clamped onto short lines' newline fn verticalMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize) void { const cur = modal.hxCursor(text, range); - const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else modal.hxPos(text, cur).col; - const t = modal.hxVertTarget(text, cur, down, cnt, goal); + const pos = panePos(pane, text, cur); + const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else pos.col; + // modal.hxVertTarget with the row we already have and the indexed + // offset conversion — it would otherwise recount the buffer's newlines + // and walk to the target line, two more full passes per j/k + const t = if (indexOf(pane, text)) |idx| blk: { + const nline = if (down) @min(pos.row + @max(1, cnt), idx.len - 1) else pos.row -| @max(1, cnt); + break :blk paneOff(pane, text, .{ .row = nline, .col = goal }); + } else modal.hxVertTarget(text, cur, down, cnt, goal); // extend mode never walks onto the empty trailing line (helix) if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false); @@ -3005,17 +3095,17 @@ pub const Pardes = struct { const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); pane.scrollBy(delta); const top = pane.scroll(); - const last_row: i32 = @intCast(modal.hxLineCount(text) - 1); + const last_row: i32 = @intCast((if (indexOf(pane, text)) |idx| idx.len else modal.hxLineCount(text)) - 1); const cur = modal.hxCursor(text, range); if (delta > 0) { const snap: i32 = @max(0, @min(top + margin, last_row)); - const head = modal.lineStartOffset(text, @intCast(snap)); + const head = paneLineStart(pane, text, @intCast(snap)); if (head <= cur) return; const anchor = if (pane.select) range.anchor else head; setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); } else { const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row)); - const head = modal.lineStartOffset(text, @intCast(snap)); + const head = paneLineStart(pane, text, @intCast(snap)); if (head >= cur) return; const anchor = if (pane.select) range.anchor else head; setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); @@ -3026,7 +3116,7 @@ pub const Pardes = struct { fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: enum { top, center, bottom }, cnt: usize) void { const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const top = pane.scroll(); - const last_row: i32 = @intCast(modal.hxLineCount(text) - 1); + const last_row: i32 = @intCast((if (indexOf(pane, text)) |idx| idx.len else modal.hxLineCount(text)) - 1); const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top); const n: i32 = @intCast(cnt - 1); var vline: i32 = switch (which) { @@ -3037,7 +3127,7 @@ pub const Pardes = struct { vline = @max(vline, top + margin); vline = @min(vline, top + last_vis - margin); const row: i32 = std.math.clamp(vline, 0, last_row); - pointMove(pane, pl, text, range, modal.lineStartOffset(text, @intCast(row))); + pointMove(pane, pl, text, range, paneLineStart(pane, text, @intCast(row))); } /// helix Range::line_range — the inclusive line span a range covers @@ -3161,20 +3251,15 @@ pub const Pardes = struct { const range = paneRange(pane, text, pl.row0); const cur = modal.hxCursor(text, range); // block-cursor gap offset - // the last line a goto may land on: helix skips the empty trailing line - const max_line = blk: { - const nl = modal.hxLineCount(text); - break :blk if (modal.lineStartOffset(text, nl - 1) >= text.len) nl -| 2 else nl - 1; - }; // prefix continuations if (pane.pending == config.goto_prefix) { pane.pending = 0; if (hit(key, config.goto_file_start)) { - const line = if (has_count) @min(cnt - 1, max_line) else 0; + const line = if (has_count) @min(cnt - 1, maxLine(text)) else 0; return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); } - if (hit(key, config.goto_last_line)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, max_line)); + if (hit(key, config.goto_last_line)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, maxLine(text))); if (hit(key, config.goto_line_start)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); if (hit(key, config.goto_line_end)) { const line = modal.hxLineOf(text, cur); @@ -3394,7 +3479,7 @@ pub const Pardes = struct { // helix goto_line: only acts WITH a count (bare G is a no-op; // ge is goto-last-line) if (!has_count) return; - const line = @min(cnt - 1, max_line); + const line = @min(cnt - 1, maxLine(text)); return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); } if (hit(key, config.half_page_down)) return halfPageMove(pane, pl, text, range, true); @@ -5966,7 +6051,7 @@ pub const Pardes = struct { p.active = id; if (line == 0) return; if (pane.file) |*f| { - if (line > file_pane.lineCount(f.content)) return; + if (line > file_pane.nlines(p.gpa, f)) return; const next = (line - 1) -| pane.rows / 2; // center, clamp at top if (next != f.scroll) { f.scroll = next; @@ -6419,6 +6504,7 @@ pub const Pardes = struct { .max_scrollback = 16 * 1024 * 1024, }), .stream = undefined, + .gpa = gpa, .cols = @max(1, src.cols), .rows = @max(1, src.rows), }; @@ -6796,9 +6882,9 @@ pub const Pardes = struct { } var sb_off: usize = undefined; var sb_total: usize = undefined; - if (at.file) |f| { + if (at.file) |*f| { sb_off = f.scroll; - sb_total = file_pane.lineCount(f.content); + sb_total = file_pane.nlines(p.gpa, f); } else { const sb = at.vt.screens.active.pages.scrollbar(); sb_off = sb.offset; @@ -7056,8 +7142,8 @@ pub const Pardes = struct { s.fill(r.x, r.y, config.GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg } }); if (r.h > BOX_H) { s.fill(r.x, r.y + BOX_H, config.GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); - const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |f| .{ - .total = file_pane.lineCount(f.content), + const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ + .total = file_pane.nlines(p.gpa, f), .offset = f.scroll, .len = pane.rows, } else blk: { |
