diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 394 |
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 }); |
