diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-08 10:44:56 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 198889ab8f78b12c9881a20309a792e419ba1386 (patch) | |
| tree | 264bb756389f4c4cbaf68a6a2aabf3d602450a36 /src | |
| parent | c3c8bbd8d8add99088c774c54bc1acf1e39ec895 (diff) | |
| download | pardes-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.zig | 44 | ||||
| -rw-r--r-- | src/config.zig | 21 | ||||
| -rw-r--r-- | src/pardes.zig | 336 | ||||
| -rw-r--r-- | src/tutor.txt | 24 |
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. |
