diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-30 10:50:18 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:17 -0300 |
| commit | 048e81e3471c73b9ac690901819976f689f6a4ce (patch) | |
| tree | f2872bb0d64c25ff83c779785abe4119ff389ff6 /src/ninep | |
| parent | a0ce72b3d02e836439cac52e8074f569a6b4ae85 (diff) | |
| download | pardes-048e81e3471c73b9ac690901819976f689f6a4ce.tar.gz pardes-048e81e3471c73b9ac690901819976f689f6a4ce.zip | |
An append to a body through 9P costs its own bytes, not five passes over the whole body: 60 KB appends go from 35.7 to 2.1 ms each
Round 25 measured bulk body writes at about 8 ms a write. There is no frame wait in it: a profile of open, write, clunk in a loop found the flush of each close walking the whole body five times. dotOf, setDot and showOffset turned the cursor between offsets and rows by counting every newline from the top; setContent found the line index's changed span by comparing old and new byte for byte, and hashed the new text to see whether it was back to the saved one. The rows now come from the file's line index by binary search (checked against the counting at every offset), a splice tells setContent the span it changed, and the text is hashed only when its length is the saved text's. Measured over 9P on a Debug build: 60 KB appends 35.7 to 2.1 ms, 8 KB 5.3 to 0.8 ms, 1 KB 1.3 to 0.9 ms. What is left is two copies of the text an edit (the splice's and the undo snapshot's). The perf gate gains body-appends, 64 appends of 8 KB on the 50k-line file, and its three baselines are recorded again.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/ninep')
| -rw-r--r-- | src/ninep/ctl.zig | 1 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 80 |
2 files changed, 75 insertions, 6 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 5ed03c58..05f93ac5 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -983,6 +983,7 @@ fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 { panes.File.setContent(p, f, bytes); f.saved_revision = f.revision; f.saved_hash = std.hash.Wyhash.hash(0, f.content); + f.saved_len = f.content.len; f.disk_newer = null; f.disk_gone = false; if (discarded) { diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 3fad1fae..af8bb52c 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -149,12 +149,62 @@ pub fn clip(n: usize) u32 { return std.math.cast(u32, n) orelse std.math.maxInt(u32); } +/// The file's line index when it describes `text` (built by a render or a +/// look), else null: with it a row is a binary search, not a count of every +/// newline from the top, which a bulk write paid on each flush, twice over. +fn lineIndex(pane: *const Pane, text: []const u8) ?[]const usize { + const f = if (pane.file) |*file| file else return null; + if (f.line_starts.len == 0 or f.content.ptr != text.ptr or f.content.len != text.len) return null; + return f.line_starts; +} + +/// The line `off` is on: the last start at or before it. +fn rowAt(starts: []const usize, off: usize) usize { + const after = std.sort.upperBound(usize, starts, off, struct { + fn order(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.order); + return after -| 1; +} + +fn lineEnd(starts: []const usize, text: []const u8, row: usize) usize { + return if (row + 1 < starts.len) starts[row + 1] - 1 else text.len; +} + +/// `modal.runeOffsetAt`, through the line index when there is one. +fn runeOffsetAt(pane: *const Pane, text: []const u8, c: modal.Cursor) usize { + const starts = lineIndex(pane, text) orelse return modal.runeOffsetAt(text, c); + const row = @min(c.row, starts.len - 1); + const s = starts[row]; + const e = lineEnd(starts, text, row); + return s + modal.runeStart(text[s..e], @min(c.col, e - s)); +} + +/// `modal.runePositionAt`, through the line index when there is one. +fn runePositionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor { + const starts = lineIndex(pane, text) orelse return modal.runePositionAt(text, off); + const bounded = modal.runeStart(text, off); + const row = rowAt(starts, bounded); + return .{ .row = row, .col = bounded - starts[row] }; +} + +/// `modal.positionAt`, through the line index when there is one. +fn positionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor { + const starts = lineIndex(pane, text) orelse return modal.positionAt(text, off); + const bounded = @min(off, text.len); + const row = rowAt(starts, bounded); + const s = starts[row]; + const e = lineEnd(starts, text, row); + return .{ .row = row, .col = modal.graphemeStart(text[s..e], @min(bounded - s, e - s)) }; +} + pub fn dotOf(pane: *Pane) State.Range { const text = bodyOf(pane); // In runes, as every address is (modal.runeStart). - const head = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }); + const head = runeOffsetAt(pane, text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }); if (!pane.body.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; - const anchor = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) }); + const anchor = runeOffsetAt(pane, text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) }); var hi = @max(head, anchor); if (hi < text.len) hi = modal.nextRune(text, hi); return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; @@ -164,9 +214,9 @@ pub fn setDot(pane: *Pane, r: State.Range) void { const text = bodyOf(pane); const q0 = @min(@as(usize, r.q0), text.len); const q1 = @max(q0, @min(@as(usize, r.q1), text.len)); - const a = modal.runePositionAt(text, q0); + const a = runePositionAt(pane, text, q0); pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; - const h = modal.runePositionAt(text, if (q1 > q0) modal.prevRune(text, q1) else q0); + const h = runePositionAt(pane, text, if (q1 > q0) modal.prevRune(text, q1) else q0); pane.body.cur_row = @intCast(h.row); pane.body.cur_col = @intCast(h.col); pane.body.cur_pinned = true; @@ -177,7 +227,7 @@ pub fn setDot(pane: *Pane, r: State.Range) void { pub fn showOffset(pane: *Pane, off: usize) void { const text = bodyOf(pane); - const c = modal.positionAt(text, @min(off, text.len)); + const c = positionAt(pane, text, @min(off, text.len)); pane.body.cur_row = @intCast(c.row); pane.body.cur_col = @intCast(c.col); pane.body.cur_pinned = true; @@ -229,10 +279,28 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const const join = pane.fs.joined == f.revision; pane.fs.joined = null; if (!pane.fs.nomark and !join) panes.File.pushUndo(p, pane); - panes.File.setContent(p, f, new); + panes.File.setContentSpan(p, f, new, .{ .head = lo, .tail = f.content.len - hi }); return take; } +test "rows through the line index are the ones counting newlines gives, at every offset" { + const p = try withFile(testing.allocator, "héllo\nwo\u{301}rld\n\n last line é\ntail"); + defer p.deinit(); + const pane = p.panes[0].?; + const f = &pane.file.?; + _ = try panes.File.lineIndex(p.gpa, f); + const text = bodyOf(pane); + try testing.expect(lineIndex(pane, text) != null); + for (0..text.len + 2) |off| { + try testing.expectEqual(modal.positionAt(text, off), positionAt(pane, text, off)); + try testing.expectEqual(modal.runePositionAt(text, off), runePositionAt(pane, text, off)); + } + for (0..8) |row| for (0..16) |col| { + const c: modal.Cursor = .{ .row = row, .col = col }; + try testing.expectEqual(modal.runeOffsetAt(text, c), runeOffsetAt(pane, text, c)); + }; +} + test "a terminal's body stats as long as it reads" { const gpa = testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); |
