summaryrefslogtreecommitdiff
path: root/src/pardes.zig
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 /src/pardes.zig
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]>
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig136
1 files changed, 123 insertions, 13 deletions
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