summaryrefslogtreecommitdiff
path: root/src/pardes.zig
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/pardes.zig
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/pardes.zig')
-rw-r--r--src/pardes.zig173
1 files changed, 146 insertions, 27 deletions
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) {