diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-24 10:06:08 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 3f6ad1bc30570348297ae4aa6d1fd676de229a09 (patch) | |
| tree | b52037635072e90e0ff20c80a15c5d18c578d445 /src | |
| parent | 82c4f9e7212f569d6343dd03ddbab3520a784993 (diff) | |
| download | pardes-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')
| -rw-r--r-- | src/CHANGELOG.md | 8 | ||||
| -rw-r--r-- | src/builtins.zig | 43 | ||||
| -rw-r--r-- | src/config.zig | 8 | ||||
| -rw-r--r-- | src/gui/gui.zig | 2 | ||||
| -rw-r--r-- | src/layout.zig | 29 | ||||
| -rw-r--r-- | src/ninep/screen.zig | 2 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 2 | ||||
| -rw-r--r-- | src/panes.zig | 6 | ||||
| -rw-r--r-- | src/pardes.zig | 136 | ||||
| -rw-r--r-- | src/tutor.txt | 5 |
10 files changed, 210 insertions, 31 deletions
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. |
