From afec162464d360cbbf475d3fb7457cda83ce918f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 17:52:24 -0300 Subject: Back and Forward keep to the focused file: Ctrl-o, Ctrl-i, the mouse's side buttons (now the tty's 8 and 9 too), the builtins and 9P go to the nearest place that way in this pane, else in this file in another pane, else of any file; JumpScope all takes every place in order as before --- src/pardes.zig | 203 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 197 insertions(+), 6 deletions(-) (limited to 'src/pardes.zig') 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 }); -- cgit v1.3