diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/File.zig | 46 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 1 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 80 |
3 files changed, 111 insertions, 16 deletions
diff --git a/src/File.zig b/src/File.zig index e8a9e2f8..43b921e3 100644 --- a/src/File.zig +++ b/src/File.zig @@ -57,6 +57,9 @@ pub const State = struct { /// replaces it: an edit that brings the text back to it is clean again, /// as an undo to the save point is in acme. saved_hash: ?u64 = null, + /// The length of the text `saved_hash` is of, when it is of one: an + /// edit to another length cannot be back to it, and is not hashed. + saved_len: ?usize = null, watch_after_save: bool = false, /// Save makes the file's directory, and any above it, first: `Config`'s /// pane for an init file that is not there yet, whose directory may not @@ -617,14 +620,22 @@ pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize { /// The line index of `new` from `old`'s: an edit replaces one span, so /// line starts before it stand, starts after it shift, and only the span /// is scanned. Rebuilding scanned the whole file on every keystroke. -fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8) ![]usize { +/// The bytes a splice left alone at either end, when its caller knows them. +pub const Span = struct { head: usize, tail: usize }; + +fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8, known: ?Span) ![]usize { const n = @min(old.len, new.len); var head: usize = 0; - while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64; - while (head < n and old[head] == new[head]) head += 1; var tail: usize = 0; - while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64; - while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1; + if (known) |span| { + head = span.head; + tail = span.tail; + } else { + while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64; + while (head < n and old[head] == new[head]) head += 1; + while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64; + while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1; + } // A start s follows the newline at s-1: kept while that newline is in // the common head, shifted while it is in the common tail. const kept = std.sort.upperBound(usize, starts, head, struct { @@ -677,7 +688,12 @@ test "edited line index equals a rebuilt one" { const old = try gpa.dupe(u8, text.items); defer gpa.free(old); try text.replaceRange(gpa, at, del, ins[0..ins_len]); - const edited = try editedLineStarts(gpa, starts, old, text.items); + // As a splice that says what it kept, and as one that does not. + const known: Span = .{ .head = at, .tail = old.len - at - del }; + const told = try editedLineStarts(gpa, starts, old, text.items, known); + defer gpa.free(told); + const edited = try editedLineStarts(gpa, starts, old, text.items, null); + try std.testing.expectEqualSlices(usize, edited, told); defer gpa.free(edited); var fresh_state: State = undefined; fresh_state.content = text.items; @@ -906,6 +922,12 @@ fn reportEdit(p: *Pardes, f: *State, new: []const u8) void { } pub fn setContent(p: *Pardes, f: *State, new: []u8) void { + setContentSpan(p, f, new, null); +} + +/// `setContent` for a splice that knows what it left alone at either end, +/// so the line index is not found again by comparing the whole text. +pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void { locations.freeRows(p.gpa, f.location_rows); f.location_rows = &.{}; for (p.panes) |slot| if (slot) |pane| { @@ -919,13 +941,17 @@ pub fn setContent(p: *Pardes, f: *State, new: []u8) void { reportEdit(p, f, new); if (f.mini) |*mini| mini.deinit(p.gpa); f.mini = null; - const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new) catch &.{} else &.{}; - // ponytail: a whole-text hash per edit; a rolling hash when files grow large. - if (f.revision == f.saved_revision or f.saved_hash == null) f.saved_hash = std.hash.Wyhash.hash(0, f.content); + const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new, span) catch &.{} else &.{}; + // ponytail: a whole-text hash per edit that keeps the saved length; a + // rolling hash when that too matters. + if (f.revision == f.saved_revision or f.saved_hash == null) { + f.saved_hash = std.hash.Wyhash.hash(0, f.content); + f.saved_len = f.content.len; + } p.gpa.free(f.content); f.content = new; f.revision +%= 1; - if (f.saved_hash) |h| if (std.hash.Wyhash.hash(0, new) == h) { + if (f.saved_hash) |h| if ((f.saved_len orelse new.len) == new.len and std.hash.Wyhash.hash(0, new) == h) { f.saved_revision = f.revision; }; f.mtime = pardes.ctlfs.events.now(); 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 }); |
