summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.zig11
-rw-r--r--src/dump.zig3
-rw-r--r--src/ninep/ctl.zig47
-rw-r--r--src/pardes.zig203
-rw-r--r--src/tty/tty.zig10
5 files changed, 267 insertions, 7 deletions
diff --git a/src/config.zig b/src/config.zig
index 45deb7bb..d80e2a02 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -85,6 +85,7 @@ pub const leader_path = paths: {
.Pager = null,
.PagerColor = null,
.TermImages = null,
+ .JumpScope = null,
.Debug = "td",
.Msg = null,
.Colors = "tc",
@@ -791,6 +792,10 @@ pub const Runtime = struct {
/// (yazi's previews): `real`, as pixels where the shell can, or
/// `petscii`, as glyph art. The Petscii builtin flips one pane.
term_images: TermImages = .real,
+ /// What Back and Forward keep to (Pardes.jumpBy): `file`, the places in
+ /// the focused pane's file first, other files only once those run out
+ /// that way; `all`, every place in the order it was jumped to.
+ jump_scope: JumpScope = .file,
pub const ShaderAnimation = enum { off, on, always };
pub const Placement = enum { acme, pardes };
@@ -799,8 +804,9 @@ pub const Runtime = struct {
pub const DirLook = enum { pane, terminal };
pub const Pager = enum { pardes, off };
pub const TermImages = enum { real, petscii };
+ pub const JumpScope = enum { file, all };
/// A setting that is one of a few words, each an enum field of Runtime.
- pub const Choice = enum { placement, boot_shell, look_word, dir_look, pager, term_images };
+ pub const Choice = enum { placement, boot_shell, look_word, dir_look, pager, term_images, jump_scope };
/// shadow: a soft drop shadow. rim: a light hairline along the top of
/// the focused pane's tag rows. auto: a shadow on a light page; on a
@@ -1069,6 +1075,7 @@ pub const Runtime = struct {
.{ .word = "Pager", .action = .{ .choice = .pager }, .doc = "What a terminal's commands page through: pardes, a +Pager pane (pardes -), or off, the environment's own; for terminals started after it." },
.{ .word = "PagerColor", .action = .{ .toggle = .pager_color }, .doc = "A +Pager keeps its program's colours, on or off; for the next text paged." },
.{ .word = "TermImages", .action = .{ .choice = .term_images }, .doc = "How a new terminal draws its program's kitty graphics: real, as pixels where it can, or petscii, as glyph art." },
+ .{ .word = "JumpScope", .action = .{ .choice = .jump_scope }, .doc = "What Back and Forward keep to: file, the focused pane's file until it runs out that way, or all, every place in order." },
};
pub fn find(name: []const u8) ?Setting {
@@ -1756,6 +1763,7 @@ pub const Runtime = struct {
.dir_look => "terminal",
.pager => "off",
.term_images => "petscii",
+ .jump_scope => "all",
},
.lift => "rim",
.motion => "playful",
@@ -1829,6 +1837,7 @@ pub const Runtime = struct {
.dir_look => "DirLook terminal\n",
.pager => "Pager off\n",
.term_images => "TermImages petscii\n",
+ .jump_scope => "JumpScope all\n",
},
.lift => "Lift rim\n",
.motion => "Motion playful\n",
diff --git a/src/dump.zig b/src/dump.zig
index 84321457..005770e1 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -1028,14 +1028,17 @@ test "a dump keeps the settings that differ from a fresh session's, and a restor
defer p.deinit();
try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes"));
try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off"));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "JumpScope all"));
try dumpState(p);
try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"Placement pardes\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"JumpScope all\"") != null);
// What is left at its default is not written.
try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"BootShell keep\"") == null);
const restored = try initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?);
defer restored.deinit();
try std.testing.expectEqual(config.Runtime.Placement.pardes, restored.settings.placement);
try std.testing.expect(!restored.settings.verbose);
+ try std.testing.expectEqual(config.Runtime.JumpScope.all, restored.settings.jump_scope);
}
test "a session with every column it may hold dumps and restores them all" {
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 157bc8b9..0f4e181c 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -3576,6 +3576,53 @@ test "Back and Forward over a closed file's entries: one reopen is that file's p
try testing.expectEqual(c_serial, p.panes[p.active].?.serial);
}
+test "Back and Forward written to exec keep to the focused file, and JumpScope all, written to ctl, takes every place in order" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "row\n" ** 100 });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "row\n" ** 100 });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ // Each a look, as a user's jumps are: b1 a1 b40 a40 b80 a80.
+ for ([_][]const u8{ "b.txt:1", "a.txt:1", "b.txt:40", "a.txt:40", "b.txt:80", "a.txt:80" }) |place| {
+ try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/{s}\n", .{ dir, place })).reply.status);
+ p.sync();
+ }
+ const Step = struct { word: []const u8, file: []const u8, line: i32 };
+ const walk = struct {
+ fn run(q: *Pardes, steps: []const Step) !void {
+ for (steps) |s| {
+ var cmd: [16]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(q, root_exec, try std.fmt.bufPrint(&cmd, "{s}\n", .{s.word})).reply.status);
+ q.sync();
+ const pane = q.panes[q.active].?;
+ try testing.expect(std.mem.endsWith(u8, pane.file.?.path, s.file));
+ try testing.expectEqual(s.line, pane.body.cur_row + 1);
+ }
+ }
+ }.run;
+ // a's places first, b only once a has none older; Forward mirrors it
+ // from b and ends at a80, where it began.
+ try walk(p, &.{
+ .{ .word = "Back", .file = "/a.txt", .line = 40 },
+ .{ .word = "Back", .file = "/a.txt", .line = 1 },
+ .{ .word = "Back", .file = "/b.txt", .line = 1 },
+ .{ .word = "Forward", .file = "/b.txt", .line = 40 },
+ .{ .word = "Forward", .file = "/b.txt", .line = 80 },
+ .{ .word = "Forward", .file = "/a.txt", .line = 80 },
+ });
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "JumpScope all\n").reply.status);
+ try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.ctl), 0, 8192).bytes, "JumpScope all\n") != null);
+ try walk(p, &.{
+ .{ .word = "Back", .file = "/b.txt", .line = 80 },
+ .{ .word = "Back", .file = "/a.txt", .line = 40 },
+ .{ .word = "Forward", .file = "/b.txt", .line = 80 },
+ });
+}
+
test "Recent keeps only files on disk: not a name never saved, nor the served tree's" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/pardes.zig b/src/pardes.zig
index 164bc253..674128a1 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -7073,12 +7073,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.
@@ -7117,14 +7147,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);
}
@@ -8699,6 +8729,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 });
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index a067ffce..f0f41747 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -2452,6 +2452,9 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
.wheel_down => .wheel_down,
.wheel_left => .wheel_left,
.wheel_right => .wheel_right,
+ // A mouse's side buttons, as xterm reports them: Back and Forward.
+ .button_8 => .back,
+ .button_9 => .forward,
.none => .none, // button-less motion: hover tracking
else => return null,
};
@@ -2471,6 +2474,13 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
} };
}
+test "a mouse's side buttons, xterm's 8 and 9, are Back and Forward, as SDL's X1 and X2 are" {
+ for ([_]vaxis.Mouse.Button{ .button_8, .button_9 }, [_]pardes.Mouse.Button{ .back, .forward }) |from, to| {
+ const event = mouseEvent(.{ .col = 3, .row = 2, .button = from, .mods = .{}, .type = .press }).?;
+ try std.testing.expectEqual(to, event.mouse.button);
+ }
+}
+
/// A blank whose ink alone changed is not sent: vaxis's render skips it
/// (pardes's patch to Vaxis.zig in build.zig), since a blank's ink shows
/// nowhere. Deciding that here cost every cell of every frame a look at the