summaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/file_pane.zig118
-rw-r--r--src/modal.zig21
-rw-r--r--src/pardes.zig148
3 files changed, 234 insertions, 53 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) {
diff --git a/src/modal.zig b/src/modal.zig
index 742dbc6f..cdc49142 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -438,7 +438,7 @@ pub fn lineStartOffset(content: []const u8, row: usize) usize {
var off: usize = 0;
var r: usize = 0;
while (r < row) : (r += 1) {
- const nl = std.mem.indexOfPos(u8, content, off, "\n") orelse return content.len;
+ const nl = std.mem.indexOfScalarPos(u8, content, off, '\n') orelse return content.len;
off = nl + 1;
}
return off;
@@ -775,15 +775,24 @@ pub fn hxLineEndIdx(text: []const u8, line: usize) usize {
/// gap offset -> (row, col) cell
pub fn hxPos(text: []const u8, off: usize) Cursor {
- const row = hxLineOf(text, off);
- return .{ .row = row, .col = @min(off, text.len) - lineStartOffset(text, row) };
+ const o = @min(off, text.len);
+ // The line start is the byte after the last '\n' BEFORE off, which is the
+ // same number lineStartOffset(text, row) walks the whole prefix to reach —
+ // one backward scan of a single line instead of a second pass over
+ // everything above the cursor. On a multi-MB buffer that second pass was
+ // most of what a keystroke cost.
+ const s = if (std.mem.lastIndexOfScalar(u8, text[0..o], '\n')) |nl| nl + 1 else 0;
+ return .{ .row = hxLineOf(text, off), .col = o - s };
}
/// (row, col) -> clamped gap offset; col == line length lands ON the '\n'
pub fn hxOff(text: []const u8, c: Cursor) usize {
const row = @min(c.row, hxLineCount(text) - 1);
const s = lineStartOffset(text, row);
- return @min(s + c.col, hxLineEndIdx(text, row));
+ // hxLineEndIdx(text, row) inlined: it starts by walking to `row` again,
+ // and we are already standing there
+ const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len;
+ return @min(s + c.col, e);
}
pub const WordTarget = enum {
@@ -979,7 +988,9 @@ pub fn hxVertTarget(text: []const u8, pos: usize, down: bool, count: usize, goal
const line = hxLineOf(text, pos);
const nline = if (down) @min(line + @max(1, count), nlines - 1) else line -| @max(1, count);
const s = lineStartOffset(text, nline);
- return @min(s + goal_col, hxLineEndIdx(text, nline));
+ // hxLineEndIdx(text, nline) without its second walk to nline (see hxOff)
+ const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len;
+ return @min(s + goal_col, e);
}
/// f/F/t/T target cell. helix find_char: the exclusive (till) search starts
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: {