From 281b1eb2d5249caece4ac69a7d975cb01d2e7a89 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 12:21:41 -0300 Subject: Replace a stillborn placeholder pane when a pane joins its column, and focus the pane a directional Del grows A pane that joins a column whose only other pane is an untouched empty scratch (Newcol's, or the stand-in a closed column leaves) takes the column whole. Checked at the end of the step, since the pane that asked for the joiner is often the placeholder itself. Del k|j focuses the pane that took the rows. Co-Authored-By: Claude Opus 5.5 --- src/builtins.zig | 4 ++- src/pardes.zig | 70 ++++++++++++++++++++++++++++++++++++++ test/snapshots/layout-close.golden | 4 +-- test/snapshots/layout-close.snap | 6 +++- test/snapshots/leader.golden | 2 +- 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/builtins.zig b/src/builtins.zig index a5dbf6cd..ac336ce0 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -517,7 +517,9 @@ pub const Del = struct { if (c.pane.msg_announcement) c.pane.msg_len = 0; return; } - c.p.removePane(c.id, to) catch |err| c.p.reportError(c.id, "close", err); + c.p.removePane(c.id, to) catch |err| return c.p.reportError(c.id, "close", err); + // Del k|j is a direction: focus lands where the rows went. + if (to) |gainer| c.p.active = gainer; } }; diff --git a/src/pardes.zig b/src/pardes.zig index 4335ffde..ca09caa1 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -6358,6 +6358,9 @@ pub const Pardes = struct { look_walk_owner: ?u32 = null, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, + /// The pane (by serial) that joined a column this step; `replaceStillborn` + /// checks it once the step is over and nothing holds the column's panes. + stillborn_joiner: ?u32 = null, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, locations_config: locations_config.Config = .{}, locations_cache: @import("locations_cache.zig").Cache = .{}, @@ -7753,6 +7756,7 @@ pub const Pardes = struct { } pub fn update(p: *Pardes, ev: Event) void { + defer p.replaceStillborn(); // A tick with nothing animating is the one event that cannot change // the screen. p.needs_frame = p.needs_frame or ev != .tick; @@ -9009,6 +9013,7 @@ pub const Pardes = struct { layout.insert(p, f.col, f.idx + 1, free); layout.splitBelow(p, src, nt); p.active = free; + p.stillborn_joiner = nt.serial; return; } for (config.jump_keys) |jk| { @@ -12569,6 +12574,7 @@ pub const Pardes = struct { }, .move => |d| { layout.movePane(p, d.id, d.cur_x, d.cur_y); + if (p.panes[d.id]) |mp| p.stillborn_joiner = mp.serial; // a file moved into the left column evicts a lone unused shell if (p.panes[d.id]) |mt| if (mt.file != null) { if (layout.findPane(p, d.id)) |f| if (f.col == 0) @@ -12810,6 +12816,7 @@ pub const Pardes = struct { layout.insert(p, f.col, f.idx + 1, free); layout.splitBelow(p, parent, nt); p.active = free; + p.stillborn_joiner = nt.serial; } /// when a doc lands in `col`, a lone pristine shell there is clutter — drop @@ -12838,6 +12845,31 @@ pub const Pardes = struct { if (p.active == tty_id) p.active = keep_id; } + /// A pane that just joined a column whose only other pane is stillborn — + /// an empty scratch nobody typed into, body or tag (what Newcol makes and + /// what a closed column's last pane leaves behind) — takes the column + /// whole: the placeholder was only holding the space for it. Run at the + /// end of a step: the pane that asked for the joiner is often the + /// placeholder itself, and its caller is still holding it. + fn replaceStillborn(p: *Pardes) void { + const serial = p.stillborn_joiner orelse return; + p.stillborn_joiner = null; + const new_id = p.paneBySerial(serial) orelse return; + const f = layout.findPane(p, new_id) orelse return; + if (p.col_n[f.col] != 2) return; + const old_id = p.col_panes[f.col][1 - f.idx]; + const old = p.panes[old_id] orelse return; + const file = old.file orelse return; + const out = file.output orelse return; + if (out.from != .cmd or out.from.cmd != .New) return; + if (file.revision != 0 or file.content.len != 0) return; + if (old.tag_init and !std.mem.eql(u8, old.tagSlice(), defaultTail(old))) return; + const was_active = p.active == old_id; + layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows + p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err); + if (was_active) p.active = new_id; + } + fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { for (p.panes, 0..) |slot, i| if (slot) |tt| { if (!std.mem.eql(u8, tt.cwdSlice(), dir)) continue; @@ -13183,6 +13215,7 @@ pub const Pardes = struct { layout.insert(p, f.col, f.idx + 1, free); layout.splitBelow(p, src, nt); p.active = free; + p.stillborn_joiner = nt.serial; }, .file => |target| { if (comptime pdf_enabled) if (target.kind == .pdf) { @@ -13339,6 +13372,7 @@ pub const Pardes = struct { } pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { + p.stillborn_joiner = nt.serial; const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) { @@ -15567,6 +15601,40 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch for (pane.sel) |selection| try std.testing.expect(selection.state == .none); } +test "a stillborn pane gives its column to the first pane that joins it" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); + defer p.deinit(); + try std.testing.expect(p.executeBuiltinLine(0, "Newcol")); + const blank = p.active; + p.sync(); // Tty splits by the rects a frame has laid out + try std.testing.expect(p.executeBuiltinLine(blank, "Tty")); + const shell = p.active; + p.update(.tick); // the step ends: the placeholder goes + try std.testing.expect(p.panes[blank] == null); + try std.testing.expectEqual(@as(usize, 1), p.col_n[layout.findPane(p, shell).?.col]); + try std.testing.expectEqual(shell, p.active); + + // Typed into, body or tag, it is a pane like any other. + try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); + const typed = p.active; + p.sync(); + for ("ix") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); + try std.testing.expect(p.executeBuiltinLine(typed, "Tty")); + p.update(.tick); + try std.testing.expect(p.panes[typed] != null); + try std.testing.expectEqual(@as(usize, 2), p.col_n[layout.findPane(p, typed).?.col]); + + try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); + const tagged = p.active; + p.sync(); + p.seedTail(p.panes[tagged].?); + try std.testing.expect(p.panes[tagged].?.appendTag(" make")); + try std.testing.expect(p.executeBuiltinLine(tagged, "Tty")); + p.update(.tick); + try std.testing.expect(p.panes[tagged] != null); +} + test "Del from a key asks which neighbour takes the rows; nothing else asks" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 40 }); @@ -15595,6 +15663,7 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" { try std.testing.expect(p.panes[mid] == null); try std.testing.expectEqual(h[0], p.rects[top].h); try std.testing.expectEqual(h[1] + h[2], p.rects[low].h); + try std.testing.expectEqual(low, p.active); // focus follows the rows // Esc takes the question back and the pane stays; so does any other key, // and so does a click. @@ -15628,6 +15697,7 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" { p.sync(); try std.testing.expect(p.panes[again] == null); try std.testing.expectEqual(h[0] + h[1], p.rects[top].h); + try std.testing.expectEqual(top, p.active); try std.testing.expectEqual(h[2], p.rects[low].h); // Bare Del from anything but a key closes at once and gives the rows up, diff --git a/test/snapshots/layout-close.golden b/test/snapshots/layout-close.golden index c9e1243a..a49ae97e 100644 --- a/test/snapshots/layout-close.golden +++ b/test/snapshots/layout-close.golden @@ -35,11 +35,11 @@ |15: |16: |22: /tmp/pardes-snap/layout-close/cw -== snap del grid=140x41 cursor=39,5 +== snap del grid=140x41 cursor=4,8 |6: |7: |8: -== snap exit grid=140x41 cursor=39,5 +== snap exit grid=140x41 cursor=39,8 |10: |11: |12: diff --git a/test/snapshots/layout-close.snap b/test/snapshots/layout-close.snap index f459ffb4..50a11cc3 100644 --- a/test/snapshots/layout-close.snap +++ b/test/snapshots/layout-close.snap @@ -25,7 +25,8 @@ snap altc # Del the 2nd pane: source-local column insertion makes this column too narrow # to expose its tag words, so focus its body and invoke the same pane-scoped # builtin by chord. With panes above and below, the chord asks which one -# takes the rows: k, the pane above. The other panes keep their rows. +# takes the rows: k, the pane above, which also takes the focus. The other +# panes keep their rows. press left 5 9 release left 5 9 stable 400 5000 @@ -36,6 +37,9 @@ snap del # exit the (now) 2nd shell through its real tty: pty EOF, again only the pane # above absorbs — and focus falls back to the # pane that had it before this one (the moved-out column), not to slot 0 +press left 40 9 +release left 40 9 +stable 400 5000 press left 5 15 release left 5 15 stable 400 5000 diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden index 3be0846b..cc420f46 100644 --- a/test/snapshots/leader.golden +++ b/test/snapshots/leader.golden @@ -28,7 +28,7 @@ |20: 8 |21: 9 |23: 1 pardes builtins under SPC h Tutor -== snap deleted grid=100x41 cursor=7,23 +== snap deleted grid=100x41 cursor=7,3 |12: |13: |14: -- cgit v1.3