summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-08 10:44:56 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit198889ab8f78b12c9881a20309a792e419ba1386 (patch)
tree264bb756389f4c4cbaf68a6a2aabf3d602450a36 /src
parentc3c8bbd8d8add99088c774c54bc1acf1e39ec895 (diff)
downloadpardes-198889ab8f78b12c9881a20309a792e419ba1386.tar.gz
pardes-198889ab8f78b12c9881a20309a792e419ba1386.zip
Esc alternates between the last two panes; Toggleterm is gone
Esc ran Toggleterm, which hopped between the newest DOC and the newest TERMINAL. That distinction never earned its keep. It made Esc unpredictable — which of three panes you landed on depended on their kinds, not on where you had been — and it could not alternate between two files at all, which is the case you hit most. Editing two files, Esc did nothing. The replacement already existed. Last (SPC j j) is "the pane you were in before this one, whichever it was": it walks the jump stack for the newest entry naming a different pane and restores its line and column. So Esc, and Shift-Esc in tty, now run Last, and Toggleterm is deleted rather than renamed — a third implementation of "go to the other pane" was the thing to avoid. SPC w t goes with it; the w group is the four directional moves, and the jump group already had SPC j j. Held down, Esc alternates. Two files, a file and its shell, a file and a +Search — all the same, because Last has no notion of kind to get wrong. This depends on the swap in the same series: Last reads the stack backwards, and until hopping stopped appending, the pane you came from could fall off it. windownav.snap needed only its keys and prose changed — its golden did not move at all, which is the useful evidence here: for the one scenario the old builtin handled well, Last produces an identical focus sequence. Coverage for what it did not handle is new: a unit test opens a second FILE by looking its name and asserts Esc alternates between two panes of the SAME kind, which is the case that used to be a no-op. Docs follow: tutor.txt, docs/helix-keys.md, docs/design.typ, and the builtin index goldens, which are now one row shorter. 75/75 snapshots, both unit suites, and the macOS ABI build all pass.
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig44
-rw-r--r--src/config.zig21
-rw-r--r--src/pardes.zig336
-rw-r--r--src/tutor.txt24
4 files changed, 319 insertions, 106 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index 9fa459f2..b1380203 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -526,40 +526,6 @@ pub const Right = struct {
}
};
-/// 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();
- var t: ?usize = null;
- // any live pane of the other kind: a pane you have never focused (the
- // file you started with) is in no history at all
- for (c.p.panes, 0..) |slot, k| {
- const op = slot orelse continue;
- if (k != c.id and op.isTerminal() == want_term) t = k;
- }
- // ...but the most recently focused one wins
- var i = c.p.njumps;
- while (i > 0) {
- i -= 1;
- 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;
- break;
- }
- }
- if (t) |target| {
- c.p.active = target;
- c.p.panes[target].?.pending = 0;
- }
- }
-};
-
// ---- the jump group ----
// Where focus HAS BEEN, as three verbs and a list over the one stack pardes
@@ -583,12 +549,20 @@ pub const Forward = struct {
};
/// 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.
+/// hop you press twice a minute and never want to count steps for. Body-normal
+/// Esc is this, which is what makes alternating between two panes one key you
+/// hold down: two files, or a file and its shell, or a file and a +Search.
///
/// 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.
+///
+/// It replaced a `Toggleterm` that hopped specifically between the newest DOC
+/// and the newest TERMINAL. That distinction never earned its keep: it made Esc
+/// unpredictable (which of three panes you landed on depended on their kinds),
+/// and it could not alternate between two files at all — the case you hit most.
+/// "The pane before this one" needs no kinds and is the same key twice.
pub const Last = struct {
pub fn run(c: Ctx) void {
var i = c.p.njumps;
diff --git a/src/config.zig b/src/config.zig
index 9451a2a7..93395406 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -144,7 +144,6 @@ pub const leader_path = paths: {
.Down = "wj",
.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
@@ -311,6 +310,18 @@ pub const topbar_str = "New Newcol Find Grep Help Tutor Dump NextColor Debug Kil
pub const pane_builtins_str = "New Del";
pub const file_pane_builtins_str = "Save New Del";
+/// Columns kept clear to the RIGHT of a tagline's builtins. The path stays at
+/// the left edge and the builtins are pushed over to end this far short of the
+/// pane's, which leaves somewhere to type: a word executed from the tagline is
+/// how you run anything here, and with the builtins hard against the edge
+/// there was nowhere to put one without first making room.
+///
+/// The gap that does the pushing is made of ordinary spaces inside the tag, so
+/// both it and this run are editable text — see Pardes.tagGap. Widen it and
+/// every untouched tagline reflows on the next frame; taglines you have
+/// already edited keep the spacing you left them with.
+pub const tag_right_pad: u16 = 20;
+
/// the pane's mode, as ONE character in the layout box at its top-left — live
/// chrome, not text you own. It used to be a three-letter word leading every
/// tagline; the box was already there carrying no information at all, so the
@@ -807,9 +818,11 @@ pub const redo: []const Chord = &.{.{ .cp = 'U' }};
/// where it is still SIGINT.
pub const comment_toggle: []const Chord = &.{.{ .cp = 'c', .ctrl = true }};
-/// In body normal mode, clear modal residue and run Toggleterm (the same
-/// builtin as `SPC w t`). Elsewhere: leave insert mode; abandon a leader path,
-/// tag, armed search or the topbar; raw tty mode forwards it to the program.
+/// In body normal mode, clear modal residue and run Last (the same builtin as
+/// `SPC j j`): the pane you were in before this one, whichever it was. Held
+/// down it alternates between two panes — two files, or a file and its shell.
+/// Elsewhere: leave insert mode; abandon a leader path, tag, armed search or
+/// the topbar; raw tty mode forwards it to the program.
pub const escape: []const Chord = &.{.{ .cp = Key.escape }};
// ---- insert mode ----
diff --git a/src/pardes.zig b/src/pardes.zig
index 5d0ff9fb..8c1e22f4 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1800,8 +1800,12 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" {
// A real middle-click on the canonical pane-tag word reaches Exec, which
// must consume New as a builtin before any write can reach the shell.
- p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } });
- p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } });
+ // The builtins are right-aligned (see tagGap), so the column is found in
+ // the rendered tag rather than assumed to be at its left edge.
+ const rendered = try p.tagText(p.scratch.allocator(), pane);
+ const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "New").?)) + 1;
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = new_x, .row = tag_y } });
+ p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = new_x, .row = tag_y } });
var saw_new = false;
while (p.nextEffect()) |effect| switch (effect) {
.new_file => |request| {
@@ -3938,7 +3942,7 @@ pub const Pardes = struct {
/// 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.
+ /// closes, Last, 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
@@ -4520,24 +4524,72 @@ pub const Pardes = struct {
return pane_tail;
}
- /// the tag exactly as it is rendered: prefix ++ tail. THE text tag_col and
- /// tag_anchor index, so the renderer, the mouse, the motions and the chord
- /// all read the same bytes at the same columns.
+ fn paneIdOf(p: *const Pardes, pane: *const Pane) ?usize {
+ for (p.panes, 0..) |slot, id| if (slot) |sp| {
+ if (sp == pane) return id;
+ };
+ return null;
+ }
+
+ /// Spaces to sit between the path and the commands, so the commands END
+ /// `tag_right_pad` columns short of the pane's edge — right-aligned, with
+ /// that many columns left free to type in.
+ ///
+ /// Real spaces rather than a second print at an offset, because the tag is
+ /// ONE buffer that tag_col, the mouse, the motions and the chord all index
+ /// by the same columns; two separately-positioned pieces would need a
+ /// column-to-offset map that none of them has. Being characters is also
+ /// what makes both paddings editable, which is the point: `:` lands at the
+ /// start of this gap, and there are `tag_right_pad` free columns past the
+ /// commands to type into.
+ ///
+ /// Zero once the tag has been touched. From then on the spaces are IN
+ /// tag_tail and belong to you — recomputing would both double them and
+ /// slide the commands sideways under your cursor as you type. So an
+ /// untouched tag reflows with the pane and an edited one stays put.
+ fn tagGap(p: *const Pardes, pane: *const Pane, used: usize) usize {
+ if (pane.tag_init) return 0;
+ const id = p.paneIdOf(pane) orelse return 0;
+ const w = p.rects[id].w;
+ if (w <= config.GUTTER) return 0;
+ const tw: usize = w - config.GUTTER;
+ return (tw -| config.tag_right_pad) -| used;
+ }
+
+ /// the tag exactly as it is rendered: prefix ++ gap ++ tail. THE text
+ /// tag_col and tag_anchor index, so the renderer, the mouse, the motions
+ /// and the chord all read the same bytes at the same columns.
fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
- return std.fmt.allocPrint(arena, "{s}{s}", .{ try p.tagPrefix(pane), curTail(pane) });
+ const prefix = try p.tagPrefix(pane);
+ const tail = curTail(pane);
+ const gap = p.tagGap(pane, prefix.len + tail.len);
+ const out = try arena.alloc(u8, prefix.len + gap + tail.len);
+ @memcpy(out[0..prefix.len], prefix);
+ @memset(out[prefix.len..][0..gap], ' ');
+ @memcpy(out[prefix.len + gap ..], tail);
+ return out;
+ }
+
+ /// Take the laid-out tail into the pane's own buffer, once, on first touch.
+ /// The gap comes along as ordinary characters — that is what hands the
+ /// padding to you to edit, and what freezes it against reflow from here on.
+ fn seedTail(p: *Pardes, pane: *Pane) void {
+ if (pane.tag_init) return;
+ const tail = curTail(pane);
+ const prefix_len = (p.tagPrefix(pane) catch return).len;
+ pane.tag_tail.appendNTimes(p.gpa, ' ', p.tagGap(pane, prefix_len + tail.len)) catch return;
+ pane.tag_tail.appendSlice(p.gpa, tail) catch return;
+ pane.tag_init = true;
}
/// focus the tag for editing, seeding the tail on first touch and parking
/// the cursor at `col` — a column of the RENDERED tag (see tag_col).
- /// NEGATIVE means the first EDITABLE column, which is where `:` and a
- /// tagline hop land: a place no click can name, so it needs no sentinel of
- /// its own and the callers need no prefix length.
+ /// NEGATIVE means the tail's first WORD, which is where `:` and a tagline
+ /// hop land: a place no click can name, so it needs no sentinel of its own
+ /// and the callers need no prefix length.
fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void {
const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len);
- if (!pane.tag_init) {
- pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return;
- pane.tag_init = true;
- }
+ p.seedTail(pane);
if (!pane.tag_edit) pane.tag_mode = pane.mode;
pane.tag_edit = true;
pane.tag_sel = false;
@@ -4545,7 +4597,15 @@ pub const Pardes = struct {
pane.mode = .insert;
pane.pending = 0;
const end = edit0 + @as(i32, @intCast(pane.tag_tail.items.len));
- pane.tag_col = @intCast(if (col < 0) edit0 else std.math.clamp(col, 0, end));
+ // Past the gap that right-aligns the builtins, not at the first
+ // editable column: `:` promises the tail's START, and the start of a
+ // run of layout spaces is not it. Landing there would cost `:w` its
+ // second keystroke — w would select the whitespace and execute nothing
+ // — and `:w` being the same two keys every time is the whole point of
+ // the door. The spaces stay editable; h and Left still walk into them.
+ const tail = pane.tag_tail.items;
+ const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len);
+ pane.tag_col = @intCast(if (col < 0) @min(edit0 + lead, end) else std.math.clamp(col, 0, end));
}
fn exitTagEdit(pane: *Pane) void {
@@ -5011,10 +5071,16 @@ pub const Pardes = struct {
// path. Shift-Esc needs a host that reports modifiers on Escape (the
// kitty keyboard protocol); where it doesn't it arrives as a plain
// Escape and still means what Escape always means.
- const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or
- hit(key, config.tty_toggle_alt);
+ const tty_alt = hit(key, config.tty_toggle_alt);
+ const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or tty_alt;
if (pane.isTerminal() and tty_toggle) {
if (pane.mode == .tty) {
+ // Shift-Esc IN tty is what Escape is in normal mode: Last,
+ // the pane you were in before this one. The pane keeps its tty
+ // mode, so coming back lands you in the program you left rather
+ // than in normal mode on top of it — and Ctrl-<the configured
+ // key> is still how you leave tty in place.
+ if (tty_alt) return p.runBuiltin(.Last, p.active, "", null);
pane.mode = .normal;
pane.pending = 0;
} else p.enterTty(p.active);
@@ -6387,7 +6453,7 @@ pub const Pardes = struct {
switch (semantic) {
.escape => {
pane.select = false;
- return p.runBuiltin(.Toggleterm, p.active, "", null);
+ return p.runBuiltin(.Last, p.active, "", null);
},
.goto => |go| switch (go.target) {
.file_start => {
@@ -6657,10 +6723,7 @@ pub const Pardes = struct {
fn startPipe(p: *Pardes, pane: *Pane) void {
const f = pane.file orelse return;
if (!output_pane.fileTraits(f.output).saves) return;
- if (!pane.tag_init) {
- pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return;
- pane.tag_init = true;
- }
+ p.seedTail(pane);
pane.pipe_at = @intCast(pane.tag_tail.items.len);
pane.tag_tail.appendSlice(p.gpa, config.pipe_marker) catch return;
pane.pipe_edit = true;
@@ -6876,10 +6939,7 @@ pub const Pardes = struct {
/// tail after `marker` — the existing modal one-line editor, visible while
/// typing, nothing that disappears. Enter/Esc are intercepted in handleKey.
pub fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void {
- if (!pane.tag_init) {
- pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return;
- pane.tag_init = true;
- }
+ p.seedTail(pane);
pane.search_at = @intCast(pane.tag_tail.items.len);
pane.tag_tail.appendSlice(p.gpa, marker) catch return;
pane.search_edit = true;
@@ -10572,7 +10632,7 @@ pub const Pardes = struct {
switch (semantic) {
.escape => {
pane.select = false;
- return p.runBuiltin(.Toggleterm, p.active, "", null);
+ return p.runBuiltin(.Last, p.active, "", null);
},
.move => |move| switch (move.motion) {
.down => p.movePdfRows(pane, 1, move.count),
@@ -11438,7 +11498,14 @@ pub const Pardes = struct {
if (output_pane.fileTraits(f.output).saves) legacy_file_pane_tail else legacy_pane_tail
else
legacy_pane_tail;
- if (std.mem.eql(u8, rest, curTail(pane)) or std.mem.eql(u8, rest, legacy)) return;
+ // A saved tag carries its layout gap, because the padding is real
+ // characters — and the pane it is restored into is very often a
+ // different width than the one it was dumped from. So compare what the
+ // tail SAYS and not where it sat: leading spaces are layout, never
+ // content, and a default that came back padded is still a default.
+ const said = std.mem.trimStart(u8, rest, " ");
+ if (std.mem.eql(u8, said, std.mem.trimStart(u8, curTail(pane), " ")) or
+ std.mem.eql(u8, said, std.mem.trimStart(u8, legacy, " "))) return;
pane.tag_tail.appendSlice(p.gpa, rest) catch return;
pane.tag_init = true;
}
@@ -11489,6 +11556,18 @@ pub const Pardes = struct {
/// 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.
+ /// The same PLACE, which is the one question the push rule asks: the same
+ /// live pane, and near enough within it that the cursor was only strolling.
+ /// Distance is a bodyful because that is what "you cannot see it any more"
+ /// means on a screen. A location with no line — an unpinned shell, whose
+ /// cursor belongs to the program — has no distance to be at.
+ fn samePlace(p: *const Pardes, a: Loc, b: Loc) bool {
+ if (a.pane != b.pane or a.serial != b.serial) return false;
+ if (a.line == 0 or b.line == 0) return true;
+ const body = @max(1, p.rects[b.pane].h -| BOX_H);
+ return @max(a.line, b.line) - @min(a.line, b.line) <= body;
+ }
+
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
@@ -11519,19 +11598,35 @@ pub const Pardes = struct {
.line = if (pane.pdfPage()) |page| @intCast(page + 1) else if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0,
.col = if (pane.pdfPage() != null) 0 else 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;
- }
+ if (p.njumps > 0 and p.samePlace(p.jumps[p.jcur], now)) {
+ p.jumps[p.jcur] = 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;
+ // ...and neither is a hop STRAIGHT BACK to the entry under this one.
+ // That is not two jumps, it is the same two places again: Esc between a
+ // doc and its shell, `SPC w k` / `SPC w j`, clicking back and forth.
+ // Appending would grow the stack by one per press until the ping-pong
+ // is the only thing it remembers — sixty-four presses and every older
+ // place is gone.
+ //
+ // So SWAP the two instead of appending, rather than the other obvious
+ // move of leaving them alone and walking jcur back down onto the older
+ // one. The stack has a second job: it is also the focus history, and
+ // prevFocus, Last and the look order all read it backwards on the
+ // promise that it "runs least-recent -> active". Parking jcur mid-array
+ // leaves the pane you are IN somewhere other than the top and quietly
+ // breaks all three — a look would resolve against the directory of the
+ // pane you just left before the one you are in. Swapping keeps the
+ // promise, keeps the length, and leaves Ctrl-o stepping out past both.
+ if (p.njumps >= 2 and p.samePlace(p.jumps[p.njumps - 2], now)) {
+ p.jumps[p.njumps - 2] = p.jumps[p.njumps - 1];
+ p.jumps[p.njumps - 1] = now;
+ p.jcur = p.njumps - 1;
+ return;
+ }
if (p.njumps == MAX_JUMPS) {
std.mem.copyForwards(Loc, p.jumps[0 .. MAX_JUMPS - 1], p.jumps[1..]);
p.njumps -= 1;
@@ -11694,6 +11789,7 @@ pub const Pardes = struct {
s.cursor = null;
s.nimages = 0;
const chrome = p.chromeTheme();
+ const th = p.theme(); // the message row paints in the editor's colours
// gaps between panes read as chrome (scrollbar track), not raw default
s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.scroll_track } });
@@ -11740,16 +11836,45 @@ pub const Pardes = struct {
pane.tag_tail.items[@min(at, pane.tag_tail.items.len)..]
else
pane.msg[0..pane.msg_len];
- if (text.len == 0) continue;
+ // The pending SPC path is the third thing that wants this row, and
+ // it can want it while the row is otherwise empty.
+ const leader_here = id == p.active and p.leader_on;
+ if (text.len == 0 and !leader_here) continue;
const tx = r.x + config.GUTTER;
const tw = r.w - config.GUTTER;
const row = r.y + r.h - 1;
+ // In the EDITOR's colours, not the tag bar's: this row is the one
+ // place the program talks back to you about the buffer you are in,
+ // and it reads as part of that buffer rather than as another strip
+ // of chrome. th and not chrome for the same reason a selection
+ // uses th — it is not attached to any geometry, so it arrives with
+ // the theme instead of sliding in over the chrome animation.
+ const msg_style: CellStyle = .{
+ .fg = if (th.fg) |c| .{ .rgb = c } else .default,
+ .bg = if (th.bg) |c| .{ .rgb = c } else .default,
+ };
// the WHOLE row, the way the tagline fills its own before printing:
// a message is a section and not a stamp, and print writes only the
// cells it needs — so without the fill the body row shows through
// to the right of a short message and reads as one garbled line.
- s.fill(tx, row, tw, 1, .{ .bg = .{ .rgb = chrome.tag_bg } });
- _ = s.print(tx, row, tw, text, .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg } });
+ s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg });
+ if (text.len > 0) _ = s.print(tx, row, tw, text, msg_style);
+ // The pending SPC leader path, right-aligned. It used to sit on the
+ // active pane's tagline, where it had to fight the builtins in the
+ // tail for the same columns; down here it is beside the rest of the
+ // transient state, and printed AFTER the message so a long one
+ // loses its last columns rather than hiding what you are typing.
+ if (leader_here) {
+ var ibuf: [16]u8 = @splat(' ');
+ @memcpy(ibuf[1..4], "SPC");
+ var iw: usize = 4;
+ for (p.leader_keys[0..p.leader_n]) |ch| {
+ ibuf[iw + 1] = ch;
+ iw += 2;
+ }
+ const w: u16 = @intCast(iw);
+ if (w < tw) _ = s.print(tx + tw - w, row, w, ibuf[0..iw], msg_style);
+ }
// ...and the cursor follows the text it edits. tag_col is a
// rendered-tag column, so the prompt's own column is it minus where
// the marker starts; a cursor LEFT of that is still over the part
@@ -11970,23 +12095,6 @@ pub const Pardes = struct {
else
tag.len;
_ = s.print(tx, r.y, tw, tag[0..tag_cut], tag_style);
- // pending SPC leader path, on the ACTIVE pane's tag at its RIGHT edge:
- // the same rule as the `/` input — state you can see, nothing that
- // disappears — but right-aligned, so it never collides with the
- // builtins in the tail. Printed AFTER the tag, so a tag long enough to
- // reach it loses its last columns instead: what you are about to press
- // beats the path, which is still readable in the prefix.
- if (active and p.leader_on) {
- var ibuf: [16]u8 = @splat(' ');
- @memcpy(ibuf[1..4], "SPC");
- var iw: usize = 4;
- for (p.leader_keys[0..p.leader_n]) |ch| {
- ibuf[iw + 1] = ch;
- iw += 2;
- }
- const w: u16 = @intCast(iw);
- if (w < tw) _ = s.print(tx + tw - w, r.y, w, ibuf[0..iw], tag_style);
- }
// tag char selection highlight (helix v/x, or a tagline sweep),
// inclusive [lo, hi] — rendered-tag columns, so no conversion.
//
@@ -12563,3 +12671,117 @@ pub const Pardes = struct {
return term_pane.bodyText(arena, pane);
}
};
+
+test "Esc alternates between two panes of the SAME kind" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ // An ABSOLUTE boot path, the way main.zig resolves argv: a file pane's
+ // directory is its path's dirname, and a relative one leaves nothing for
+ // the look below to resolve against.
+ var cwdbuf: [4096]u8 = undefined;
+ const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return)));
+ var pathbuf: [4096]u8 = undefined;
+ const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd});
+
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 40, .file = boot });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 40 } });
+ const a = p.active;
+ // A second FILE. This is the case the doc<->terminal hop Esc used to run
+ // could not do AT ALL: both panes are docs, so it had nothing of "the other
+ // kind" to reach and Esc did nothing.
+ p.runBuiltin(.Look, a, "", "build.zig");
+ p.sync();
+ const b = p.active;
+ try std.testing.expect(b != a);
+ try std.testing.expect(!p.panes[a].?.isTerminal() and !p.panes[b].?.isTerminal());
+
+ for (0..6) |_| {
+ const was = p.active;
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expectEqual(if (was == a) b else a, p.active);
+ }
+}
+
+test "Shift-Esc in tty hops to the doc and leaves the shell in tty" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc
+ const shell = p.active;
+ const shell_pane = p.panes[shell].?;
+ try std.testing.expect(shell_pane.isTerminal());
+
+ // Shift-Esc still gets you IN, exactly as the configured Ctrl-key does.
+ const shift_esc: Key = .{ .cp = Key.escape, .shift = true };
+ p.update(.{ .key = shift_esc });
+ try std.testing.expectEqual(Mode.tty, shell_pane.mode);
+
+ // ...and out of tty it is Escape-in-normal-mode instead of a toggle: the
+ // doc takes focus and the shell KEEPS its tty mode, so coming back lands
+ // in the program you left rather than in normal mode on top of it.
+ p.update(.{ .key = shift_esc });
+ try std.testing.expect(p.active != shell);
+ try std.testing.expect(!p.panes[p.active].?.isTerminal());
+ try std.testing.expectEqual(Mode.tty, shell_pane.mode);
+
+ // The configured Ctrl-key is now the only thing that leaves tty in place.
+ p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // back to the shell
+ try std.testing.expectEqual(shell, p.active);
+ p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } });
+ try std.testing.expectEqual(Mode.normal, shell_pane.mode);
+ try std.testing.expectEqual(shell, p.active);
+}
+
+test "hopping between two panes does not grow the jump stack" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc
+ const doc = 0;
+ const shell = p.active;
+ try std.testing.expect(shell != doc);
+ const depth = p.njumps;
+
+ // Esc is Last — the everyday two-pane hop. Each press used to
+ // append, so sixty-four of them left the stack remembering nothing else.
+ for (0..8) |_| {
+ const was = p.active;
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(p.active != was);
+ }
+ try std.testing.expectEqual(depth, p.njumps);
+
+ // ...and so are the other two ways to switch panes.
+ for (0..8) |_| {
+ const want = if (p.active == doc) shell else doc;
+ const dir: u21 = if (p.active == doc) 'j' else 'k';
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'w' } });
+ p.update(.{ .key = .{ .cp = dir } });
+ try std.testing.expectEqual(want, p.active);
+ }
+ try std.testing.expectEqual(depth, p.njumps);
+
+ for (0..8) |_| {
+ const other = if (p.active == doc) shell else doc;
+ const r = p.rects[other];
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + config.GUTTER + 1, .row = r.y + BOX_H + 1 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + config.GUTTER + 1, .row = r.y + BOX_H + 1 } });
+ try std.testing.expectEqual(other, p.active);
+ }
+ try std.testing.expectEqual(depth, p.njumps);
+
+ // The collapse must not eat history: Back still walks OUT of the ping-pong
+ // to the place before it, which is what makes the entry a cursor move
+ // rather than a deletion.
+ const before = p.active;
+ p.runBuiltin(.Back, p.active, "", null);
+ p.sync();
+ try std.testing.expect(p.active != before);
+ try std.testing.expectEqual(depth, p.njumps);
+}
diff --git a/src/tutor.txt b/src/tutor.txt
index 1d01d626..1d51a4ac 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -227,12 +227,14 @@
TTY ($) the REAL shell — prompts + typed input shown, and keys
go straight to the pty as terminal input.
- ESC: insert -> normal; from body NORMAL it hops between the last
- document and last terminal (the same Toggleterm as SPC w t).
+ ESC: insert -> normal; from body NORMAL it hops to the pane you
+ were in before this one, whichever it was (the same Last as
+ SPC j j) — so held down it alternates between two panes.
In raw TTY it still goes to the program.
- Ctrl-b: toggles TTY on a terminal by default; SHIFT-ESC is the same
- toggle, where the host reports modifiers on Escape. Those are
- the ONLY ways in or out.
+ Ctrl-b: toggles TTY on a terminal, and is the only way back OUT of it.
+ SHIFT-ESC gets you IN, and once in TTY it does what ESC does in
+ normal mode — hops away, leaving this pane in TTY so coming
+ back lands you in the program you left.
Use `pardes --tty-toggle=g` (or another letter) to make Ctrl-g
the toggle instead. Entering is tty-native: if the shell is at
a prompt and your modal cursor sits on the input line, it first
@@ -494,8 +496,9 @@
Ctrl-w h/j/k/l focus the pane left/down/up/right (SPC w h/j/k/l
does the same; Ctrl-w also reaches a tty pane,
where SPC belongs to the shell)
- Esc hop between the last document and last terminal
- (body normal mode; the same builtin as SPC w t)
+ Esc hop to the pane you were in before this one, so
+ held down it alternates between two (body normal
+ mode; the same builtin as SPC j j)
Alt-n new terminal below the active one
Alt-c move the active terminal into a fresh column
@@ -523,8 +526,9 @@
letters join the matcher's glyph set
SPC s d / s r Dump / Restore the session
SPC w h/j/k/l Left/Down/Up/Right: focus the pane that way
- SPC w t Toggleterm: hop between the last document you
- looked at and the last terminal, and back
+ SPC j o / j i Back / Forward: walk the jump history
+ SPC j j Last: the pane you were in before this one — what
+ body-normal ESC runs, so it alternates
`?` works at ANY depth: SPC ? lists everything, SPC h ? lists only
what the "h" group holds. Help writes into a "+Help" OUTPUT BUFFER,
@@ -564,7 +568,7 @@
SPC = the leader: a key path runs a builtin (SPC ? lists
them; SPC k Kill, SPC d Del, SPC f s Save, SPC f f Find,
SPC f n New,
- SPC w hjkl focus, SPC w t file <-> terminal)
+ SPC w hjkl focus, SPC j j the pane before this one)
vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars.
vs Vim: no verb+noun (dw); motions only move; body-normal Esc hops focus.