summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/typ/builtins-gui.json3
-rw-r--r--docs/typ/builtins-tty.json3
-rw-r--r--src/File.zig239
-rw-r--r--src/builtins.zig28
-rw-r--r--src/config.zig1
-rw-r--r--src/exec.zig1
-rw-r--r--src/ninep/ctl.zig56
-rw-r--r--src/pardes.zig11
-rw-r--r--test/perf-baseline-gui-Debug.json2
-rw-r--r--test/perf-baseline-tty-Debug.json2
-rw-r--r--test/perf-baseline-tty-ReleaseFast.json2
-rw-r--r--test/perf.zig34
12 files changed, 369 insertions, 13 deletions
diff --git a/docs/typ/builtins-gui.json b/docs/typ/builtins-gui.json
index 4833360e..95eeaa07 100644
--- a/docs/typ/builtins-gui.json
+++ b/docs/typ/builtins-gui.json
@@ -139,5 +139,6 @@
{"name": "WindowOpacity", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": null, "doc": "The window's opacity, a percentage."},
{"name": "Wrap", "leader": "SPC t w", "also": [], "arg": true, "scope": "root", "setting": true, "choices": "on, off", "doc": "Long lines wrap onto the next row, on or off."},
{"name": "WsDiagnostics", "leader": "SPC l D", "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "List the workspace's diagnostics from the language server."},
- {"name": "WsSymbols", "leader": "SPC l S", "also": [], "arg": true, "scope": "pane", "setting": false, "choices": null, "doc": "Search the workspace's symbols for its argument."}
+ {"name": "WsSymbols", "leader": "SPC l S", "also": [], "arg": true, "scope": "pane", "setting": false, "choices": null, "doc": "Search the workspace's symbols for its argument."},
+ {"name": "Zerox", "leader": null, "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "acme's Zerox: a second pane on this pane's file, the same text, undo and unsaved state, with a scroll and a cursor of its own (File.zerox)."}
]
diff --git a/docs/typ/builtins-tty.json b/docs/typ/builtins-tty.json
index fa0a44a1..887393ac 100644
--- a/docs/typ/builtins-tty.json
+++ b/docs/typ/builtins-tty.json
@@ -120,5 +120,6 @@
{"name": "Verbose", "leader": null, "also": [], "arg": true, "scope": "root", "setting": true, "choices": "on, off", "doc": "A builtin says its own name on the message row as it runs, on or off."},
{"name": "Wrap", "leader": "SPC t w", "also": [], "arg": true, "scope": "root", "setting": true, "choices": "on, off", "doc": "Long lines wrap onto the next row, on or off."},
{"name": "WsDiagnostics", "leader": "SPC l D", "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "List the workspace's diagnostics from the language server."},
- {"name": "WsSymbols", "leader": "SPC l S", "also": [], "arg": true, "scope": "pane", "setting": false, "choices": null, "doc": "Search the workspace's symbols for its argument."}
+ {"name": "WsSymbols", "leader": "SPC l S", "also": [], "arg": true, "scope": "pane", "setting": false, "choices": null, "doc": "Search the workspace's symbols for its argument."},
+ {"name": "Zerox", "leader": null, "also": [], "arg": false, "scope": "pane", "setting": false, "choices": null, "doc": "acme's Zerox: a second pane on this pane's file, the same text, undo and unsaved state, with a scroll and a cursor of its own (File.zerox)."}
]
diff --git a/src/File.zig b/src/File.zig
index 3ef1593d..522d4bd6 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -3,6 +3,7 @@
const panes = @import("panes.zig");
const std = @import("std");
const vaxis = @import("vaxis");
+const builtin = @import("builtin");
const pardes = @import("pardes.zig");
const config = @import("config.zig");
const Pardes = pardes.Pardes;
@@ -107,6 +108,10 @@ pub const State = struct {
/// to; gpa-owned ranges and the primary's index.
node_history: std.ArrayList(NodeStep) = .empty,
history: *History,
+ /// Zerox's group: the panes on one buffer share `content`, `history`
+ /// and (while it is the one index) `line_starts`, each with its own
+ /// scroll, cursor and syntax; 0, none. See `zerox`.
+ twin: u32 = 0,
};
pub const NodeStep = struct { ranges: []modal.Selection, primary: usize };
@@ -992,11 +997,17 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
if (file.output == null) for (p.panes, 0..) |slot, id| {
if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
};
+ // A twin's text, undo and shared index stay with the twins left.
+ var twins: [pardes.MAX_PANES]usize = undefined;
+ const ntwins = twinsOf(p, file, &twins);
+ const index_shared = for (twins[0..ntwins]) |id| {
+ if (p.panes[id].?.file.?.line_starts.ptr == file.line_starts.ptr) break true;
+ } else false;
p.gpa.free(file.path);
- p.gpa.free(file.content);
+ if (ntwins == 0) p.gpa.free(file.content);
if (file.listing) |listing| p.gpa.free(listing.names);
if (file.mini) |*mini| mini.deinit(p.gpa);
- if (file.line_starts.len > 0) p.gpa.free(file.line_starts);
+ if (file.line_starts.len > 0 and !index_shared) p.gpa.free(file.line_starts);
if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights);
p.gpa.free(file.pager_spans);
locations.freeRows(p.gpa, file.location_rows);
@@ -1004,6 +1015,7 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void {
if (file.node_tree) |tree| syntax.freeTree(tree);
for (file.node_history.items) |step| p.gpa.free(step.ranges);
file.node_history.deinit(p.gpa);
+ if (ntwins > 0) return;
for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content);
for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content);
p.gpa.destroy(file.history);
@@ -1024,7 +1036,20 @@ pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
/// `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 {
+pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span_in: ?Span) void {
+ // Twins (Zerox) take the same splice: where it is, found once here when
+ // the caller did not say, is what the line index wants anyway.
+ var twins: [pardes.MAX_PANES]usize = undefined;
+ const ntwins = twinsOf(p, f, &twins);
+ const span: ?Span = span_in orelse if (ntwins > 0) spanOf(f.content, new) else null;
+ var moved: [pardes.MAX_PANES]struct { cursor: usize, scroll: usize } = undefined;
+ for (twins[0..ntwins], 0..) |id, i| {
+ const t = p.panes[id].?;
+ const tf = &t.file.?;
+ const at = lineStart(p.gpa, tf, @intCast(@max(0, t.body.cur_row))) + @as(usize, @intCast(@max(0, t.body.cur_col)));
+ moved[i] = .{ .cursor = spliced(@min(at, f.content.len), f.content.len, new.len, span.?), .scroll = spliced(lineStart(p.gpa, tf, tf.scroll), f.content.len, new.len, span.?) };
+ }
+ const old_starts = f.line_starts.ptr;
locations.freeRows(p.gpa, f.location_rows);
f.location_rows = &.{};
p.gpa.free(f.pager_spans);
@@ -1061,6 +1086,214 @@ pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void {
f.highlight_start = 0;
f.syntax_dirty = true;
if (std.mem.endsWith(u8, f.path, "/+Pager")) f.pager_diff = looksLikeDiff(new[0..@min(new.len, 64 * 1024)]);
+ for (twins[0..ntwins], 0..) |id, i| {
+ const t = p.panes[id].?;
+ const tf = &t.file.?;
+ // Its own index, built while the shared one was empty, goes; the
+ // shared one, freed above, is replaced.
+ if (tf.line_starts.len > 0 and tf.line_starts.ptr != old_starts) p.gpa.free(tf.line_starts);
+ tf.content = f.content;
+ tf.line_starts = f.line_starts;
+ tf.revision = f.revision;
+ tf.saved_revision = f.saved_revision;
+ tf.saved_hash = f.saved_hash;
+ tf.saved_len = f.saved_len;
+ tf.mtime = f.mtime;
+ if (tf.highlights.len > 0) p.tree_sitter_gpa.free(tf.highlights);
+ tf.highlights = &.{};
+ tf.highlight_start = 0;
+ tf.syntax_dirty = true;
+ locations.freeRows(p.gpa, tf.location_rows);
+ tf.location_rows = &.{};
+ if (tf.mini) |*mini| mini.deinit(p.gpa);
+ tf.mini = null;
+ for (0..t.sel.len) |button| {
+ t.clearPointerSelection(button);
+ t.sel[button].state = .none;
+ }
+ t.body.vsel.active = false;
+ t.body.msel.active = false;
+ t.body.nsel = 0;
+ const cursor = positionIn(p.gpa, tf, moved[i].cursor);
+ t.body.cur_row = @intCast(cursor.row);
+ t.body.cur_col = @intCast(cursor.col);
+ tf.scroll = positionIn(p.gpa, tf, moved[i].scroll).row;
+ }
+}
+
+/// Where an offset into the old text is in the new, after the splice that
+/// kept `span` at either end: past it, moved by what it added; inside it,
+/// at its start, as acme moves a clone's q0 (textinsert, textdelete).
+fn spliced(at: usize, old_len: usize, new_len: usize, span: Span) usize {
+ if (at <= span.head) return at;
+ if (at >= old_len - span.tail) return at - (old_len - span.tail) + (new_len - span.tail);
+ return span.head;
+}
+
+/// What `old` and `new` agree on at either end.
+fn spanOf(old: []const u8, new: []const u8) Span {
+ const n = @min(old.len, new.len);
+ const head = std.mem.indexOfDiff(u8, old[0..n], new[0..n]) orelse n;
+ var tail: usize = 0;
+ while (tail < n - head and old[old.len - 1 - tail] == new[new.len - 1 - tail]) tail += 1;
+ return .{ .head = head, .tail = tail };
+}
+
+fn positionIn(gpa: std.mem.Allocator, f: *State, at: usize) struct { row: usize, col: usize } {
+ const index = lineIndex(gpa, f) catch {
+ const c = modal.positionAt(f.content, at);
+ return .{ .row = c.row, .col = c.col };
+ };
+ const row = std.sort.upperBound(usize, index, at, struct {
+ fn order(key: usize, item: usize) std.math.Order {
+ return std.math.order(key, item);
+ }
+ }.order) -| 1;
+ return .{ .row = row, .col = at - index[row] };
+}
+
+/// The other panes on `f`'s buffer (Zerox), by id.
+pub fn twinsOf(p: *Pardes, f: *const State, out: *[pardes.MAX_PANES]usize) usize {
+ if (f.twin == 0) return 0;
+ var n: usize = 0;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ const other = if (pane.file) |*file| file else continue;
+ if (other == f or other.twin != f.twin) continue;
+ out[n] = id;
+ n += 1;
+ }
+ return n;
+}
+
+/// acme's Zerox: pane `to` made on `from`'s buffer, its text, undo and
+/// dirty state shared, its scroll and cursor its own. An edit in either is
+/// spliced into both (setContentSpan, O(the edit) more); what Save and the
+/// disk watch change on one is copied to the others after each step
+/// (syncTwins). An event reader on one hears no edit made in another.
+pub fn zerox(p: *Pardes, from: usize, to: usize) !*Pane {
+ const src = p.panes[from].?;
+ const f = &src.file.?;
+ const path = try p.gpa.dupe(u8, f.path);
+ errdefer p.gpa.free(path);
+ if (f.twin == 0) {
+ const group = try p.gpa.dupe(u8, f.path);
+ errdefer p.gpa.free(group);
+ try p.twin_groups.append(p.gpa, .{ .id = p.next_twin, .saved_revision = f.saved_revision, .saved_hash = f.saved_hash, .saved_len = f.saved_len, .disk_newer = f.disk_newer, .disk_gone = f.disk_gone, .path = group });
+ f.twin = p.next_twin;
+ p.next_twin +%= 1;
+ }
+ const pane = try p.newDocPane(to);
+ pane.file = .{
+ .path = path,
+ .content = f.content,
+ .line_starts = f.line_starts,
+ .history = f.history,
+ .revision = f.revision,
+ .saved_revision = f.saved_revision,
+ .saved_hash = f.saved_hash,
+ .saved_len = f.saved_len,
+ .disk_newer = f.disk_newer,
+ .disk_gone = f.disk_gone,
+ .mtime = f.mtime,
+ .scroll = f.scroll,
+ .tree_context = f.tree_context,
+ .twin = f.twin,
+ };
+ pane.body.cur_row = src.body.cur_row;
+ pane.body.cur_col = src.body.cur_col;
+ pane.body.cur_pinned = true;
+ if (filesystem.localPath(path) != null) p.emit(.{ .watch = .{ .pane = @intCast(to), .on = true } });
+ return pane;
+}
+
+/// A twin closed: it keeps a text of its own until it is torn down (a
+/// retired pane is drawn going), and the twins keep the shared one, its
+/// undo and its index.
+pub fn leaveTwins(p: *Pardes, f: *State) void {
+ var twins: [pardes.MAX_PANES]usize = undefined;
+ const n = twinsOf(p, f, &twins);
+ f.twin = 0;
+ if (n == 0) return;
+ const shared_index = for (twins[0..n]) |id| {
+ if (p.panes[id].?.file.?.line_starts.ptr == f.line_starts.ptr) break true;
+ } else false;
+ if (shared_index) f.line_starts = &.{};
+ f.content = p.gpa.dupe(u8, f.content) catch p.gpa.alloc(u8, 0) catch &.{};
+ f.history = History.create(p.gpa) catch f.history;
+ // The one left is no twin now (syncTwins drops the group).
+ if (n == 1) p.panes[twins[0]].?.file.?.twin = 0;
+}
+
+pub const TwinGroup = struct {
+ id: u32,
+ saved_revision: u32,
+ saved_hash: ?u64,
+ saved_len: ?usize,
+ disk_newer: ?u64,
+ disk_gone: bool,
+ path: []u8,
+};
+
+/// After each step: what a Save, a get, the disk watch or a rename changed
+/// on one twin is copied to the others; a group down to one pane is no
+/// group. O(panes) a step, and nothing at all with no twins.
+pub fn syncTwins(p: *Pardes) void {
+ var g: usize = 0;
+ while (g < p.twin_groups.items.len) {
+ const group = &p.twin_groups.items[g];
+ var members: [pardes.MAX_PANES]*State = undefined;
+ var n: usize = 0;
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ const f = if (pane.file) |*file| file else continue;
+ if (f.twin == group.id) {
+ members[n] = f;
+ n += 1;
+ }
+ }
+ if (n < 2) {
+ if (n == 1) members[0].twin = 0;
+ p.gpa.free(group.path);
+ _ = p.twin_groups.swapRemove(g);
+ continue;
+ }
+ for (members[0..n]) |f| {
+ if (f.saved_revision == group.saved_revision and f.saved_hash == group.saved_hash and f.saved_len == group.saved_len and
+ f.disk_newer == group.disk_newer and f.disk_gone == group.disk_gone and std.mem.eql(u8, f.path, group.path)) continue;
+ // This one changed: the group, and every other twin, take it.
+ if (!std.mem.eql(u8, f.path, group.path)) {
+ const path = p.gpa.dupe(u8, f.path) catch break;
+ p.gpa.free(group.path);
+ group.path = path;
+ }
+ group.saved_revision = f.saved_revision;
+ group.saved_hash = f.saved_hash;
+ group.saved_len = f.saved_len;
+ group.disk_newer = f.disk_newer;
+ group.disk_gone = f.disk_gone;
+ for (members[0..n]) |other| {
+ if (other == f) continue;
+ other.saved_revision = f.saved_revision;
+ other.saved_hash = f.saved_hash;
+ other.saved_len = f.saved_len;
+ other.disk_newer = f.disk_newer;
+ other.disk_gone = f.disk_gone;
+ if (!std.mem.eql(u8, other.path, f.path)) {
+ const path = p.gpa.dupe(u8, f.path) catch continue;
+ p.gpa.free(other.path);
+ other.path = path;
+ }
+ }
+ break;
+ }
+ // Tests hold the twins to one text, one revision, every step.
+ if (builtin.is_test) for (members[1..n]) |other| {
+ std.debug.assert(other.content.ptr == members[0].content.ptr and other.content.len == members[0].content.len);
+ std.debug.assert(other.revision == members[0].revision);
+ };
+ g += 1;
+ }
}
/// Whether `head`, a text's first bytes, holds a diff: a `diff` header, or
diff --git a/src/builtins.zig b/src/builtins.zig
index 5fdabd87..da87ba37 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -575,6 +575,22 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
.column => |col| if ((layout.findPane(c.p, id) orelse continue).col != col) continue,
}
const f = pane.file orelse continue;
+ // Twins (Zerox) hold one text: closing one while another stays
+ // loses nothing, and the text is asked about once.
+ if (f.twin != 0) {
+ const asked_elsewhere = for (c.p.panes, 0..) |other_slot, other_id| {
+ const other = other_slot orelse continue;
+ const of = other.file orelse continue;
+ if (other_id == id or of.twin != f.twin) continue;
+ const closing = switch (which) {
+ .all => true,
+ .pane => |only| other_id == only,
+ .column => |col| if (layout.findPane(c.p, other_id)) |at| at.col == col else false,
+ };
+ if (!closing or other_id < id) break true;
+ } else false;
+ if (asked_elsewhere) continue;
+ }
// A +New scratch saves nowhere, but what is typed in it is as lost;
// under 100 bytes it is not asked about, as acme's winclean does not
// whine about a small unnamed window, nor about a command's output.
@@ -1196,6 +1212,18 @@ pub const Incl = struct {
}
};
+/// acme's Zerox: a second pane on this pane's file, the same text, undo and
+/// unsaved state, with a scroll and a cursor of its own (File.zerox).
+pub const Zerox = struct {
+ pub fn run(c: Ctx) void {
+ const f = c.pane.file orelse return c.p.reportFailure(c.id, "Zerox: only a file pane takes it");
+ if (f.output != null or f.listing != null or f.mini != null) return c.p.reportFailure(c.id, "Zerox: only a file pane takes it");
+ const free = c.p.freeSlot() orelse return c.p.reportError(c.id, "Zerox", error.NoPaneSlots);
+ _ = panes.File.zerox(c.p, c.id, free) catch |err| return c.p.reportError(c.id, "Zerox", err);
+ exec.placeNew(c.p, c.id, c.id, free, .doc);
+ }
+};
+
/// Write the pane's text to its file, or to the file its argument names.
pub const Save = struct {
pub const takes_arg = true;
diff --git a/src/config.zig b/src/config.zig
index 89e5f813..26e1de2c 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -62,6 +62,7 @@ pub const leader_path = paths: {
.Putall = null,
.Incl = null,
.Tab = null,
+ .Zerox = null,
.ClipYank = "y",
.ClipYankMain = "Y",
.ClipPaste = "p",
diff --git a/src/exec.zig b/src/exec.zig
index 1ee57520..9aa8a8cb 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -948,7 +948,6 @@ pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint:
.{ .acme = "Snarf", .pardes = null, .hint = ": y yanks the selection" },
.{ .acme = "Cut", .pardes = null, .hint = ": d deletes the selection into a register" },
.{ .acme = "Paste", .pardes = null, .hint = ": p puts a register" },
- .{ .acme = "Zerox", .pardes = null },
.{ .acme = "Sort", .pardes = null },
.{ .acme = "Load", .pardes = "Restore" },
.{ .acme = "ID", .pardes = null },
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 188795de..537ac4d0 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -1829,6 +1829,60 @@ test "Putall saves every pane with unsaved edits to a file, one each, and leaves
try testing.expectEqual(@as(usize, 1), saves);
}
+test "Zerox makes a second pane on one buffer: one text, undo and dirty state, its own cursor; Save cleans both, Del of one asks nothing" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ const p = try withFile(testing.allocator, "one\ntwo\nthree\n");
+ defer p.deinit();
+ const first = serialOf(p);
+ var name: [std.fs.max_path_bytes + 8]u8 = undefined;
+ _ = wr(p, Node.of(first, .name), try std.fmt.bufPrint(&name, "{s}/z.txt\n", .{dir}));
+ try testing.expect(p.executeBuiltinLine(0, "Zerox"));
+ const twin_id = for (p.panes, 0..) |slot, id| {
+ if (id != 0) if (slot) |pane| if (pane.file != null) break id;
+ } else return error.NoTwin;
+ const a = p.panes[0].?;
+ const b = p.panes[twin_id].?;
+ // Both in the index, each its serial, one name.
+ const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes;
+ try testing.expectEqual(@as(usize, 2), std.mem.count(u8, index, "/z.txt"));
+ // b's cursor on `three`; a line written above it in a moves it down.
+ b.body.cur_row = 2;
+ b.body.cur_col = 1;
+ _ = wr(p, Node.of(first, .addr), "0");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(first, .data), "zero\n").reply.status);
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", b.file.?.content);
+ try testing.expectEqual(a.file.?.content.ptr, b.file.?.content.ptr);
+ try testing.expectEqual(@as(i32, 3), b.body.cur_row);
+ try testing.expectEqual(@as(i32, 1), b.body.cur_col);
+ try testing.expect(pane_files.dirtyOf(a) and pane_files.dirtyOf(b));
+ // A write through b is a's too; Undo in a takes it back from both.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(b.serial, .body), "four\n").reply.status);
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\nfour\n", a.file.?.content);
+ try testing.expect(p.executeBuiltinLine(0, "Undo"));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", b.file.?.content);
+ // Saved from b: both come clean.
+ try testing.expect(p.executeBuiltinLine(twin_id, "Save"));
+ while (p.nextEffect()) |e| p.perform(e);
+ try testing.expect(!pane_files.dirtyOf(a) and !pane_files.dirtyOf(b));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\n", p.fallback.get(b.file.?.path).?);
+ // Edited again, Del of a asks nothing: b holds the text.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(b.serial, .body), "five\n").reply.status);
+ const failures = p.fs.failures;
+ try testing.expect(p.executeBuiltinLine(0, "Del"));
+ p.sync();
+ try testing.expectEqual(failures, p.fs.failures);
+ try testing.expect(p.paneBySerial(first) == null);
+ try testing.expect(pane_files.dirtyOf(b));
+ try testing.expectEqualStrings("zero\none\ntwo\nthree\nfive\n", b.file.?.content);
+ // Alone now, it is no twin; Del asks about it as about any pane.
+ try testing.expectEqual(@as(u32, 0), b.file.?.twin);
+ try testing.expect(p.executeBuiltinLine(p.paneBySerial(b.serial).?, "Del"));
+ try testing.expect(p.fs.failures != failures);
+}
+
test "a body write to a terminal is typed input, not a paste, even to a program that asked for bracketed paste" {
const p = try Pardes.init(testing.allocator, .{ .cols = 90, .rows = 30 });
defer p.deinit();
@@ -3211,7 +3265,7 @@ test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask)
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
const serial = serialOf(p);
- for ([_][]const u8{ "Snarf\n", "Zerox\n", "Indent\n", "Local\n", "Abort\n" }) |line| {
+ for ([_][]const u8{ "Snarf\n", "Indent\n", "Local\n", "Abort\n" }) |line| {
for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| {
const r = wr(p, node, line);
try testing.expectEqual(E.INVAL, r.errno());
diff --git a/src/pardes.zig b/src/pardes.zig
index af2b80a2..7ae73636 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4369,6 +4369,10 @@ pub const Pardes = struct {
/// (`stdio.h`, `<foo.h>`) is tried in, in order. The session's, seeded
/// with C's (/usr/include, /usr/local/include), kept in a dump.
incl: std.ArrayList([]u8) = .empty,
+ /// Zerox's groups of panes on one buffer (File.syncTwins), and the id
+ /// the next takes.
+ twin_groups: std.ArrayList(panes.File.TwinGroup) = .empty,
+ next_twin: u32 = 1,
/// The kept list was read (or there was none): only then is it written,
/// so a session never writes over one it did not read.
recent_loaded: bool = false,
@@ -4872,6 +4876,8 @@ pub const Pardes = struct {
p.recent.deinit(p.recentGpa());
for (p.incl.items) |dir| p.gpa.free(dir);
p.incl.deinit(p.gpa);
+ for (p.twin_groups.items) |group| p.gpa.free(group.path);
+ p.twin_groups.deinit(p.gpa);
p.fs.batch.bytes.deinit(p.gpa);
if (p.stand_in) |pane| p.teardownPane(pane);
look.cancelLookHover(p);
@@ -5006,6 +5012,7 @@ pub const Pardes = struct {
// to it reopen it there.
p.noteClosed(pane);
panes.Output.dropUnsaved(p, pane);
+ if (pane.file) |*file| panes.File.leaveTwins(p, file);
const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf();
for (p.panes, 0..) |slot, id| if (slot == pane) {
if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
@@ -5682,6 +5689,8 @@ pub const Pardes = struct {
}
pub fn perform(p: *Pardes, e: Effect) void {
+ // A Save performed on one twin is every twin's (File.syncTwins).
+ defer panes.File.syncTwins(p);
const v = p.host.vtable;
switch (e) {
// The host reads the pane (its command, its shell) as it forks,
@@ -5862,6 +5871,7 @@ pub const Pardes = struct {
/// that changes a pane costs a frame, which is why a round trip on a
/// local socket costs microseconds and not a vsync.
pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply {
+ defer panes.File.syncTwins(p);
if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req);
// An address, a limit or the mark flag is nothing drawn: a script's
// addr write then its close costs no frame between them.
@@ -5892,6 +5902,7 @@ pub const Pardes = struct {
}
pub fn update(p: *Pardes, ev: Event) void {
+ defer panes.File.syncTwins(p);
defer p.reportFocus();
// A tick with nothing animating is the one event that cannot change
// the screen.
diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json
index ace9a967..39f5913c 100644
--- a/test/perf-baseline-gui-Debug.json
+++ b/test/perf-baseline-gui-Debug.json
@@ -1 +1 @@
-{"harness":"e63a8b27a0bcb261","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":2100,"med_us":2437,"p90_us":2875,"max_us":3207},{"op":"render","fixture":"medium","min_us":82,"med_us":84,"p90_us":88,"max_us":89},{"op":"key-down","fixture":"medium","min_us":88,"med_us":95,"p90_us":102,"max_us":114},{"op":"key-right","fixture":"medium","min_us":84,"med_us":88,"p90_us":94,"max_us":105},{"op":"page-down","fixture":"medium","min_us":83,"med_us":86,"p90_us":88,"max_us":89},{"op":"wheel","fixture":"medium","min_us":82,"med_us":85,"p90_us":90,"max_us":99},{"op":"edit-char","fixture":"medium","min_us":1276,"med_us":1769,"p90_us":2239,"max_us":2754},{"op":"body-2m","fixture":"medium","min_us":5333,"med_us":9734,"p90_us":12257,"max_us":14666},{"op":"body-appends","fixture":"medium","min_us":135052,"med_us":262059,"p90_us":295829,"max_us":304576},{"op":"deep-sel","fixture":"medium","min_us":77,"med_us":79,"p90_us":83,"max_us":118}]}
+{"harness":"303736f83a625e87","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1818,"med_us":2104,"p90_us":2287,"max_us":2293},{"op":"render","fixture":"medium","min_us":78,"med_us":82,"p90_us":87,"max_us":87},{"op":"key-down","fixture":"medium","min_us":79,"med_us":81,"p90_us":89,"max_us":109},{"op":"key-right","fixture":"medium","min_us":79,"med_us":84,"p90_us":85,"max_us":89},{"op":"page-down","fixture":"medium","min_us":79,"med_us":81,"p90_us":84,"max_us":87},{"op":"wheel","fixture":"medium","min_us":77,"med_us":78,"p90_us":82,"max_us":82},{"op":"edit-char","fixture":"medium","min_us":1019,"med_us":1349,"p90_us":1597,"max_us":1632},{"op":"edit-twin","fixture":"medium","min_us":2029,"med_us":2353,"p90_us":2455,"max_us":2676},{"op":"body-2m","fixture":"medium","min_us":5978,"med_us":9708,"p90_us":11361,"max_us":11666},{"op":"body-appends","fixture":"medium","min_us":117933,"med_us":211247,"p90_us":268014,"max_us":275006},{"op":"deep-sel","fixture":"medium","min_us":73,"med_us":75,"p90_us":80,"max_us":84}]}
diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json
index 6b5c0bd5..9110398c 100644
--- a/test/perf-baseline-tty-Debug.json
+++ b/test/perf-baseline-tty-Debug.json
@@ -1 +1 @@
-{"harness":"e63a8b27a0bcb261","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1830,"med_us":2222,"p90_us":2264,"max_us":2287},{"op":"render","fixture":"medium","min_us":78,"med_us":83,"p90_us":86,"max_us":94},{"op":"key-down","fixture":"medium","min_us":80,"med_us":84,"p90_us":89,"max_us":98},{"op":"key-right","fixture":"medium","min_us":78,"med_us":81,"p90_us":88,"max_us":96},{"op":"page-down","fixture":"medium","min_us":78,"med_us":82,"p90_us":89,"max_us":95},{"op":"wheel","fixture":"medium","min_us":77,"med_us":85,"p90_us":96,"max_us":109},{"op":"edit-char","fixture":"medium","min_us":1125,"med_us":1307,"p90_us":1558,"max_us":1584},{"op":"body-2m","fixture":"medium","min_us":5189,"med_us":9224,"p90_us":10859,"max_us":14741},{"op":"body-appends","fixture":"medium","min_us":108477,"med_us":198709,"p90_us":227756,"max_us":252672},{"op":"deep-sel","fixture":"medium","min_us":73,"med_us":77,"p90_us":80,"max_us":91}]}
+{"harness":"303736f83a625e87","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1881,"med_us":2148,"p90_us":2324,"max_us":2644},{"op":"render","fixture":"medium","min_us":79,"med_us":87,"p90_us":90,"max_us":93},{"op":"key-down","fixture":"medium","min_us":81,"med_us":84,"p90_us":86,"max_us":90},{"op":"key-right","fixture":"medium","min_us":80,"med_us":85,"p90_us":89,"max_us":102},{"op":"page-down","fixture":"medium","min_us":85,"med_us":86,"p90_us":88,"max_us":88},{"op":"wheel","fixture":"medium","min_us":79,"med_us":85,"p90_us":90,"max_us":91},{"op":"edit-char","fixture":"medium","min_us":1037,"med_us":1300,"p90_us":1639,"max_us":1647},{"op":"edit-twin","fixture":"medium","min_us":2046,"med_us":2378,"p90_us":2529,"max_us":2585},{"op":"body-2m","fixture":"medium","min_us":5790,"med_us":9510,"p90_us":12226,"max_us":12462},{"op":"body-appends","fixture":"medium","min_us":132421,"med_us":224849,"p90_us":257368,"max_us":274890},{"op":"deep-sel","fixture":"medium","min_us":75,"med_us":80,"p90_us":84,"max_us":86}]}
diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json
index 87a31754..4cbc8b95 100644
--- a/test/perf-baseline-tty-ReleaseFast.json
+++ b/test/perf-baseline-tty-ReleaseFast.json
@@ -1 +1 @@
-{"harness":"e63a8b27a0bcb261","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":2042,"med_us":2280,"p90_us":2451,"max_us":2478},{"op":"render","fixture":"medium","min_us":82,"med_us":84,"p90_us":87,"max_us":104},{"op":"key-down","fixture":"medium","min_us":83,"med_us":86,"p90_us":90,"max_us":93},{"op":"key-right","fixture":"medium","min_us":84,"med_us":87,"p90_us":91,"max_us":104},{"op":"page-down","fixture":"medium","min_us":84,"med_us":87,"p90_us":93,"max_us":96},{"op":"wheel","fixture":"medium","min_us":81,"med_us":85,"p90_us":89,"max_us":89},{"op":"edit-char","fixture":"medium","min_us":1309,"med_us":1613,"p90_us":1840,"max_us":2128},{"op":"body-2m","fixture":"medium","min_us":5386,"med_us":11064,"p90_us":11706,"max_us":12374},{"op":"body-appends","fixture":"medium","min_us":144772,"med_us":232307,"p90_us":273586,"max_us":281169},{"op":"deep-sel","fixture":"medium","min_us":79,"med_us":83,"p90_us":87,"max_us":130}]}
+{"harness":"303736f83a625e87","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1775,"med_us":1995,"p90_us":2092,"max_us":2094},{"op":"render","fixture":"medium","min_us":80,"med_us":86,"p90_us":89,"max_us":95},{"op":"key-down","fixture":"medium","min_us":81,"med_us":83,"p90_us":95,"max_us":101},{"op":"key-right","fixture":"medium","min_us":79,"med_us":84,"p90_us":85,"max_us":98},{"op":"page-down","fixture":"medium","min_us":80,"med_us":82,"p90_us":96,"max_us":97},{"op":"wheel","fixture":"medium","min_us":78,"med_us":80,"p90_us":87,"max_us":88},{"op":"edit-char","fixture":"medium","min_us":1036,"med_us":1377,"p90_us":1602,"max_us":1621},{"op":"edit-twin","fixture":"medium","min_us":2266,"med_us":2715,"p90_us":3695,"max_us":3927},{"op":"body-2m","fixture":"medium","min_us":4739,"med_us":8675,"p90_us":11518,"max_us":13657},{"op":"body-appends","fixture":"medium","min_us":111613,"med_us":214959,"p90_us":264859,"max_us":283506},{"op":"deep-sel","fixture":"medium","min_us":74,"med_us":76,"p90_us":89,"max_us":96}]}
diff --git a/test/perf.zig b/test/perf.zig
index 94076a25..c9235954 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -46,6 +46,9 @@ const Op = enum {
page_down,
wheel,
edit_char,
+ /// edit_char with a Zerox twin open on the same file: the twin takes
+ /// the splice, not a copy of the text (File.setContentSpan).
+ edit_twin,
/// 2 MB appended to the file through 9P, on one open, in 256 KB
/// writes: linear, not a copy of the whole buffer per write.
body_write,
@@ -67,6 +70,7 @@ const Op = enum {
.page_down => "page-down",
.wheel => "wheel",
.edit_char => "edit-char",
+ .edit_twin => "edit-twin",
.body_write => "body-2m",
.body_appends => "body-appends",
.deep_selection => "deep-sel",
@@ -511,6 +515,19 @@ fn gate(cells: *const [std.enums.values(Op).len][fixtures.len]Cell, base: *const
failed = true;
}
};
+ // Typing beside a twin costs about what typing alone does, whatever the
+ // baseline: the twin takes the splice, never a copy of the text.
+ const twin_op = @intFromEnum(Op.edit_twin);
+ const alone_op = @intFromEnum(Op.edit_char);
+ for (fixtures, 0..) |fx, fi| {
+ const twin = cells[twin_op][fi].min_us;
+ const alone = cells[alone_op][fi].min_us;
+ if (twin == 0 or alone == 0) continue;
+ if (@as(f64, @floatFromInt(twin)) > ratio * @as(f64, @floatFromInt(alone))) {
+ std.debug.print("perf gate: edit-twin on {s} took {d} us at best, over {d:.1}x edit-char's {d} us\n", .{ fx.name, twin, ratio, alone });
+ failed = true;
+ }
+ }
if (failed) return error.PerformanceRegression;
std.debug.print("perf gate: every cell within {d:.1}x of the baseline\n", .{ratio});
}
@@ -545,7 +562,13 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
core.active = id;
const pane = core.panes[id].?;
_ = try frame(core);
- if (op == .edit_char) {
+ if (op == .edit_twin) {
+ if (!core.executeBuiltinLine(id, "Zerox")) return error.ZeroxRefused;
+ pump(core);
+ core.active = id;
+ _ = try frame(core);
+ }
+ if (op == .edit_char or op == .edit_twin) {
core.update(.{ .key = .{ .cp = 'i', .text = "i" } });
pump(core);
}
@@ -572,7 +595,7 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
.key_right => core.update(.{ .key = .{ .cp = 'l', .text = "l" } }),
.page_down => core.update(.{ .key = .{ .cp = pardes.Key.page_down } }),
.wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = wheel_col, .row = wheel_row } }),
- .edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }),
+ .edit_char, .edit_twin => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }),
.body_write => bodyWrite(core, pane.serial),
.body_appends => bodyAppends(core, pane.serial),
}
@@ -591,11 +614,16 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
return error.PageDidNotScroll,
.wheel => if (pane.scroll() <= before.scroll and before.scroll < @as(i32, @intCast(fx.lines)) - pane.rows)
return error.WheelDidNotScroll,
- .edit_char => {
+ .edit_char, .edit_twin => {
if (pane.file.?.content.len != before.len + 1 or pane.body.cur_row != before.row or pane.body.cur_col != before.col + 1)
return error.CharacterWasNotInserted;
const line = pardes.panes.File.sourceLine(pane, before.row);
if (line[@intCast(before.col)] != 'x') return error.CharacterWasNotInserted;
+ if (op == .edit_twin) for (core.panes, 0..) |slot, other| {
+ const twin = slot orelse continue;
+ if (other == id or twin.file == null or twin.file.?.twin != pane.file.?.twin) continue;
+ if (twin.file.?.content.ptr != pane.file.?.content.ptr) return error.TwinMissedTheEdit;
+ } else if (pane.file.?.twin == 0) return error.NoTwin;
},
}
if (n >= warmup) samples[n - warmup] = dt / 1000;