diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.zig | 11 | ||||
| -rw-r--r-- | src/dump.zig | 3 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 47 | ||||
| -rw-r--r-- | src/pardes.zig | 203 | ||||
| -rw-r--r-- | src/tty/tty.zig | 10 |
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 |
