diff options
| -rw-r--r-- | docs/typ/builtins-gui.json | 3 | ||||
| -rw-r--r-- | docs/typ/builtins-tty.json | 3 | ||||
| -rw-r--r-- | src/File.zig | 239 | ||||
| -rw-r--r-- | src/builtins.zig | 28 | ||||
| -rw-r--r-- | src/config.zig | 1 | ||||
| -rw-r--r-- | src/exec.zig | 1 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 56 | ||||
| -rw-r--r-- | src/pardes.zig | 11 | ||||
| -rw-r--r-- | test/perf-baseline-gui-Debug.json | 2 | ||||
| -rw-r--r-- | test/perf-baseline-tty-Debug.json | 2 | ||||
| -rw-r--r-- | test/perf-baseline-tty-ReleaseFast.json | 2 | ||||
| -rw-r--r-- | test/perf.zig | 34 |
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; |
