summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig394
1 files changed, 374 insertions, 20 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 38373266..2a74e441 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -46,6 +46,8 @@ pub const Region = @import("surface.zig").Region;
pub const Chrome = @import("surface.zig").Chrome;
pub const MAX_REGIONS = @import("surface.zig").MAX_REGIONS;
pub const builtins = @import("builtins.zig");
+/// Every builtin as JSON, for the docs (tools/gen_builtins_json.zig).
+pub const builtins_json = @import("builtins_json.zig");
const limits = memory.limits;
const selection_pipe = @import("selection_pipe.zig");
pub const config = @import("config.zig");
@@ -218,6 +220,7 @@ pub const Turn = struct {
while (t.lsp_answers == answers) t.settled.wait(t.io.?, &t.mutex) catch return;
}
+
pub fn awaitRestored(t: *Turn, restores: u64) void {
while (t.restores == restores) t.settled.wait(t.io.?, &t.mutex) catch return;
}
@@ -477,9 +480,11 @@ pub const Loc = struct {
test {
_ = @import("tagline.zig");
+ _ = @import("builtins_json.zig");
_ = @import("exec.zig");
_ = @import("look.zig");
_ = @import("sam_edit.zig");
+ _ = @import("edit_cmd.zig");
_ = @import("mouse.zig");
_ = @import("normal.zig");
_ = @import("regexp.zig");
@@ -1983,6 +1988,7 @@ test "a Look on a directory does not type ls into an occupied terminal" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 });
defer p.deinit();
+ p.settings.dir_look = .terminal; // `ls` in a terminal, not a directory pane
while (p.nextEffect()) |_| {}
var realbuf: [4096]u8 = undefined;
@@ -3145,6 +3151,74 @@ test "Unicode display cells map back to body and tag byte cursors" {
try std.testing.expectEqualStrings(before, tagline.curTail(pane));
}
+test "Tab N draws every pane's tab N cells wide and clicks count past it; bare Tab says the width; the ctl read and a Dump keep it" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ // The width is the process's: the next test starts from the default.
+ defer config.tab_now = config.tab_width;
+ const pane = try p.setTestFile("\tx = 1\n");
+ try std.testing.expect(p.executeBuiltinLine(0, "Tab 8"));
+ try std.testing.expectEqual(@as(u16, 8), config.tab_now);
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const surface = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const text_x = rect.x + config.GUTTER + config.PREFIX_W;
+ const body_y = rect.y + BOX_H;
+ try std.testing.expectEqualStrings("x", surface.at(text_x + 8, body_y).grapheme());
+ // A click on the x is on its byte, past the tab.
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = text_x + 8, .row = body_y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = text_x + 8, .row = body_y } });
+ try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col);
+ // Bare, it says the width.
+ try std.testing.expect(p.executeBuiltinLine(0, "Tab"));
+ try std.testing.expectEqualStrings("Tab 8", pane.msg[0..pane.msg_len]);
+ // Out of range: refused, the width kept.
+ try std.testing.expect(p.executeBuiltinLine(0, "Tab 0"));
+ try std.testing.expectEqual(@as(u16, 8), config.tab_now);
+ // The ctl read's tab column, and a Dump's settings.
+ const ctl_text = pardes_ctlfs_ctlText(p, pane);
+ try std.testing.expect(std.mem.indexOf(u8, ctl_text, " 8 ") != null);
+ try dump.dumpState(p);
+ const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon");
+ defer restored.deinit();
+ try std.testing.expectEqual(@as(u16, 8), restored.settings.tab);
+ try std.testing.expectEqual(@as(u16, 8), config.tab_now);
+}
+
+fn pardes_ctlfs_ctlText(p: *Pardes, pane: *Pane) []const u8 {
+ const th = @import("ninep/testing.zig");
+ return th.rd(p, ctlfs.Node.of(pane.serial, .ctl), 0, 4096).bytes;
+}
+
+test "a name typed into the tag's path survives Esc, pending, and the next Enter renames; a second Esc drops it" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("x\n");
+ for ([_]bool{ true, false }) |enter| {
+ tagline.enterTag(p, pane);
+ pane.tag.mode = .insert;
+ pane.tag.cur_row = 0;
+ pane.tag.cur_col = 1;
+ p.update(.{ .key = .{ .cp = 'z', .text = "z" } });
+ try std.testing.expect(pane.prompt == .name);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(pane.prompt == .name);
+ // A stray key changes nothing while it waits.
+ p.update(.{ .key = .{ .cp = 'q', .text = "q" } });
+ if (enter) {
+ p.update(.{ .key = .{ .cp = Key.enter } });
+ try std.testing.expectEqualStrings("/ztest.txt", pane.file.?.path);
+ } else {
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(pane.prompt != .name);
+ try std.testing.expectEqualStrings("/ztest.txt", pane.file.?.path);
+ }
+ }
+}
+
test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
@@ -3657,6 +3731,8 @@ pub const Effect = union(enum) {
/// Snapshot the matching request with pipeRequest(id), then run it away
/// from the UI/event loop and answer with pipe_resp.
pipe: struct { id: u32 },
+ /// Write what an Edit's `w` took (`Pipe.writes`), in order.
+ edit_write,
watch: struct { pane: u8, on: bool, mode: WatchMode = .reconcile },
theme_file: struct { generation: u32, on: bool },
/// Write the build-time theme ring below the per-user config directory.
@@ -4320,6 +4396,14 @@ pub const Pardes = struct {
/// The files opened lately, closed ones too (recent.zig): `Recent`,
/// /recent, and the jumplist's way back to a closed pane.
recent: recent_files.List = .{},
+ /// acme's Incl: the directories a look at a name that is nowhere else
+ /// (`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,
@@ -4610,6 +4694,8 @@ pub const Pardes = struct {
}
pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes {
+ // A new session draws a tab at the default until `Tab` says otherwise.
+ config.tab_now = config.tab_width;
const p = try create(gpa, opts);
errdefer p.deinit();
p.boot = Boot.of(opts);
@@ -4705,6 +4791,13 @@ pub const Pardes = struct {
}
p.sync();
p.loadRecent();
+ for (config.default_incl) |dir| {
+ const copy = try gpa.dupe(u8, dir);
+ p.incl.append(gpa, copy) catch |err| {
+ gpa.free(copy);
+ return err;
+ };
+ }
exec.applyStartupConfig(p);
colors.finishThemeInitialization(p);
p.sync();
@@ -4812,6 +4905,10 @@ pub const Pardes = struct {
for (p.panes) |slot| if (slot) |pane| p.noteClosed(pane);
p.saveRecent();
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);
@@ -4841,7 +4938,7 @@ pub const Pardes = struct {
if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value);
if (p.chord_arg) |a| gpa.free(a);
for (p.spawn_dirs) |d| if (d) |dir| gpa.free(dir);
- if (p.pipe.wait) |*wait| wait.deinit(gpa);
+ p.pipe.deinit(gpa);
p.locations_cache.deinit(p.tree_sitter_gpa);
p.fs.deinit(gpa);
p.shell_rows.reset(gpa);
@@ -4946,6 +5043,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 } });
@@ -5622,6 +5720,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,
@@ -5726,6 +5826,37 @@ pub const Pardes = struct {
f(p.host.ctx, q.id)
else
p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }),
+ .edit_write => {
+ // Each write takes the bytes `w` took when it ran: the text
+ // as it was before the Edit's changes.
+ var writes = p.pipe.writes;
+ p.pipe.writes = .empty;
+ defer {
+ for (writes.items) |*w| w.deinit(p.gpa);
+ writes.deinit(p.gpa);
+ }
+ for (writes.items) |w| {
+ const was = if (p.panes[w.pane]) |pane| (if (pane.file) |f| f.saved_revision else 0) else 0;
+ const failures = p.fs.failures;
+ makeParents(w.path);
+ p.hostWriteFile(w.pane, w.path, w.bytes);
+ const pane = p.panes[w.pane] orelse continue;
+ if (pane.serial != w.serial) continue;
+ const f = if (pane.file) |*file| file else continue;
+ // Failed, it changes nothing, as save_text's does not.
+ if (p.fs.failures != failures) {
+ f.saved_revision = was;
+ continue;
+ }
+ if (w.promote) {
+ p.promoteSaved(w.pane, w.path);
+ } else if (w.clean != null and w.clean.? == f.revision) {
+ f.saved_revision = f.revision;
+ f.disk_gone = false;
+ ctlfs.events.noteLog(p, .save, pane);
+ } else ctlfs.events.noteLogAs(p, .save, pane, w.path);
+ }
+ },
.watch => |w| if (v.watch_file) |f|
f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on, w.mode)
else {
@@ -5802,6 +5933,13 @@ 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 {
+ // The held write of an Edit whose commands run, asked again by a
+ // retry of its park: still waiting, or its answer, never run twice.
+ if (p.fs.edit_hold) |*h| if (h.names(req)) {
+ if (!h.done) return .{ .tag = req.tag, .status = .again };
+ return filesystem.EditHold.answer(&p.fs);
+ };
+ 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.
@@ -5811,6 +5949,7 @@ pub const Pardes = struct {
look.cancelLookHover(p);
p.fs.serving = true;
p.fs.lsp_answer_at = null;
+ p.fs.edit_started = false;
const reply = ctlfs.handle(p, req);
p.fs.serving = false;
// The one-failure rule is a write's: a refused open (its OTRUNC's
@@ -5832,6 +5971,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.
@@ -6242,16 +6382,28 @@ pub const Pardes = struct {
}
if (header == null and pane.prompt == .name) {
// Enter or the execute key commits the draft and the keyboard
- // goes back to the body; Esc drops it.
+ // goes back to the body. Esc while typing keeps the draft,
+ // pending, as Esc keeps any edit: Enter then renames, and a
+ // second Esc drops it. Pending, no other key changes it.
if (hit(key, config.look_key) or hit(key, config.exec_key)) {
if (tagline.commitTagName(p, pane)) pane.focus = .body;
return;
}
+ const pending = pane.input.mode != .insert;
+ if (hit(key, config.escape) and !pending) {
+ pane.input.mode = .normal;
+ if (p.paneIdOf(pane)) |id| p.setMessage(id, "name drafted: Enter renames the buffer, Esc drops the draft");
+ return;
+ }
if (hit(key, config.escape)) {
pane.closePrompt();
pane.focus = .body;
return;
}
+ if (pending) {
+ if (p.paneIdOf(pane)) |id| p.setMessage(id, "name drafted: Enter renames the buffer, Esc drops the draft");
+ return;
+ }
edit.handleInsert(p, &pane.input, key);
return;
}
@@ -6292,12 +6444,13 @@ pub const Pardes = struct {
const range = t.primaryRange(shown, 0);
const lo = @min(range.anchor, range.head);
const hi = @max(range.anchor, range.head);
- const txt = if ((t.vsel.active and t.vsel.explicit) or t.msel.active)
- shown[lo..hi]
+ const span: [2]usize = if ((t.vsel.active and t.vsel.explicit) or t.msel.active)
+ .{ lo, hi }
else word: {
const b = config.wordBounds(shown, modal.selectionCursor(shown, range));
- break :word shown[b.lo..b.hi];
+ break :word .{ b.lo, b.hi };
};
+ const txt = shown[span[0]..span[1]];
// A column tag's command acts on that column, empty or not.
p.exec_column = if (header != null) p.header_column else null;
defer p.exec_column = null;
@@ -6306,7 +6459,12 @@ pub const Pardes = struct {
// Out of the tag or header FIRST: Del frees the pane, Kill
// and Restore the whole session.
if (header != null) tagline.exitHeader(p) else pane.focus = .body;
- if (txt.len > 0) exec.runBuiltin(p, cmd, p.active, "", txt);
+ if (txt.len == 0) return;
+ // A pane's tag word: an event reader hears it as a click on
+ // it, at its place in the tag (flatSurface is the tag's text).
+ if (header == null and t.what == .tag and
+ mouse.reportRange(p, p.active, cmd, txt, true, .{ .q0 = @intCast(span[0]), .q1 = @intCast(span[1]) }, null)) return;
+ exec.runBuiltin(p, cmd, p.active, "", txt);
return;
}
edit.pinPaneCursor(p, &pane.body);
@@ -6323,9 +6481,11 @@ pub const Pardes = struct {
return;
}
}
- const sel = look.expandedSel(p, pane, look.cursorWordSel(p, pane)) orelse return;
- const word = edit.selectionText(p, pane, sel) catch return;
- exec.runBuiltinFrom(p, cmd, p.active, "", word, true);
+ // The cursor's word as a click there takes it: one expansion
+ // (exec.operandAt), a diff line's address and all, and an
+ // event reader told as a click tells it.
+ const operand = exec.operandAt(p, pane, look.cursorWordSel(p, pane), false, cmd == config.look_cmd);
+ mouse.dispatchPointerBuiltin(p, p.active, cmd, operand.text, .{ .on_tag = false, .operand = operand });
return;
}
if (t.mode == .normal and t.what == .body and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key);
@@ -6428,7 +6588,7 @@ pub const Pardes = struct {
.owned => |cwd| return cwd,
.none => {},
}
- if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/";
+ if (pane.file) |f| return panes.Dir.ofName(f.path);
if (comptime pdf_enabled) if (pane.pdf) |pv|
return std.fs.path.dirname(pv.path) orelse "/";
if (pane.image) |iv| return std.fs.path.dirname(iv.path) orelse "/";
@@ -6802,7 +6962,7 @@ pub const Pardes = struct {
return;
}
- const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ const dir = if (pane.file) |f| panes.Dir.ofName(f.path) else pane.cwdSlice();
const content = p.gpa.dupe(u8, rows) catch return;
const serial_before = p.next_serial;
p.fs.refilled = null;
@@ -7067,12 +7227,42 @@ pub const Pardes = struct {
p.njumps += 1;
}
+ /// One step through the jumplist, older for a negative `delta`, newer
+ /// otherwise: Back and Forward from every key, mouse button, builtin and
+ /// 9P exec. `jcur` is the entry where you are; the step moves it to the
+ /// entry it lands on, and the entries it passed over stay where they are.
+ ///
+ /// JumpScope file, from a pane with a file: the nearest entry that way in
+ /// this pane, else in this file in any pane (a closed one's too), else,
+ /// with none of this file left that way, the nearest of any file, as
+ /// JumpScope all always takes. Forward mirrors Back, so it retraces a run
+ /// of Backs inside one file step for step, and Forwards end, as they
+ /// always have, at the newest entry.
pub fn jumpBy(p: *Pardes, delta: i32) void {
- const next = @as(i64, @intCast(p.jcur)) + delta;
+ const older = delta < 0;
// At an end of the list: said, not a step that silently does nothing.
- if (p.njumps == 0 or next < 0 or next >= p.njumps)
- return p.setMessage(p.active, if (delta < 0) "Back: nothing further back" else "Forward: nothing further on");
- const j = p.jumps[@intCast(next)];
+ if (p.njumps == 0 or (older and p.jcur == 0) or (!older and p.jcur + 1 >= p.njumps))
+ return p.setMessage(p.active, if (older) "Back: nothing further back" else "Forward: nothing further on");
+ var next = if (older) p.jcur - 1 else p.jcur + 1;
+ // A terminal or a scratch has no file to keep to: the step is global.
+ if (p.settings.jump_scope == .file) if (p.panes[p.active]) |here| if (recentPlace(here)) |place| if (place.path.len > 0) {
+ const hash = if (recentDot(here)) |d| std.hash.Wyhash.hash(0, d.path) else 0;
+ // The nearest of this pane's own wins over a nearer one of this
+ // file in another pane: the step does not move the keyboard.
+ var pick: ?usize = null;
+ var i = p.jcur;
+ while (if (older) i > 0 else i + 1 < p.njumps) {
+ i = if (older) i - 1 else i + 1;
+ const k = p.jumps[i];
+ if (k.pane == p.active and k.serial == here.serial) {
+ pick = i;
+ break;
+ }
+ if (pick == null and hash != 0 and k.path_hash == hash) pick = i;
+ }
+ next = pick orelse next;
+ };
+ const j = p.jumps[next];
if (j.pane >= MAX_PANES) return;
// A closed file's entry: opened again at its dot, placed as a look
// places it, and the entry is that pane from here on.
@@ -7111,14 +7301,14 @@ pub const Pardes = struct {
k.pane = @intCast(p.active);
k.serial = opened.serial;
};
- p.jcur = @intCast(next);
+ p.jcur = next;
return;
}
const pane = p.panes[j.pane] orelse return;
if (pane.serial != j.serial) return;
if (p.panes[p.active]) |source| look.clearNavigationSelection(p, source);
if (p.active != j.pane) look.clearNavigationSelection(p, pane);
- p.jcur = @intCast(next);
+ p.jcur = next;
look.focusPaneLine(p, j.pane, .{ .line = j.line, .col = j.col }, .center);
}
@@ -7133,7 +7323,7 @@ pub const Pardes = struct {
var wrap_room: [32 * 1024]u8 = undefined;
var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room);
const wa = wrap_fba.allocator();
- const prefix = tagline.prefixIn(wa, pane) catch return pane.tag_rows;
+ const prefix = tagline.prefixIn(p, wa, pane) catch return pane.tag_rows;
const whole = std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) }) catch return pane.tag_rows;
const shown = tagline.wrapTag(wa, whole, tagline.tagWidth(p, id)) catch return pane.tag_rows;
return @intCast(@max(BOX_H, @min(shown.rows(), MAX_TAG_ROWS)));
@@ -7192,7 +7382,7 @@ pub const Pardes = struct {
// every event may take from the scratch arena.
var room: [2 * limits.host_path_cap + 256]u8 = undefined;
var measure = std.heap.FixedBufferAllocator.init(&room);
- const lead = if (tagline.prefixIn(measure.allocator(), pane)) |prefix| prefix.len else |_| pane.tag_lead;
+ const lead = if (tagline.prefixIn(p, measure.allocator(), pane)) |prefix| prefix.len else |_| pane.tag_lead;
if (lead != pane.tag_lead) {
const t = &pane.tag;
const old: i32 = @intCast(pane.tag_lead);
@@ -7212,7 +7402,7 @@ pub const Pardes = struct {
var lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1;
var wanted = lines;
var caret_row: usize = @intCast(@max(0, pane.tag.cur_row));
- if (tagline.prefixIn(wa, pane)) |prefix| {
+ if (tagline.prefixIn(p, wa, pane)) |prefix| {
if (std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) })) |whole| {
if (tagline.wrapTag(wa, whole, tagline.tagWidth(p, id))) |shown| {
lines = shown.rows();
@@ -7255,6 +7445,9 @@ pub const Pardes = struct {
pane.rows = rows;
if (pane.file) |*file| file.syntax_dirty = true;
}
+ // A directory pane's entries follow its width, as acme's
+ // columns do (Dir.fit).
+ if (pane.file) |file| if (file.listing != null and r.h > pane.tag_rows) panes.Dir.fit(p, pane, pane.cols);
panes.terminal.releasePendingCommandIfReady(p, id, pane);
// The greeting `ls` of a new terminal in a directory is typed at
// its shell's FIRST prompt, or never: typed later it would land
@@ -8690,6 +8883,167 @@ test "jump history clears selections and lands at the recorded cursor" {
}
}
+/// A session for the JumpScope tests: four panes of 100 lines in one
+/// column, A (pane 0) and B each on a file of its own, C on A's file, and O
+/// an output buffer, no file behind it. `order` is the jumplist, a letter a
+/// place, each letter's n-th place its line 10 + 40n: "ABABA" is A10 B10 A50
+/// B50 A90. The keyboard is on the last place, as after jumping there.
+const JumpsTest = struct {
+ p: *Pardes,
+ ids: [4]usize = .{ 0, 0, 0, 0 },
+
+ const letters = "ABCO";
+ const Way = enum { call, key, builtin, mouse };
+
+ fn init(order: []const u8) !JumpsTest {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 80 });
+ errdefer p.deinit();
+ const text = "row\n" ** 100;
+ _ = try p.setTestFile(text);
+ var t: JumpsTest = .{ .p = p };
+ for ([_][]const u8{ "/b.txt", "/test.txt", "" }, 1..) |path, n| {
+ const id = p.freeSlot().?;
+ const pane = try p.newDocPane(id);
+ pane.file = .{
+ .path = try p.gpa.dupe(u8, path),
+ .content = try p.gpa.dupe(u8, text),
+ .history = try panes.File.History.create(p.gpa),
+ };
+ if (path.len == 0) pane.file.?.output = .{ .from = .search };
+ layout.insert(p, 0, n, id);
+ t.ids[n] = id;
+ }
+ layout.compute(p);
+ var seen: [4]u32 = @splat(0);
+ for (order, 0..) |letter, i| {
+ const which = std.mem.indexOfScalar(u8, letters, letter).?;
+ const pane = p.panes[t.ids[which]].?;
+ p.jumps[i] = .{
+ .pane = @intCast(t.ids[which]),
+ .serial = pane.serial,
+ .line = 10 + 40 * seen[which],
+ .col = 1,
+ .path_hash = if (letter == 'O') 0 else std.hash.Wyhash.hash(0, pane.file.?.path),
+ };
+ seen[which] += 1;
+ }
+ p.njumps = order.len;
+ p.jcur = order.len - 1;
+ look.focusPaneLine(p, p.jumps[p.jcur].pane, .{ .line = p.jumps[p.jcur].line, .col = 1 }, .center);
+ p.sync();
+ return t;
+ }
+
+ /// One step `delta` through `way`: the function, Ctrl-o or Ctrl-i, the
+ /// mouse's side button pressed and let go, or the builtin's word.
+ fn step(t: JumpsTest, way: Way, delta: i32) void {
+ const back = delta < 0;
+ switch (way) {
+ .call => t.p.jumpBy(delta),
+ .key => t.p.update(.{ .key = .{ .cp = if (back) 'o' else 'i', .ctrl = true } }),
+ .builtin => _ = t.p.executeBuiltinLine(t.p.active, if (back) "Back" else "Forward"),
+ .mouse => for ([_]Mouse.Kind{ .press, .release }) |kind|
+ t.p.update(.{ .mouse = .{ .button = if (back) .back else .forward, .kind = kind, .col = 0, .row = 0 } }),
+ }
+ t.p.sync();
+ }
+
+ /// Steps `delta` until the list ends that way, saying so; the places
+ /// landed on, as "A50 A10 B10".
+ fn walk(t: JumpsTest, way: Way, delta: i32, buf: []u8) ![]const u8 {
+ var out: std.Io.Writer = .fixed(buf);
+ for (0..2 * MAX_JUMPS) |_| {
+ const before = t.p.jcur;
+ t.step(way, delta);
+ if (t.p.jcur == before) break;
+ const pane = t.p.panes[t.p.active].?;
+ const which = std.mem.indexOfScalar(usize, &t.ids, t.p.active).?;
+ try out.print("{s}{c}{d}", .{ if (out.end > 0) " " else "", letters[which], pane.body.cur_row + 1 });
+ } else return error.NoEnd;
+ const pane = t.p.panes[t.p.active].?;
+ const said = pane.msg[0..pane.msg_len];
+ try std.testing.expect(std.mem.indexOf(u8, said, if (delta < 0) "nothing further back" else "nothing further on") != null);
+ return out.buffered();
+ }
+};
+
+test "JumpScope file: Back keeps to the focused file, A50 then A10, and leaves it only once it runs out that way" {
+ const t = try JumpsTest.init("BABABA");
+ defer t.p.deinit();
+ try std.testing.expectEqual(config.Runtime.JumpScope.file, t.p.settings.jump_scope);
+ var buf: [128]u8 = undefined;
+ // B90, B50 passed over, kept in their places: A has nothing older than
+ // A10, so the step after it is the nearest place of any file, B10.
+ try std.testing.expectEqualStrings("A50 A10 B10", try t.walk(.call, -1, &buf));
+ try std.testing.expectEqual(@as(usize, 6), t.p.njumps);
+ // Forward is the mirror, from B10 in B: B's own places first, then A90,
+ // the newest, where the Backs started.
+ try std.testing.expectEqualStrings("B50 B90 A90", try t.walk(.call, 1, &buf));
+ try std.testing.expectEqual(@as(usize, 5), t.p.jcur);
+}
+
+test "JumpScope file: Forward retraces a run of Backs inside one file step for step, back to where it began" {
+ const t = try JumpsTest.init("ABABA");
+ defer t.p.deinit();
+ var buf: [128]u8 = undefined;
+ // A1 B1 A2 B2 A3 from A3: nothing older than A1 at all, so Back ends there.
+ try std.testing.expectEqualStrings("A50 A10", try t.walk(.call, -1, &buf));
+ try std.testing.expectEqual(@as(usize, 0), t.p.jcur);
+ try std.testing.expectEqualStrings("A50 A90", try t.walk(.call, 1, &buf));
+ try std.testing.expectEqual(@as(usize, 4), t.p.jcur);
+ // One Back, one Forward: the same entry, whichever were passed over.
+ t.step(.call, -1);
+ try std.testing.expectEqual(@as(usize, 2), t.p.jcur);
+ t.step(.call, 1);
+ try std.testing.expectEqual(@as(usize, 4), t.p.jcur);
+ try std.testing.expectEqual(@as(usize, 5), t.p.njumps);
+}
+
+test "JumpScope all: Back and Forward take every place in the order it was jumped to" {
+ const t = try JumpsTest.init("ABABA");
+ defer t.p.deinit();
+ try std.testing.expect(t.p.executeBuiltinLine(t.p.active, "JumpScope all"));
+ try std.testing.expectEqual(config.Runtime.JumpScope.all, t.p.settings.jump_scope);
+ var buf: [128]u8 = undefined;
+ try std.testing.expectEqualStrings("B50 A50 B10 A10", try t.walk(.call, -1, &buf));
+ try std.testing.expectEqualStrings("B10 A50 B50 A90", try t.walk(.call, 1, &buf));
+ // Bare, the word steps back to file.
+ try std.testing.expect(t.p.executeBuiltinLine(t.p.active, "JumpScope"));
+ try std.testing.expectEqual(config.Runtime.JumpScope.file, t.p.settings.jump_scope);
+}
+
+test "JumpScope file: from a pane with no file, Back and Forward are global" {
+ const t = try JumpsTest.init("OAOBO");
+ defer t.p.deinit();
+ var buf: [128]u8 = undefined;
+ // O90 keeps to nothing: B10, the next older of all. From B, nothing of
+ // B is older, so O50; from O, A10; from A, O10.
+ try std.testing.expectEqualStrings("B10 O50 A10 O10", try t.walk(.call, -1, &buf));
+ try std.testing.expectEqualStrings("A10 O50 B10 O90", try t.walk(.call, 1, &buf));
+}
+
+test "JumpScope file: a file in two panes keeps to the pane first, then the file in the other pane" {
+ // C is A's file in a second pane: C10 B10 A10 C50 A50.
+ const t = try JumpsTest.init("CBACA");
+ defer t.p.deinit();
+ var buf: [128]u8 = undefined;
+ // From A50: A10 in this pane, over the nearer C50; then none of A's
+ // own is older, so the file's in C, C10, over B10.
+ try std.testing.expectEqualStrings("A10 C10", try t.walk(.call, -1, &buf));
+ // From C10 in C: C50, its own, then the file's newest, A50.
+ try std.testing.expectEqualStrings("C50 A50", try t.walk(.call, 1, &buf));
+}
+
+test "Back and Forward take the same steps from Ctrl-o and Ctrl-i, the mouse's side buttons, the builtins and jumpBy" {
+ for ([_]JumpsTest.Way{ .call, .key, .builtin, .mouse }) |way| {
+ const t = try JumpsTest.init("BABABA");
+ defer t.p.deinit();
+ var buf: [128]u8 = undefined;
+ try std.testing.expectEqualStrings("A50 A10 B10", try t.walk(way, -1, &buf));
+ try std.testing.expectEqualStrings("B50 B90 A90", try t.walk(way, 1, &buf));
+ }
+}
+
test "jump history terminal rectangle follows output and clears on reflow" {
if (comptime !terminal_panes) return error.SkipZigTest;
const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true });