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/pardes.zig | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) (limited to 'src/pardes.zig') 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, -- cgit v1.3