diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-31 01:47:40 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:08 -0300 |
| commit | be404e0d4f91548af6ed600b2fd2027ad8a041c7 (patch) | |
| tree | fb3597f4f1fe9703a64726c22a3b9eb29609bcdf /src/pardes.zig | |
| parent | 8a7112f08e0ce93a37b177e3aa83637e1691b451 (diff) | |
| download | pardes-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.zig | 173 |
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) { |
