summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-24 10:06:08 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit3f6ad1bc30570348297ae4aa6d1fd676de229a09 (patch)
treeb52037635072e90e0ff20c80a15c5d18c578d445
parent82c4f9e7212f569d6343dd03ddbab3520a784993 (diff)
downloadpardes-3f6ad1bc30570348297ae4aa6d1fd676de229a09.tar.gz
pardes-3f6ad1bc30570348297ae4aa6d1fd676de229a09.zip
Ask from the keyboard which neighbour a closed pane's rows go to
Del takes a side: `Del k` gives the closed pane's rows to the nearest expanded pane above it, `Del j` to the one below, each falling back to the other side when it has none. DelAbove and DelBelow are those two lines, with no path of their own under SPC. A bare Del started from a key -- SPC d, Enter on the tag word, a row run from an output buffer -- on a pane with expanded panes both above and below asks instead of guessing. The question is a prompt like Save's or a search's (Pane.Prompt.del_side), so it is painted on the pane's notice band by the same path, and the next key answers it before any mode sees it: k or Up, j or Down, anything else keeps the pane, as does a click. Only a key press sets Pardes.can_ask, so a click, a 9P ctl or event write, a startup line, a restore and a shell exiting all close the pane at once, the rows going where layout.absorbVWeight has always sent them. A collapsed pane is not asked about (it has only a tag row to give), and collapsed neighbours are passed over (layout.expandedNeighbor, which Collapse now uses too). removePane and absorbVWeight take the recipient; every other caller passes null. Three scripts that closed a middle pane with SPC d answer k, which is where the rows went before, and their goldens are unchanged. delask.snap covers the question, Esc, j, a clicked DelBelow and a clicked Del. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
-rw-r--r--docs/tags.md11
-rw-r--r--src/CHANGELOG.md8
-rw-r--r--src/builtins.zig43
-rw-r--r--src/config.zig8
-rw-r--r--src/gui/gui.zig2
-rw-r--r--src/layout.zig29
-rw-r--r--src/ninep/screen.zig2
-rw-r--r--src/ninep/tree.zig2
-rw-r--r--src/panes.zig6
-rw-r--r--src/pardes.zig136
-rw-r--r--src/tutor.txt5
-rw-r--r--test/perf.zig2
-rw-r--r--test/snapshots/builtins.snap2
-rw-r--r--test/snapshots/delask.golden56
-rw-r--r--test/snapshots/delask.snap71
-rw-r--r--test/snapshots/layout-close.snap4
-rw-r--r--test/snapshots/leader.snap2
-rw-r--r--test/snapshots/lspdebug.snap3
18 files changed, 359 insertions, 33 deletions
diff --git a/docs/tags.md b/docs/tags.md
index 8b1d3bf6..f299c859 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -33,6 +33,17 @@ Its text and terminal process are kept;
the command stays available in the visible tag. Every default pane tag includes
`Collapse`, including file, terminal, image, and PDF panes.
+`Del` closes a pane and gives its rows to one neighbor; the rest of the
+column keeps its heights. `Del k` (or `DelAbove`) gives them to the nearest
+expanded pane above, `Del j` (or `DelBelow`) to the one below, each falling
+back to the other side. Bare `Del` from the keyboard (`SPC d`, or Enter on
+the word) on a pane with expanded panes both above and below asks on the
+pane's notice band: `k` or Up gives the rows above, `j` or Down below, and
+any other key keeps the pane. A click, a 9P write or a startup line never
+asks; there bare `Del` gives the rows to the nearest document above, as it
+always has. A collapsed pane is never asked about, and collapsed neighbors
+are passed over: they only keep a weight for later.
+
Left-click a tag to place its caret; drag to select text, then type to
replace it. Arrow keys, Home, End, Backspace and Delete edit the line.
Tags reveal the caret horizontally when text is wider than their column.
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 0083f97f..f8451baf 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -2,6 +2,14 @@
## 0.0.3
+- `Del` takes a side. `Del k` and `DelAbove` give the closed pane's rows to
+ the expanded pane above it, `Del j` and `DelBelow` to the one below. A bare
+ `Del` from the keyboard on a pane with panes both above and below asks on
+ its notice band, and one key answers; a click, a 9P write or a script
+ still closes it at once, rows going up as before.
+- A prompt (Save's path, a search, a pipe) is drawn in the tagline's pitch
+ like the other notices instead of spread one glyph to a body cell, and in
+ the SDL shell every notice chip is ruled off from the body like a tagline.
- Answer 9P on the connection's task instead of the editor's loop. The core
is single-threaded and `pardes.turn` says whose turn it is with it: the
editor's by default, given up while it waits for input and while a step of
diff --git a/src/builtins.zig b/src/builtins.zig
index 30a5a50f..146789b0 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -460,9 +460,38 @@ pub const Newcol = struct {
}
};
+/// Close the pane. `Del k` gives its rows to the open pane above it and
+/// `Del j` to the one below, each falling back to the other side.
pub const Del = struct {
+ pub const takes_arg = true;
pub fn run(c: Ctx) void {
- c.p.removePane(c.id) catch |err| c.p.reportError(c.id, "close", err);
+ const side = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ const at = layout.findPane(c.p, c.id);
+ const above = if (at) |f| layout.expandedNeighbor(c.p, f.col, f.idx, .above) else null;
+ const below = if (at) |f| layout.expandedNeighbor(c.p, f.col, f.idx, .below) else null;
+ const to: ?usize = if (std.mem.eql(u8, side, "k"))
+ above orelse below
+ else if (std.mem.eql(u8, side, "j"))
+ below orelse above
+ else if (side.len > 0)
+ return c.p.reportError(c.id, "Del k|j", error.InvalidArgument)
+ else
+ null;
+ // Someone at the keyboard with a real choice to make is asked, on the
+ // pane's notice band; a click, a 9P write or a script closes it the
+ // way it always has. A folded pane gives back only its tag row, and a
+ // pane with a prompt open closes as it did rather than trade the
+ // prompt for a question.
+ if (side.len == 0 and c.p.can_ask and above != null and below != null and
+ !c.pane.collapsed and c.pane.prompt == .none)
+ {
+ c.pane.prompt = .del_side;
+ c.p.active = c.id;
+ // The question says Del already.
+ 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);
}
};
@@ -500,6 +529,18 @@ pub const Delcol = struct {
}
};
+pub const DelAbove = struct {
+ pub fn run(c: Ctx) void {
+ Del.run(.{ .p = c.p, .pane = c.pane, .id = c.id, .txt = c.txt, .arg = "k" });
+ }
+};
+
+pub const DelBelow = struct {
+ pub fn run(c: Ctx) void {
+ Del.run(.{ .p = c.p, .pane = c.pane, .id = c.id, .txt = c.txt, .arg = "j" });
+ }
+};
+
/// A shell in the calling pane's directory, raw from the first frame. On every
/// pane's tagline: the fast path from wherever you are to a prompt there.
pub const Tty = struct {
diff --git a/src/config.zig b/src/config.zig
index 527c3752..4e46b1d8 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -42,6 +42,8 @@ pub const leader_path = paths: {
.Supertypes = "lt",
.Subtypes = "lT",
.Del = "d",
+ .DelAbove = null,
+ .DelBelow = null,
.Filter = null,
.Mode = null,
.Togglettymode = null,
@@ -269,6 +271,12 @@ pub const pipe_marker_insert = " !";
pub const pipe_marker_append = " !+";
pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }};
+// Del from the keyboard on a pane with open panes both above and below it
+// asks which of them takes its rows. One key answers; any other keeps the pane.
+pub const del_question = "Del: give space to k above j below";
+pub const del_above: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } };
+pub const del_below: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } };
+
pub const wheel_rows: i32 = 1;
pub const wheel_cols: i32 = 4;
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 92ded62c..acb8d729 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -1387,7 +1387,7 @@ test "GUI PTY deletion joins an idle reader and retains only its owned child for
const child = try host_io.forkShell(null, 0, &rcs, "/bin/sh", "", 24, 80, null);
ptys[0] = .{ .fd = child.file.handle, .pid = child.pid, .serial = core.panes[0].?.serial };
try spawnReader(gpa, &ptys[0].?, 0, 1, &queue);
- try core.removePane(0);
+ try core.removePane(0, null);
shell.reconcilePtys();
try std.testing.expect(queue.readers[0] == null);
try std.testing.expectEqual(@as(usize, 0), queue.waiting);
diff --git a/src/layout.zig b/src/layout.zig
index 3637f30e..116a64b8 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -995,7 +995,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
if (needs_rebase) old_weight *= 2;
if (id == source_id) {
if (p.col_n[source_col] <= 1) return false;
- absorbVWeight(p, id);
+ absorbVWeight(p, id, null);
removePane(p, id);
} else if (findPane(p, id) != null) return false;
@@ -1033,13 +1033,16 @@ pub fn snapColWeights(p: *Pardes, c: usize) void {
}
}
-pub fn absorbVWeight(p: *Pardes, id: usize) void {
+/// Hand a closing pane's rows to `to`. With none named they go up: past any
+/// results listing to the nearest pane above that is not one, or to the
+/// pane below when the closing one is first in its column.
+pub fn absorbVWeight(p: *Pardes, id: usize, to: ?usize) void {
const f = findPane(p, id) orelse return;
if (p.col_n[f.col] <= 1) return;
snapColWeights(p, f.col);
- var sib = if (f.idx > 0) p.col_panes[f.col][f.idx - 1] else p.col_panes[f.col][f.idx + 1];
+ var sib = to orelse if (f.idx > 0) p.col_panes[f.col][f.idx - 1] else p.col_panes[f.col][f.idx + 1];
var k = f.idx;
- while (k > 0) : (k -= 1) {
+ while (to == null and k > 0) : (k -= 1) {
sib = p.col_panes[f.col][k - 1];
if (p.panes[sib]) |pp| if (if (pp.file) |ff| panes.Output.fileTraits(ff.output).doc else true) break;
}
@@ -1119,7 +1122,7 @@ pub fn toggleCollapse(p: *Pardes, id: usize) void {
// Convert only visible panes to row weights. The toggle then changes one
// pair, rather than letting the column redistribute the released space.
snapColWeights(p, at.col);
- const neighbor = collapseNeighbor(p, at.col, at.idx);
+ const neighbor = expandedNeighbor(p, at.col, at.idx, .above) orelse expandedNeighbor(p, at.col, at.idx, .below);
if (was_collapsed) {
if (neighbor) |other| {
const wanted: u16 = @intFromFloat(@min(@as(f32, @floatFromInt(std.math.maxInt(u16))), @max(1, @round(remembered_height))));
@@ -1139,15 +1142,17 @@ pub fn toggleCollapse(p: *Pardes, id: usize) void {
compute(p);
}
-fn collapseNeighbor(p: *Pardes, col: usize, index: usize) ?usize {
- var above = index;
- while (above > 0) {
- above -= 1;
- const id = p.col_panes[col][above];
+/// The nearest pane on one side of `index` in its column that is not folded
+/// to its tag: the one Collapse lends rows to and Del k / Del j gives them
+/// to. A folded pane only keeps its weight for later, so rows handed to it
+/// would spread over the whole column instead.
+pub fn expandedNeighbor(p: *const Pardes, col: usize, index: usize, side: enum { above, below }) ?usize {
+ var k = index;
+ while (if (side == .above) k > 0 else k + 1 < p.col_n[col]) {
+ k = if (side == .above) k - 1 else k + 1;
+ const id = p.col_panes[col][k];
if (p.panes[id]) |pane| if (!pane.collapsed) return id;
}
- for (p.col_panes[col][index + 1 .. p.col_n[col]]) |id|
- if (p.panes[id]) |pane| if (!pane.collapsed) return id;
return null;
}
diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig
index 2684aa41..60fb5951 100644
--- a/src/ninep/screen.zig
+++ b/src/ninep/screen.zig
@@ -237,7 +237,7 @@ test "terminal body snapshots are lazy bounded and released after the pane close
try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen_node, .handle = handles[0], .size = 32 }).errno());
_ = call(p, .{ .tag = 7, .op = .release, .node = screen_node, .handle = handles[0] });
try testing.expectEqual(node, p.fs.snapshots[handles[0] - 1].node);
- try p.removePane(0);
+ try p.removePane(0, null);
for (handles) |opened_handle| _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = opened_handle });
for (p.fs.snapshots) |snapshot| {
try testing.expectEqual(@as(u64, 0), snapshot.node);
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index b13b1472..aca8d109 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -529,7 +529,7 @@ fn remove(p: *Pardes, req: Req) Reply {
};
if (t.file != .dir) return Reply.fail(req.tag, E.PERM);
const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
- p.removePane(id) catch return Reply.fail(req.tag, E.IO);
+ p.removePane(id, null) catch return Reply.fail(req.tag, E.IO);
return .{ .tag = req.tag };
}
diff --git a/src/panes.zig b/src/panes.zig
index 0b565676..0b4204aa 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -94,6 +94,9 @@ pub const Pane = struct {
pipe: struct { at: u16, how: modal.Normal.PipeBehavior },
/// Save on a scratch buffer or a terminal: the tail is a path to write to.
save: u16,
+ /// Del from the keyboard with open panes both above and below: which
+ /// one takes the rows. Nothing is typed; one key answers.
+ del_side,
};
pub const Cwd = union(enum) { none, inherited: *Pane, owned: []u8 };
@@ -338,7 +341,7 @@ pub const Pane = struct {
}
pub fn promptAt(p: *const Pane) ?u16 {
return switch (p.prompt) {
- .none => null,
+ .none, .del_side => null,
.search, .save => |at| at,
.pipe => |pipe| pipe.at,
};
@@ -347,6 +350,7 @@ pub const Pane = struct {
/// What an open prompt shows on its notice band: the one answer every
/// painter of the band reads.
pub fn promptText(p: *const Pane) ?[]const u8 {
+ if (p.prompt == .del_side) return config.del_question;
const at = p.promptAt() orelse return null;
return p.tagSlice()[@min(at, p.tag_tail_len)..];
}
diff --git a/src/pardes.zig b/src/pardes.zig
index 29f937d7..42729d50 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -2104,7 +2104,7 @@ test "New opens an empty scratch below the caller, inheriting its directory" {
try std.testing.expectEqualStrings("/tmp/pardes-scratch-dir", Pardes.paneDir(np));
p.setCwd(source, "/tmp/pardes-moved");
try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np));
- try p.removePane(source);
+ try p.removePane(source, null);
p.sync();
try std.testing.expect(np.cwd == .owned);
try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np));
@@ -2176,7 +2176,7 @@ test "owned cwd close copies are transactional across inherited chains" {
const active = p.active;
const live_bytes = allocator.allocated_bytes - allocator.freed_bytes;
allocator.fail_index = allocator.alloc_index + failed_copy;
- try std.testing.expectError(error.OutOfMemory, p.removePane(0));
+ try std.testing.expectError(error.OutOfMemory, p.removePane(0, null));
try std.testing.expectEqual(parent, p.panes[0].?);
try std.testing.expectEqual(parent, first.cwd.inherited);
try std.testing.expectEqual(parent, second.cwd.inherited);
@@ -2189,12 +2189,12 @@ test "owned cwd close copies are transactional across inherited chains" {
try std.testing.expectEqual(live_bytes, allocator.allocated_bytes - allocator.freed_bytes);
allocator.fail_index = std.math.maxInt(usize);
- try p.removePane(0);
+ try p.removePane(0, null);
try std.testing.expect(p.panes[0] == null);
try std.testing.expect(first.cwd == .owned and second.cwd == .owned);
try std.testing.expectEqual(first, grandchild.cwd.inherited);
try std.testing.expect(first.cwdSlice().ptr != second.cwdSlice().ptr);
- try p.removePane(first_id);
+ try p.removePane(first_id, null);
try std.testing.expect(grandchild.cwd == .owned);
p.sync();
p.sync();
@@ -5539,7 +5539,7 @@ test "vertical close samples only a canonical baseline containing that pane" {
_ = frame.reset(.retain_capacity);
const opening = try p.render(frame.allocator());
p.acknowledgePanelPresentation(opening.panelTracks());
- try p.removePane(1);
+ try p.removePane(1, null);
p.sync();
try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len);
@@ -5565,7 +5565,7 @@ test "vertical close samples only a canonical baseline containing that pane" {
try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len);
p.acknowledgePanelPresentation(canonical.panelTracks());
const serial = p.panes[1].?.serial;
- try p.removePane(1);
+ try p.removePane(1, null);
p.sync();
try std.testing.expectEqual(@as(usize, 1), p.presentation.closing_len);
@@ -6387,6 +6387,10 @@ pub const Pardes = struct {
chord_arg: ?[]u8 = null,
/// how many `execute` calls are on the stack — see max_exec_depth
exec_depth: u8 = 0,
+ /// A key press is being handled: someone is at the keyboard to answer,
+ /// so a builtin may ask rather than guess. Nothing else sets it -- not
+ /// a click, a 9P write, a startup line or a pane's shell exiting.
+ can_ask: bool = false,
lsp_seq: u32 = 0,
lsp_wait: ?struct {
@@ -7794,14 +7798,18 @@ pub const Pardes = struct {
ctlfs.events.notePtyOutput(p, o.pane, o.bytes);
panes.Terminal.feedOutput(p, pane, o.bytes);
},
- .eof => |e| p.removePane(e.pane) catch |err| {
+ .eof => |e| p.removePane(e.pane, null) catch |err| {
if (p.panes[e.pane]) |pane| pane.mode = .normal;
p.reportError(e.pane, "terminal exited; Del retries close", err);
},
.lsp_resp => |r| p.lspResponse(r.id, r.rows),
.pipe_resp => |r| p.pipeResponse(r.id, r.success, r.outputs, r.failure),
.file_changed => |fc| _ = p.applyWatchedFileChanged(fc.pane, fc.bytes),
- .key => |key| p.handleKey(key),
+ .key => |key| {
+ p.can_ask = true;
+ defer p.can_ask = false;
+ p.handleKey(key);
+ },
.mouse => |m| {
if (m.kind == .release) switch (p.drag) {
.border_v, .border_h, .move, .column_move => p.presentation.snap_once = true,
@@ -8899,6 +8907,16 @@ pub const Pardes = struct {
}
fn handleKey(p: *Pardes, key: Key) void {
+ // A question waiting on a pane takes the next key, whatever mode the
+ // pane or a header edit is in: that key is its answer.
+ for (p.panes, 0..) |slot, id| {
+ const asked = slot orelse continue;
+ if (asked.prompt != .del_side) continue;
+ asked.prompt = .none;
+ if (hit(key, config.del_above)) p.runBuiltin(.Del, id, "", "k");
+ if (hit(key, config.del_below)) p.runBuiltin(.Del, id, "", "j");
+ return;
+ }
if (p.topbar_col != null) return p.topbarKey(key);
const pane = p.panes[p.active] orelse return;
if (pane.isTerminal() and pane.mode == .tty and !pane.tag_edit) {
@@ -8988,7 +9006,7 @@ pub const Pardes = struct {
p.applySelRegex(pane, "", false);
pane.tag_tail_len = @min(at, pane.tag_tail_len);
},
- .none => unreachable,
+ .none, .del_side => unreachable,
}
exitTagEdit(pane);
pane.mode = .normal;
@@ -11955,6 +11973,15 @@ pub const Pardes = struct {
}
fn handleMouse(p: *Pardes, m: Mouse) void {
+ // A click goes somewhere else: the question it leaves unanswered is
+ // taken back, and the pane stays. A wheel only looks around.
+ if (m.kind == .press and switch (m.button) {
+ .left, .middle, .right => true,
+ else => false,
+ }) for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.prompt == .del_side) pane.prompt = .none;
+ };
if (m.button == .back or m.button == .forward) {
if (m.kind == .press) p.jumpBy(if (m.button == .back) -1 else 1);
return;
@@ -12768,7 +12795,7 @@ pub const Pardes = struct {
panes.Terminal.scrollbar(tt).total > tt.rows) return;
p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err);
layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows
- layout.absorbVWeight(p, tty_id);
+ layout.absorbVWeight(p, tty_id, null);
layout.removePane(p, tty_id);
p.panes[tty_id] = null;
if (p.active == tty_id) p.active = keep_id;
@@ -13621,7 +13648,9 @@ pub const Pardes = struct {
pane.tag_init = true;
}
- pub fn removePane(p: *Pardes, id: usize) !void {
+ /// Close a pane; its rows go to `to`, or where layout.absorbVWeight
+ /// sends them when that is null.
+ pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void {
const pane = p.panes[id] orelse return;
// A column outlives its panes. Closing the last one leaves an empty
// pane standing in its place and in its directory; only Delcol ever
@@ -13647,7 +13676,7 @@ pub const Pardes = struct {
layout.insert(p, f.col, f.idx + 1, free);
break :heir free;
};
- layout.absorbVWeight(p, id);
+ layout.absorbVWeight(p, id, to);
layout.removePane(p, id);
p.panes[id] = null;
if (p.active == id) p.active = heir orelse p.prevFocus(id) orelse {
@@ -15499,6 +15528,87 @@ 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 "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 });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const top = p.active;
+ p.newScratchBelow(top);
+ const mid = p.active;
+ p.newScratchBelow(mid);
+ const low = p.active;
+ p.sync();
+ var h = [_]u16{ p.rects[top].h, p.rects[mid].h, p.rects[low].h };
+
+ // SPC d on the middle pane asks on its notice band, and closes nothing yet.
+ p.active = mid;
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expect(p.panes[mid] != null);
+ try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[mid].?.prompt);
+ _ = try p.render(frame.allocator());
+ try std.testing.expectEqual(Pane.Notices.Kind.prompt, p.panes[mid].?.notices.kinds[p.panes[mid].?.notices.len - 1]);
+ try std.testing.expectEqualStrings(config.del_question, p.panes[mid].?.promptText().?);
+ // j: the pane below takes every row, the one above keeps its own.
+ p.update(.{ .key = .{ .cp = 'j' } });
+ 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);
+
+ // Esc takes the question back and the pane stays; so does any other key,
+ // and so does a click.
+ p.newScratchBelow(top);
+ const again = p.active;
+ p.sync();
+ h = .{ p.rects[top].h, p.rects[again].h, p.rects[low].h };
+ try std.testing.expect(p.executeBuiltinLine(again, "Msg keep"));
+ for ([_]Key{ .{ .cp = Key.escape }, .{ .cp = 'x' } }) |answer| {
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[again].?.prompt);
+ p.update(.{ .key = answer });
+ try std.testing.expect(p.panes[again] != null);
+ try std.testing.expectEqual(Pane.Prompt.none, p.panes[again].?.prompt);
+ }
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ const r = p.rects[again];
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + 5, .row = r.y + 3 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + 5, .row = r.y + 3 } });
+ try std.testing.expect(p.panes[again] != null);
+ try std.testing.expectEqual(Pane.Prompt.none, p.panes[again].?.prompt);
+ try std.testing.expectEqual(h[1], p.rects[again].h);
+
+ // A word that is not a side closes nothing.
+ try std.testing.expect(p.executeBuiltinLine(again, "Del x"));
+ try std.testing.expect(p.panes[again] != null);
+ // DelAbove is Del k, and a line that is not a key never asks.
+ try std.testing.expect(p.executeBuiltinLine(again, "DelAbove"));
+ 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(h[2], p.rects[low].h);
+
+ // Bare Del from anything but a key closes at once and gives the rows up,
+ // as it always has.
+ p.newScratchBelow(top);
+ const third = p.active;
+ p.sync();
+ h = .{ p.rects[top].h, p.rects[third].h, p.rects[low].h };
+ try std.testing.expect(p.executeBuiltinLine(third, "Del"));
+ p.sync();
+ try std.testing.expect(p.panes[third] == null);
+ try std.testing.expectEqual(h[0] + h[1], p.rects[top].h);
+
+ // With nothing open below, there is nothing to ask: SPC d closes at once.
+ p.active = low;
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expect(p.panes[low] == null);
+}
+
test "closing the last pane in a column leaves an empty one in its place" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 });
@@ -15513,7 +15623,7 @@ test "closing the last pane in a column leaves an empty one in its place" {
const had = try std.testing.allocator.dupe(u8, p.panes[lone].?.cwdSlice());
defer std.testing.allocator.free(had);
- try p.removePane(lone);
+ try p.removePane(lone, null);
p.sync();
// The column outlives the pane: still two columns, and the one that was
diff --git a/src/tutor.txt b/src/tutor.txt
index ab73be33..2f5b521c 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -127,7 +127,10 @@
Tty new terminal in the calling pane's directory
Togglettymode in a terminal tag: switch editor/raw mode (Ctrl-B)
Joincol delete this column, move its panes to the one right
- Del in a pane's own tag: close it
+ Del in a pane's own tag: close it. From the keyboard, between
+ two panes, it asks which one gets the space: k or j
+ DelAbove close it, giving the space to the pane above (Del k)
+ DelBelow close it, giving the space to the pane below (Del j)
A new file opens in a new COLUMN only if both panes would still get
100 columns of width. Otherwise it stacks in the one you are in.
diff --git a/test/perf.zig b/test/perf.zig
index 5ac38ccf..057725fd 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -1012,7 +1012,7 @@ fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const
if (core) |p| {
pump(p);
try pardes.panes.Mini.open(p, 0, path);
- for (p.panes, 0..) |slot, id| if (slot != null and id != p.active) try p.removePane(id);
+ for (p.panes, 0..) |slot, id| if (slot != null and id != p.active) try p.removePane(id, null);
pump(p);
p.settings.colors = true;
const file = p.panes[p.active].?.file.?;
diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap
index 02547b91..9b91a93d 100644
--- a/test/snapshots/builtins.snap
+++ b/test/snapshots/builtins.snap
@@ -23,6 +23,8 @@ wheel up 110 50
wheel up 110 50
wheel up 110 50
wheel up 110 50
+wheel up 110 50
+wheel up 110 50
wait 10000 Look enter, right-click
wait 10000 Exec tab, middle-click
wait 10000 Filter
diff --git a/test/snapshots/delask.golden b/test/snapshots/delask.golden
new file mode 100644
index 00000000..56de45cf
--- /dev/null
+++ b/test/snapshots/delask.golden
@@ -0,0 +1,56 @@
+== snap three grid=100x36 cursor=18,20
+|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
+| New Tty Find Grep Joincol Delcol
+| /tmp/pardes-snap/delask/cwd/top.txt Save Tty Collapse Del
+| 1 top-marker
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/delask/cwd/+New * Save Tty Collapse Del
+| 1 middle-pane
+|
+|
+|
+|
+|
+|
+|
+| /tmp/pardes-snap/delask/cwd/+New * Save Tty Collapse Del
+| 1 bottom-pane Up
+== snap asked grid=100x36 cursor=18,20
+|20: 1 middle-pane Del: give space to k above j below
+|29: 1 bottom-pane
+== snap kept grid=100x36 cursor=18,20
+|20: 1 middle-pane
+== snap below-took grid=100x36 cursor=18,20
+|20: 1 bottom-pane
+|28:
+|29:
+== snap typed grid=100x36 cursor=27,20
+|20: 1 bottom-pane DelBelow
+|28: /tmp/pardes-snap/delask/cwd/+New * Save Tty Collapse Del
+|29: 1 third-pane
+== snap below-took-click grid=100x36 cursor=17,20
+|20: 1 third-pane
+|28:
+|29:
+== snap four grid=100x36 cursor=7,12
+|3: 1 top-marker New
+|11: /tmp/pardes-snap/delask/cwd/+New Save Tty Collapse Del
+|12: 1
+== snap mouse-del grid=100x36 cursor=9,3
+|3: 1 top-marker
+|11:
+|12:
diff --git a/test/snapshots/delask.snap b/test/snapshots/delask.snap
new file mode 100644
index 00000000..04bc850c
--- /dev/null
+++ b/test/snapshots/delask.snap
@@ -0,0 +1,71 @@
+# Del from the keyboard on a pane with open panes above AND below asks, on the
+# pane's notice band, which one takes its rows: k above, j below, anything else
+# keeps the pane. A click, and DelAbove/DelBelow (Del k / Del j), never ask.
+file top.txt top-marker
+start 36 100 top.txt
+wait 8000 top-marker
+stable 700 20000
+# two scratch panes below the file, each saying which it is, then focus the
+# middle one
+key space f n
+stable 400 5000
+key i
+text middle-pane
+key esc
+settle 100
+key space f n
+stable 400 5000
+key i
+text bottom-pane
+key esc
+settle 100
+key c-w k
+stable 400 5000
+snap three
+# asked, and nothing closes yet
+key space d
+stable 400 5000
+snap asked
+# Esc takes the question back: the pane stays
+key esc
+settle 100
+stable 400 5000
+snap kept
+# j: the pane below grows over the middle one's rows, the file keeps its own
+key space d
+stable 400 5000
+key j
+stable 700 10000
+snap below-took
+# a pane under it, and DelBelow (Del j) appended to the middle one's text and
+# executed with a click: no question, the pane below takes the rows
+key space f n
+stable 400 5000
+key i
+text third-pane
+key esc
+settle 100
+key c-w k
+stable 400 5000
+key A
+send \x20DelBelow
+key esc
+settle 100
+stable 400 5000
+snap typed
+press middle @DelBelow 21
+release middle @DelBelow 21
+stable 700 10000
+snap below-took-click
+# a fresh pane between the file and the third one, closed by a middle click
+# on its tag's Del: no question, and the rows go up as they always have
+press left 10 5
+release left 10 5
+stable 400 5000
+key space f n
+stable 400 5000
+snap four
+press middle @Del 12
+release middle @Del 12
+stable 700 10000
+snap mouse-del
diff --git a/test/snapshots/layout-close.snap b/test/snapshots/layout-close.snap
index 3d28e1d5..f459ffb4 100644
--- a/test/snapshots/layout-close.snap
+++ b/test/snapshots/layout-close.snap
@@ -24,11 +24,13 @@ stable 700 15000
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. The pane above absorbs; the other panes keep their rows.
+# 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.
press left 5 9
release left 5 9
stable 400 5000
key space d
+key k
stable 700 10000
snap del
# exit the (now) 2nd shell through its real tty: pty EOF, again only the pane
diff --git a/test/snapshots/leader.snap b/test/snapshots/leader.snap
index f018a88c..78594a47 100644
--- a/test/snapshots/leader.snap
+++ b/test/snapshots/leader.snap
@@ -32,7 +32,9 @@ key space h t
wait 10000 PARDES TUTOR
stable 700 15000
snap tutor
+# the tutor has panes above and below it, so SPC d asks: k gives its rows up
key space d
+key k
stable 700 15000
snap deleted
diff --git a/test/snapshots/lspdebug.snap b/test/snapshots/lspdebug.snap
index 23a95db0..ab330a76 100644
--- a/test/snapshots/lspdebug.snap
+++ b/test/snapshots/lspdebug.snap
@@ -46,7 +46,10 @@ wait 10000 definition z.zig
# report one row short of its second query: step the cursor past it.
key j j j
wait 10000 explain z.zig
+# the report sits between the source and the results, so Del asks where its
+# rows go: k, back to the source above
key space d
+key k
key space w j
key space d
stable 700 15000