summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 01:47:40 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commitbe404e0d4f91548af6ed600b2fd2027ad8a041c7 (patch)
treefb3597f4f1fe9703a64726c22a3b9eb29609bcdf /src
parent8a7112f08e0ce93a37b177e3aa83637e1691b451 (diff)
downloadpardes-be404e0d4f91548af6ed600b2fd2027ad8a041c7.tar.gz
pardes-be404e0d4f91548af6ed600b2fd2027ad8a041c7.zip
focus history is a stack of locations, and the Jumplist is that same stack
One container, not two: focus_hist (a stack of pane ids rebuilt every sync) is now jumps[] + a current pointer, and the +Jumps buffer is a RENDERING of that array — nothing copies it, nothing shadows it. prevFocus, Toggleterm, Look's directory order, Back/Forward, Last and Jumplist all walk the one list. A pane id is reused, so a location that only remembered an id would retarget after a respawn: panes now carry a monotonic serial and an entry whose slot holds a different serial is dead. trackJump compacts those out and fixes the pointer in the same pass. The push rule lives in ONE place and says: a location is worth remembering when you cannot see it any more — a different pane, or more than a bodyful of rows away in the same one. So hjkl never grows the list and 100G, a search hit and a goto-definition do. Ctrl-o/Ctrl-i walk it, SPC j j toggles the last two, SPC j l lists them. Ctrl-i IS Tab on a legacy host, where the binding simply never fires and Tab-executes is untouched; kitty reports them apart. SPC j o/i work anywhere. Two goldens moved, both the SPC ? Help listing gaining four rows.
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig66
-rw-r--r--src/config.zig33
-rw-r--r--src/output_pane.zig71
-rw-r--r--src/pardes.zig173
4 files changed, 307 insertions, 36 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index cf144d33..7e20a95b 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -351,13 +351,12 @@ pub const Right = struct {
}
};
-/// The file<->terminal hop. "Latest" is already recorded: focus_hist is the
-/// MRU sync() rebuilds every update (most recent last), the same list a
-/// closing pane hands focus back through — so this walks it instead of keeping
-/// a second one. Which side is which: only a shell is a terminal; a file, an
-/// image and an output buffer (+Search/+Help) are all DOCS you read, so
-/// isTerminal is the whole test. Landing pushes this pane to the top of that
-/// same history, which is why the hop back is the same key.
+/// The file<->terminal hop. "Latest" is already recorded: the jump stack runs
+/// oldest-first and a closing pane hands focus back through the same list — so
+/// this walks it instead of keeping a second one. Which side is which: only a
+/// shell is a terminal; a file, an image and an output buffer (+Search/+Help)
+/// are all DOCS you read, so isTerminal is the whole test. Landing pushes this
+/// pane onto that same history, which is why the hop back is the same key.
pub const Toggleterm = struct {
pub fn run(c: Ctx) void {
const want_term = !c.pane.isTerminal();
@@ -369,10 +368,10 @@ pub const Toggleterm = struct {
if (k != c.id and op.isTerminal() == want_term) t = k;
}
// ...but the most recently focused one wins
- var i = c.p.nfocus;
+ var i = c.p.njumps;
while (i > 0) {
i -= 1;
- const hid = c.p.focus_hist[i];
+ const hid = c.p.jumps[i].pane;
const hp = c.p.panes[hid] orelse continue;
if (hid != c.id and hp.isTerminal() == want_term) {
t = hid;
@@ -386,6 +385,55 @@ pub const Toggleterm = struct {
}
};
+// ---- the jump group ----
+
+// Where focus HAS BEEN, as three verbs and a list over the one stack pardes
+// keeps (Pardes.jumps — see trackJump for what gets onto it). Builtins rather
+// than bare key handlers for the same reason the four directions are: one
+// implementation, reachable by chord, by `SPC j ...`, and by executing the
+// word wherever it is written.
+
+/// Ctrl-o: one step back into the history.
+pub const Back = struct {
+ pub fn run(c: Ctx) void {
+ c.p.jumpBy(-1);
+ }
+};
+
+/// Ctrl-i: one step forward again, up to wherever Back started.
+pub const Forward = struct {
+ pub fn run(c: Ctx) void {
+ c.p.jumpBy(1);
+ }
+};
+
+/// vim's Ctrl-^: the pane you were in before this one, whichever it was — the
+/// hop you press twice a minute and never want to count steps for.
+///
+/// It does NOT move the stack cursor: it goes somewhere, so trackJump records
+/// it like any other move, and that is exactly what makes it an involution —
+/// after the hop, the pane you came from is the newest OTHER pane, so pressing
+/// it again comes straight back. Back/Forward walk history; this one makes it.
+pub const Last = struct {
+ pub fn run(c: Ctx) void {
+ var i = c.p.njumps;
+ while (i > 0) {
+ i -= 1;
+ const j = c.p.jumps[i];
+ if (j.pane != c.id) return c.p.focusPaneLine(j.pane, j.line, j.col);
+ }
+ }
+};
+
+/// The same stack, as text you can read and click. Not a copy of it and not a
+/// second list kept in step — the buffer is RENDERED from the stack when you
+/// ask, the way +Search is rendered from a walk.
+pub const Jumplist = struct {
+ pub fn run(c: Ctx) void {
+ output_pane.openJumps(c.p, c.id);
+ }
+};
+
// ---- the language group ----
// Reached as `SPC l <helix's letter>` — see leader_path for why the prefix
diff --git a/src/config.zig b/src/config.zig
index 394f9ca3..c4249270 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -123,6 +123,15 @@ pub const leader_path = std.EnumArray(Builtin, ?[]const u8).init(.{
.Up = "wk",
.Right = "wl",
.Toggleterm = "wt",
+ // the `j` JUMP group, its own letter rather than more of `w`: the window
+ // group moves focus by GEOMETRY (the pane left of this one), these move it
+ // by TIME (the pane I was in before). `o` and `i` are the letters of the
+ // chords that do the same thing, `jj` is the group's obvious verb, and
+ // `jl` is the list itself.
+ .Back = "jo",
+ .Forward = "ji",
+ .Last = "jj",
+ .Jumplist = "jl",
// the two acme verbs: keys and buttons, no leader path — see above
.Look = null,
.Exec = null,
@@ -180,6 +189,29 @@ pub const window_keys = [_]struct { letter: Chord, arrow: Chord, cmd: Builtin }{
pub const new_shell_below: []const Chord = &.{.{ .cp = 'n', .alt = true }};
pub const pane_to_new_column: []const Chord = &.{.{ .cp = 'c', .alt = true }};
+// ---- jumps ----
+
+/// vim's Ctrl-o / Ctrl-i, walking the focus history back and forward. Global
+/// in any mode, like the two Alt- ops above and for the same reason: getting
+/// BACK has to work from inside a pane that owns its keys.
+///
+/// CAREFUL, and this is why the table has a comment: **Ctrl-i is Tab**. On the
+/// wire they are the same byte (0x09), so on a host that speaks only the
+/// legacy encoding this binding never fires and 0x09 keeps meaning `exec_key`
+/// below — which is the right way round, since Tab-executes is the older and
+/// more used of the two. Where the host speaks the kitty keyboard protocol
+/// (`CSI 105;5u`) the two are distinct keys and both work. Shift-Esc
+/// (tty_toggle_alt) is already spelled on that same bet.
+///
+/// Shaped like window_keys: the `cmd` column is what makes the chord
+/// discoverable from the builtin as well as the other way round, and it is
+/// what keeps ONE implementation — pressing the chord and executing the word
+/// `Back` are the same call.
+pub const jump_keys = [_]struct { chord: Chord, cmd: Builtin }{
+ .{ .chord = .{ .cp = 'o', .ctrl = true }, .cmd = .Back },
+ .{ .chord = .{ .cp = 'i', .ctrl = true }, .cmd = .Forward },
+};
+
/// `j` off the topbar drops back onto a tagline — the mirror of the `k` that
/// got you there (window_keys' letter, answered by tagNormalKey).
pub const topbar_down: []const Chord = &.{.{ .cp = 'j' }};
@@ -368,6 +400,7 @@ pub const symbol_marker = " WsSymbols /";
/// these changes only what you read in a tag.
pub const search_buffer = "+Search";
pub const help_buffer = "+Help";
+pub const jumps_buffer = "+Jumps";
pub const hover_buffer = "+Hover";
pub const lsp_buffer = "+Lsp";
diff --git a/src/output_pane.zig b/src/output_pane.zig
index 45eb0931..8e08285f 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -20,6 +20,7 @@ const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
const File = pardes.File;
const file_pane = @import("file_pane.zig");
+const modal = @import("modal.zig");
const builtins = @import("builtins.zig");
const Builtin = builtins.Builtin();
const config = @import("config.zig");
@@ -110,6 +111,9 @@ pub fn traits(o: Origin) Traits {
.search => .{ .name = config.search_buffer, .steps = true },
.cmd => |b| switch (b) {
.Help => .{ .name = config.help_buffer },
+ // the focus history, one location per row: not a search, but the
+ // same kind of list, so n/N walk it and a row is a look target
+ .Jumplist => .{ .name = config.jumps_buffer, .steps = true },
// Find (file names) and Grep (file contents) both list locations;
// no other builtin opens a buffer, and the day one does it lands
// here rather than in a call site.
@@ -188,6 +192,73 @@ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u
return pane;
}
+/// The Jumplist builtin: the focus history (Pardes.jumps) written out as text,
+/// one row per location, oldest first — the same `location text` shape every
+/// results buffer here has, which is what buys n/N stepping and Look-on-a-row
+/// for nothing: the leading word is an ordinary look target, and the ordinary
+/// look path is what goes there.
+///
+/// A RENDERING, never a second list. The rows are spelled from the stack at
+/// the moment you ask and go stale the moment you jump, exactly like a search
+/// result — which is also why this opens a fresh buffer per press instead of
+/// refreshing one the way Help does: Help is a document, this is a snapshot.
+///
+/// How a location is spelled is the rule runSearch already follows: a REAL
+/// file names itself (its path is absolute, so the row resolves from any
+/// pane's directory), and everything else — a terminal, an output buffer, an
+/// image — has no file to point at and gets `@pN`. The trailing text is the
+/// content line for anything holding text, else the pane's directory: enough
+/// to recognise the place without opening it.
+pub fn openJumps(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const arena = p.scratch.allocator();
+ var out: std.ArrayList(u8) = .empty;
+ for (p.jumps[0..p.njumps]) |j| {
+ const jp = p.panes[j.pane] orelse continue;
+ var idbuf: [16]u8 = undefined;
+ const has_path = if (jp.file) |f| f.output == null else false;
+ const loc: []const u8 = if (has_path)
+ jp.file.?.path
+ else
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch continue;
+ const what: []const u8 = if (jp.file) |f|
+ std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r")
+ else if (jp.image) |iv|
+ iv.path
+ else
+ jp.cwdSlice();
+ // one line of a file can be the whole file: cut it, but never mid
+ // codepoint — a partial UTF-8 sequence reaches the renderer as a row
+ // and there is nothing sane for it to draw (grepText cuts the same way)
+ var cut = @min(what.len, 120);
+ while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1;
+ // line 0 is a place with no spot in it (a shell whose cursor is still
+ // the program's): it is written WITHOUT the suffix, which is the same
+ // thing a bare path has always meant to a look.
+ const row = if (j.line == 0)
+ std.fmt.allocPrint(arena, "{s} {s}\n", .{ loc, what[0..cut] }) catch return
+ else
+ std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }) catch return;
+ out.appendSlice(arena, row) catch return;
+ }
+ const content = p.gpa.dupe(u8, out.items) catch return;
+ const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ const free = p.freeSlot() orelse {
+ p.gpa.free(content);
+ return;
+ };
+ const np = open(p, free, dir, .{ .cmd = .Jumplist }, "", content) catch {
+ p.gpa.free(content);
+ return;
+ };
+ p.placeDoc(id, free, np);
+ // focus stays with the pane that asked, exactly as it does after a search:
+ // n/N are read there, and they step the buffer they just armed.
+ p.active = id;
+ pane.search_pane = free;
+ pane.search_row = null;
+}
+
/// The Help builtin: every builtin reachable from `prefix` (empty = all
/// of them) with the keys that run it, written into an output buffer
/// (acme's +Errors) — ordinary text, so the names in it are LIVE: middle-
diff --git a/src/pardes.zig b/src/pardes.zig
index 2365c33f..3bf32c82 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -44,9 +44,34 @@ pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config"
// minimums — live in config.zig with everything else a user retargets.
pub const MAX_PANES = 16;
pub const MAX_COLS = 6;
+/// how far back the jump stack remembers. Vim keeps 100; this is a session of
+/// at most sixteen panes, so the depth that matters is "more visits than you
+/// can hold in your head" and the oldest entry falls off the bottom.
+pub const MAX_JUMPS = 64;
pub const TOPBAR_H: u16 = 1;
pub const BOX_H: u16 = 1;
+/// A place the keyboard has been: a pane AND a spot in it, which is the whole
+/// upgrade over the stack of bare pane ids this replaces — Ctrl-o can now
+/// rewind WITHIN a pane, and a Jumplist row can name a line.
+///
+/// It is SAFE against the pane it names dying: `serial` is the pane's own
+/// identity, so an entry whose slot has since been handed to a different pane
+/// reads as dead rather than silently retargeting itself at the newcomer, and
+/// sync() drops it. What it does not do is outlive the pane — ponytail: a
+/// location is a place in the SESSION, not on disk, so closing a file forgets
+/// the entries pointing into it. To make Ctrl-o RE-OPEN a closed file, this
+/// grows a path field and jumpBy looks it when the pane is gone.
+pub const Loc = struct {
+ pane: u16,
+ serial: u32,
+ /// 1-based, both — this is the `path:LINE:COL` a look word spells, and
+ /// focusPaneLine takes exactly these. 0 = no spot, just the pane (see
+ /// trackJump: a shell whose cursor is still the program's).
+ line: u32,
+ col: u32,
+};
+
const pane_tail = " " ++ config.pane_builtins_str;
const file_pane_tail = " " ++ config.file_pane_builtins_str;
@@ -654,6 +679,11 @@ pub const Image = struct {
pub const Pane = struct {
vt: ghostty_vt.Terminal,
stream: ghostty_vt.TerminalStream,
+ /// WHICH pane this is, for anything that outlives the pane: slots are
+ /// REUSED (freeSlot hands back the lowest free one), so a remembered id
+ /// alone can silently come to mean an unrelated pane. Handed out by
+ /// Pardes.next_serial and never reused. The one reader is the jump stack.
+ serial: u32 = 0,
mode: Mode = .normal,
vweight: f32 = 1,
cols: u16,
@@ -899,11 +929,21 @@ pub const Pardes = struct {
col_w: [MAX_COLS]u16 = undefined,
active: usize = 0,
- /// keyboard focus history, least-recent first, `active` last: rebuilt each
- /// sync (dead slots pruned, ids are reused). A closing pane hands focus
- /// back to the last live one instead of whatever slot comes first.
- focus_hist: [MAX_PANES]usize = undefined,
- nfocus: usize = 0,
+ /// THE focus history: where the keyboard has BEEN, oldest first, and `jcur`
+ /// is the entry it is at NOW (so `jumps[jcur]` is always the live spot and
+ /// the entries past it are the ones Ctrl-i walks forward into). Maintained
+ /// in exactly one place — sync() — and read by everything that asks "where
+ /// was I": Back/Forward, Last, the Jumplist buffer, prevFocus when a pane
+ /// closes, Toggleterm, and the directory order a look resolves in.
+ ///
+ /// One list, not two. A jumplist kept beside a focus history is two things
+ /// to keep agreeing, and they would disagree the first time one of them
+ /// forgot a pane the other still names.
+ jumps: [MAX_JUMPS]Loc = undefined,
+ njumps: usize = 0,
+ jcur: usize = 0,
+ /// hands out Pane.serial; monotonic, never reused
+ next_serial: u32 = 0,
theme_idx: usize = 0,
colors_on: bool = true,
show_debug: bool = false,
@@ -1111,6 +1151,8 @@ pub const Pardes = struct {
// crossterm apps (nushell, helix, fzf) block on the reply forever.
pane.stream.handler.effects.write_pty = term_pane.ptyReport;
pane.stream.handler.effects.device_attributes = term_pane.ptyDeviceAttrs;
+ p.next_serial += 1;
+ pane.serial = p.next_serial;
p.panes[id] = pane;
p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } });
return pane;
@@ -1128,6 +1170,8 @@ pub const Pardes = struct {
.rows = p.screen_h,
};
pane.stream = pane.vt.vtStream();
+ p.next_serial += 1;
+ pane.serial = p.next_serial;
p.panes[id] = pane;
return pane;
}
@@ -1690,6 +1734,14 @@ pub const Pardes = struct {
p.active = free;
return;
}
+ // the jump chords, global for the same reason: Ctrl-o has to get you
+ // out of wherever you are, including a pane in raw tty mode. Above the
+ // acme chords below, which is what makes Ctrl-i reachable at all — on
+ // a kitty-protocol host it arrives as its own key, and where it does
+ // not it IS Tab (0x09) and falls through to Exec, see config.jump_keys.
+ for (config.jump_keys) |jk| {
+ if (hit(key, &.{jk.chord})) return p.runBuiltin(jk.cmd, p.active, "", null);
+ }
if (hit(key, config.pane_to_new_column)) {
const f = p.layoutFindTerm(p.active).?;
if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) {
@@ -4888,7 +4940,7 @@ pub const Pardes = struct {
/// there (`col` likewise, 0 = line start): files recenter the view on it,
/// terminals ride their scrollback to it. Both look targets that name a
/// live pane land here — a path a pane already holds, and `@pN:LINE:COL`.
- fn focusPaneLine(p: *Pardes, id: usize, line: usize, col: usize) void {
+ pub fn focusPaneLine(p: *Pardes, id: usize, line: usize, col: usize) void {
if (id >= MAX_PANES) return;
const pane = p.panes[id] orelse return;
p.active = id;
@@ -4956,7 +5008,7 @@ pub const Pardes = struct {
// The word is resolved against the pane DIRECTORIES in access
// order: the pane the click came from FIRST — its answer is the
// one taken, so nothing that resolves today moves — then every
- // other live pane, most recently focused first (focus_hist runs
+ // other live pane, most recently focused first (the jump stack runs
// least-recent -> active, so it is that array backwards; the
// clicked pane is not always `active`, a right click does not
// focus). Only when ALL of them fail does the word fall through to
@@ -4970,8 +5022,8 @@ pub const Pardes = struct {
var seen: [MAX_PANES][]const u8 = undefined;
var nseen: usize = 0;
var n: usize = 0;
- cand: while (n <= p.nfocus) : (n += 1) {
- const k = if (n == 0) id else p.focus_hist[p.nfocus - n];
+ cand: while (n <= p.njumps) : (n += 1) {
+ const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane;
if (n > 0 and k == id) continue; // tried first, skip the rerun
const op = p.panes[k] orelse continue;
const dir = if (op.file) |f| (std.fs.path.dirname(f.path) orelse "/") else op.cwdSlice();
@@ -5409,11 +5461,13 @@ pub const Pardes = struct {
/// where focus falls when the active pane closes: the most recently
/// focused pane still alive (else any live one). Null = nothing left.
+ /// Walks the jump stack newest-first, so it answers exactly what it always
+ /// did — a pane's newest entry sits where the old MRU put the pane.
pub fn prevFocus(p: *Pardes, closing: usize) ?usize {
- var i = p.nfocus;
+ var i = p.njumps;
while (i > 0) {
i -= 1;
- const id = p.focus_hist[i];
+ const id = p.jumps[i].pane;
if (id != closing and p.panes[id] != null) return id;
}
for (p.panes, 0..) |slot, k| {
@@ -5422,26 +5476,91 @@ pub const Pardes = struct {
return null;
}
+ /// THE PUSH RULE, and the only place it is written down.
+ ///
+ /// A location is worth remembering when you cannot see it any more: focus
+ /// ended this update in a DIFFERENT pane, or more than a bodyful of rows
+ /// away in the same one. Anything closer is the cursor strolling, and the
+ /// current entry just follows it — so h/j/k/w/b never grow the list, while
+ /// a goto-line, a search hit, a goto-definition and every focus change do.
+ /// (Vim's rule is a hand-kept list of "jump commands"; this one asks the
+ /// question those commands are a proxy for, and needs no list.)
+ ///
+ /// It is read HERE, once per update, and nowhere else: what a call site
+ /// does transiently is invisible, which is what keeps n/N over a results
+ /// buffer — which focuses each hit and comes straight back — from pushing
+ /// two entries per keystroke. That transparency is the whole reason the
+ /// rule lives in sync() rather than at the sites that move focus, which is
+ /// where the heuristics used to be scattered.
+ fn trackJump(p: *Pardes) void {
+ // dead entries first, in one compacting pass. A slot is reused, so the
+ // test is the SERIAL: an entry whose pane has been replaced names a
+ // pane that no longer exists, not the newcomer sitting in its slot.
+ var w: usize = 0;
+ var cur: usize = 0;
+ for (p.jumps[0..p.njumps], 0..) |j, i| {
+ if (i == p.jcur) cur = w; // survives -> lands at w; dies -> the next one does
+ const pane = p.panes[j.pane] orelse continue;
+ if (pane.serial != j.serial) continue;
+ p.jumps[w] = j;
+ w += 1;
+ }
+ p.njumps = w;
+ p.jcur = @min(cur, w -| 1);
+
+ const pane = p.panes[p.active] orelse return;
+ // An UNPINNED cursor belongs to the program on the other end of the
+ // pty, not to you, so such a pane is remembered as a place and not as
+ // a spot: line 0 is the "no line" focusPaneLine already understands,
+ // and going back there focuses the shell without dragging its view up
+ // to scrollback row 0. It also has no line to be FAR from, which is
+ // what keeps the first keypress in a shell (which pins the cursor
+ // wherever the prompt is) from reading as a jump.
+ const now: Loc = .{
+ .pane = @intCast(p.active),
+ .serial = pane.serial,
+ .line = if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0,
+ .col = if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) else 0,
+ };
+ if (p.njumps > 0) {
+ const at = &p.jumps[p.jcur];
+ const body = @max(1, p.rects[p.active].h -| BOX_H);
+ const far = at.line != 0 and now.line != 0 and
+ @max(now.line, at.line) - @min(now.line, at.line) > body;
+ if (at.pane == now.pane and !far) {
+ at.* = now; // same visit; the entry IS where you are
+ return;
+ }
+ }
+ // a new jump made from the middle of the list drops everything ahead of
+ // it, the way vim's does: the future you did not take is not history.
+ if (p.njumps > 0) p.njumps = p.jcur + 1;
+ if (p.njumps == MAX_JUMPS) {
+ std.mem.copyForwards(Loc, p.jumps[0 .. MAX_JUMPS - 1], p.jumps[1..]);
+ p.njumps -= 1;
+ }
+ p.jumps[p.njumps] = now;
+ p.jcur = p.njumps;
+ p.njumps += 1;
+ }
+
+ /// Ctrl-o / Ctrl-i (the Back and Forward builtins): move the CURSOR into
+ /// the stack and go to what it names. Nothing is pushed and nothing is
+ /// dropped — walking history is not making it — and trackJump agrees,
+ /// because after the move the live spot IS `jumps[jcur]` again.
+ pub fn jumpBy(p: *Pardes, delta: i32) void {
+ const next = @as(i64, @intCast(p.jcur)) + delta;
+ if (p.njumps == 0 or next < 0 or next >= p.njumps) return;
+ p.jcur = @intCast(next);
+ const j = p.jumps[p.jcur];
+ p.focusPaneLine(j.pane, j.line, j.col);
+ }
+
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
/// deferred greetings. The mirror of the prototype's loop epilogue.
fn sync(p: *Pardes) void {
p.computeGeom();
- // focus history, rebuilt in one pass: drop dead slots and the current
- // active, then push active back on top. Every focus change (mouse,
- // look, Ctrl-w, Alt-n) lands here without touching its call site.
- {
- var w: usize = 0;
- for (p.focus_hist[0..p.nfocus]) |id| {
- if (p.panes[id] == null or id == p.active) continue;
- p.focus_hist[w] = id;
- w += 1;
- }
- p.nfocus = w;
- if (p.panes[p.active] != null) {
- p.focus_hist[p.nfocus] = p.active;
- p.nfocus += 1;
- }
- }
+ p.trackJump();
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
if (pane.reply_len > 0) {