summaryrefslogtreecommitdiff
path: root/src/file_pane.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 07:32:15 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commit560ae0a63f7b5d354bad3ba13dd8ab26b5f0a870 (patch)
treedb2762cc2bc7bdeaae187e15cfc68a33ef2d1c48 /src/file_pane.zig
parent5bf8d6dd077517270377e5d8551108ecf252374f (diff)
downloadpardes-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.zig118
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) {