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/file_pane.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/file_pane.zig')
| -rw-r--r-- | src/file_pane.zig | 118 |
1 files changed, 101 insertions, 17 deletions
diff --git a/src/file_pane.zig b/src/file_pane.zig index 739d7b54..b164a7b3 100644 --- a/src/file_pane.zig +++ b/src/file_pane.zig @@ -15,13 +15,63 @@ const look = @import("look.zig"); const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); -const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0; const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; pub fn lineCount(content: []const u8) usize { return std.mem.count(u8, content, "\n") + 1; } +/// THE LINE INDEX, built on demand: `line_starts[i]` is the byte offset where +/// line i begins and its length is the line count. Without it, every question +/// about lines is a scan from byte 0, and a file pane asks several of them per +/// keystroke — the scrollbar's total, the scroll clamp, the syntax window's +/// bounds, the body's first visible line. On a 300k-line file that was ~35% of +/// the whole frame, and it is what made a single `j` cost 25ms. +/// +/// INVALIDATION — the part that rots if nobody says it out loud. The index is +/// dropped in EXACTLY ONE PLACE: setContent, immediately below, which is the +/// funnel every content swap in the editor already goes through (typing, undo, +/// redo, a save's normalisation, an output buffer refilling itself). A File +/// built by a struct literal starts with an empty index, and empty reads as +/// "not built yet" — a real index always has at least one entry, because a +/// file always has at least one line. So there is one and only one way to make +/// this wrong: assign `f.content` without going through setContent. Don't. +/// +/// Fails only when the index could not be allocated. nlines and lineStart +/// swallow that and scan the old way, so OOM there is slow rather than wrong; +/// callers that need the whole table say `try` and drop the keystroke, which +/// is what they already did when their own arena ran out. +pub fn lineIndex(gpa: std.mem.Allocator, f: *File) ![]const usize { + if (f.line_starts.len > 0) return f.line_starts; + // exact alloc, not an ArrayList: deinitPane frees `line_starts` itself, and + // an ArrayList's items slice is shorter than its allocation whenever the + // growth strategy rounded up — freeing that is a wrong-size free. + const starts = try gpa.alloc(usize, lineCount(f.content)); + starts[0] = 0; + var i: usize = 1; + var off: usize = 0; + while (std.mem.indexOfScalarPos(u8, f.content, off, '\n')) |nl| { + off = nl + 1; + starts[i] = off; + i += 1; + } + f.line_starts = starts; + return starts; +} + +/// line count, O(1) once the index is warm +pub fn nlines(gpa: std.mem.Allocator, f: *File) usize { + const idx = lineIndex(gpa, f) catch return lineCount(f.content); + return idx.len; +} + +/// byte offset of line `row`, or content.len past the end — modal +/// .lineStartOffset's contract exactly, without its walk +pub fn lineStart(gpa: std.mem.Allocator, f: *File, row: usize) usize { + const idx = lineIndex(gpa, f) catch return modal.lineStartOffset(f.content, row); + return if (row >= idx.len) f.content.len else idx[row]; +} + pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { const content = try look.readFile(p.gpa, path); errdefer p.gpa.free(content); @@ -39,9 +89,19 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { return pane; } +/// The ONE content swap. Everything that edits a file pane lands here, which +/// is what lets the line index above have a single invalidation point. pub fn setContent(p: *Pardes, f: *File, new: []u8) void { p.gpa.free(f.content); f.content = new; + if (f.line_starts.len > 0) p.gpa.free(f.line_starts); + f.line_starts = &.{}; + // the highlights go too, and not just because they are stale: their byte + // range is what refreshHighlights tests a scroll against, and a range + // measured on the OLD content would let it skip a re-parse it needs + if (f.highlights.len > 0) p.gpa.free(f.highlights); + f.highlights = &.{}; + f.highlight_start = 0; f.syntax_dirty = true; } @@ -49,8 +109,8 @@ pub fn setContent(p: *Pardes, f: *File, new: []u8) void { /// selections in its history transactions), clamped: the content it was taken /// against may be shorter than the one it is being restored onto. pub fn restoreSnap(pane: *Pane, f: *File, snap: FileSnap) void { - const n = modal.lineCount(f.content); - const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), if (n == 0) 0 else n - 1); + const n = nlines(pane.gpa, f); + const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), n - 1); const llen = modal.lineSlice(f.content, row).len; pane.cur_row = @intCast(row); pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen)); @@ -78,10 +138,35 @@ pub fn refreshHighlights(p: *Pardes) void { f.syntax_dirty = false; continue; } - const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS; - const start = modal.lineStartOffset(f.content, start_row); - const end_row = start_row + SYNTAX_CONTEXT_BEFORE_ROWS + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS; - const end = @max(start, modal.lineStartOffset(f.content, end_row)); + // What the screen needs coloured right now. If the last parse still + // covers it, this scroll is free — and that is the whole point of the + // slack below. Highlights only ever survive while the CONTENT does: + // setContent throws them away, so these byte offsets cannot be stale. + const need_start = lineStart(p.gpa, f, f.scroll); + const need_end = @max(need_start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS)); + if (f.highlights.len > 0 and need_start >= f.highlight_start and + need_end <= f.highlight_start + f.highlights.len) + { + f.syntax_dirty = false; + continue; + } + // How much MORE than the screen to parse. An edit or a fresh open has + // no previous parse to widen (setContent throws the highlights away), + // and slack would be pure loss there: every keystroke of typing pays + // this parse and none of it is amortised over anything. A SCROLL that + // outran the covered range is the opposite case — take a screenful + // above and below and the next ~pane.rows rows cost nothing at all. + // Scrolling used to re-parse the visible window on every single row, + // which on a file with 8000-column lines is a third of a megabyte per + // keypress. Three screens once beats one screen forty times. + // + // The slack also means those lines are parsed with real context above + // them, so a construct that opens off-screen now colours correctly — + // a fidelity gain, and one that cannot reach a file shown from the top + // (scroll 0 clamps the window to exactly what it always was). + const slack: usize = if (f.highlights.len == 0) 0 else pane.rows; + const start = lineStart(p.gpa, f, f.scroll -| slack); + const end = @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack)); const new_highlights = syntax.highlightFileRange(p.gpa, f.path, f.content, start, end) catch { f.syntax_dirty = false; continue; @@ -97,11 +182,13 @@ pub fn refreshHighlights(p: *Pardes) void { /// offset, each behind its right-aligned line number, then cut by hscroll. pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File) ![]const u8 { var out: std.ArrayList(u8) = .empty; - var flines = std.mem.splitScalar(u8, f.content, '\n'); - var skipped: usize = 0; - while (skipped < f.scroll) : (skipped += 1) { - if (flines.next() == null) break; - } + // start ON the first visible line instead of walking the file to it: this + // walk was O(f.scroll) and recolorSyntax below ran the identical one again + var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n'); + // scrolled past EOF (an edit shortened the file under a stale scroll): the + // old walk left the iterator dry, so drop the one empty line a slice split + // still yields, or the body grows a phantom numbered row + if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next(); var i: usize = 0; while (i < pane.rows) : (i += 1) { if (i > 0) try out.append(arena, '\n'); @@ -174,11 +261,8 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16, const s = &p.surface; const tz_recolor = tracy.zone(@src(), "synRecolor"); defer tz_recolor.end(); - var flines = std.mem.splitScalar(u8, f.content, '\n'); - var skip: usize = 0; - while (skip < f.scroll) : (skip += 1) { - if (flines.next() == null) break; - } + // indexed start, same as bodyText — an empty tail simply paints nothing + var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n'); const hs: usize = @intCast(@max(0, pane.hscroll)); var vr: u16 = 0; while (vr < body_h) : (vr += 1) { |
