diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 18:53:37 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | b8c5e8772e646fa21f6afdd2a875a1bd52848bd5 (patch) | |
| tree | 70bca3d554501c49107b27396c7756e0e8ad62a3 /src/pardes.zig | |
| parent | c4ab29bce30f427decf3e199eebecfd415714a96 (diff) | |
| download | pardes-b8c5e8772e646fa21f6afdd2a875a1bd52848bd5.tar.gz pardes-b8c5e8772e646fa21f6afdd2a875a1bd52848bd5.zip | |
A column can be empty, as acme's can; new panes go where acme's makenewwindow puts them
Newcol makes an empty column; closing a column's last pane leaves it empty with
the keyboard on its tag; Delcol and Joincol alone take a column away; the
session's last pane closing quits. The +New stand-in and replaceStillborn /
stillborn_joiner are gone. Every new pane goes through exec.placeNew, which
follows acme's makenewwindow (active column, empty column whole, blank space,
halving the biggest) or, with Placement pardes, the old rules. ColumnTags is
gone: column tags are always shown. A grip drag shows acme's box cursor.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 457 |
1 files changed, 295 insertions, 162 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 00843b78..bad512a9 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1092,13 +1092,13 @@ test "Joincol folds the active column into its right neighbor, keeping its panes try std.testing.expectEqual(@as(usize, 2), p.ncol); p.active = 0; // the left column is current - layout.joinCol(p); + layout.joinCol(p, 0); try std.testing.expectEqual(@as(usize, 1), p.ncol); const lf = layout.findPane(p, 0) orelse return error.LostLeftPane; const rf = layout.findPane(p, right) orelse return error.LostRightPane; try std.testing.expectEqual(lf.col, rf.col); - layout.joinCol(p); // no right neighbor left: inert + layout.joinCol(p, 0); // no right neighbor left: inert try std.testing.expectEqual(@as(usize, 1), p.ncol); } @@ -1179,6 +1179,8 @@ test "first document fallback splits like Tty from the originating pane" { defer p.deinit(); const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); defer tty.deinit(); + p.settings.placement = .pardes; + tty.settings.placement = .pardes; if (collapsed) { layout.toggleCollapse(p, from); layout.toggleCollapse(tty, from); @@ -1191,8 +1193,8 @@ test "first document fallback splits like Tty from the originating pane" { // The Look origin need not be the currently active pane. p.active = (from + 1) % 3; const free = p.freeSlot().?; - const doc = try p.openTutorView(free); - exec.placeDoc(p, from, free, doc); + _ = try p.openTutorView(free); + exec.placeNew(p, from, from, free, .doc); _ = exec.spawnTty(tty, from); p.sync(); tty.sync(); @@ -1215,9 +1217,10 @@ test "first document keeps its originating shell and only opens a column when it for ([_]u16{ 199, 200 }) |width| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 }); defer p.deinit(); + p.settings.placement = .pardes; const shell = p.panes[0].?; - const doc = try p.openTutorView(1); - exec.placeDoc(p, 0, 1, doc); + _ = try p.openTutorView(1); + exec.placeNew(p, 0, 0, 1, .doc); p.sync(); try std.testing.expectEqual(shell, p.panes[0].?); try std.testing.expectEqual(@as(usize, 1), p.active); @@ -1233,6 +1236,112 @@ test "first document keeps its originating shell and only opens a column when it } } +test "acme placement: an empty active column takes the new pane whole" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 }); + defer p.deinit(); + try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement); + p.newColumn(0); // its tag has the keyboard: it is the active column + tagline.exitHeader(p); + p.sync(); + // A Look from the shell's text still lands in the active column, as + // acme's makenewwindow puts it in activecol first (util.c:456). + _ = try p.openTutorView(1); + exec.placeNew(p, 0, 0, 1, .doc); + try std.testing.expectEqualSlices(usize, &.{1}, p.col_panes[1][0..p.col_n[1]]); + try std.testing.expectEqual(@as(usize, 1), p.active); + try std.testing.expectEqual(@as(?usize, 1), p.activeColumn()); + // Never a new column. + try std.testing.expectEqual(@as(usize, 2), p.ncol); +} + +test "acme placement: under a pane's text when it has many blank rows, else halving the biggest" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 60 }); + defer p.deinit(); + const top = p.active; + _ = try p.setTestFile("one\ntwo\nthree\n"); + p.sync(); + p.setActiveColumn(0); + // Most of the pane is blank: a document looked from it starts under its + // three lines (makenewwindow, util.c:482-486). + const second = p.freeSlot().?; + _ = try p.openTutorView(second); + exec.placeNew(p, top, top, second, .doc); + p.sync(); + const text_end = p.bodyTop(p.panes[top].?, p.rects[top]) + 3; + try std.testing.expectEqual(text_end, p.rects[second].y); + // Fill both with text: no blank space is big, so the biggest pane is + // halved. + const full = "x\n" ** 80; + for ([_]usize{ top, second }) |id| panes.File.setContent(p, &p.panes[id].?.file.?, try p.gpa.dupe(u8, full)); + p.sync(); + const big = if (p.rects[second].h >= p.rects[top].h) second else top; + const was = p.rects[big]; + const third = p.freeSlot().?; + _ = try p.openTutorView(third); + exec.placeNew(p, second, second, third, .doc); + p.sync(); + try std.testing.expectEqual(big, p.col_panes[0][layout.findPane(p, third).?.idx - 1]); + try std.testing.expectEqual(was.y + was.h / 2, p.rects[third].y); + try std.testing.expectEqual(was.y + was.h, p.rects[third].y + p.rects[third].h); +} + +test "acme placement: New and 9P's new take the bottom half of the last pane" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); + defer p.deinit(); + p.newScratchBelow(p.active); + const last = p.active; + p.sync(); + const was = p.rects[last]; + p.newScratchFor(last, .fs); + const made = p.active; + p.sync(); + try std.testing.expectEqual(layout.findPane(p, last).?.idx + 1, layout.findPane(p, made).?.idx); + try std.testing.expectEqual(was.y + was.h, p.rects[made].y + p.rects[made].h); + try std.testing.expect(p.rects[made].y > was.y + BOX_H); +} + +test "Placement pardes brings back the old rules, and says so in Config" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); + defer p.deinit(); + try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes")); + try std.testing.expectEqual(config.Runtime.Placement.pardes, p.settings.placement); + const top = p.active; + p.sync(); + const was = p.rects[top]; + p.newScratchBelow(top); + p.sync(); + // Directly under the asking pane, as New always put it. + try std.testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.idx); + try std.testing.expectEqual(was.y + was.h, p.rects[p.active].y + p.rects[p.active].h); + try std.testing.expect(p.executeBuiltinLine(p.active, "Placement")); + try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement); +} + +test "dragging a column's only pane to another leaves that column in place, empty" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 }); + defer p.deinit(); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); + const moved = p.active; + p.sync(); + const weights = p.col_weight; + const tag_serial = p.col_serial[1]; + layout.movePane(p, moved, p.col_x[0] + 4, p.rects[0].y + p.rects[0].h / 2); + p.sync(); + // acme's coldragwin: colclose then coladd (cols.c:516-517); the column + // left behind keeps its place, width and tag. + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); + try std.testing.expectEqual(tag_serial, p.col_serial[1]); + try std.testing.expectEqualDeep(weights, p.col_weight); + try std.testing.expectEqual(@as(usize, 2), p.col_n[0]); + // ...and a pane dragged into the empty column takes it whole. + layout.movePane(p, moved, p.col_x[1] + 4, p.screen_h / 2); + p.sync(); + try std.testing.expectEqualSlices(usize, &.{moved}, p.col_panes[1][0..p.col_n[1]]); + try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); +} + test "pane defaults keep creation of scratch panes in column tags" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); @@ -3047,7 +3156,7 @@ test "column tags follow their column through split and close" { try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0)); p.col_tags[1].own = try p.gpa.dupe(u8, "Right Grep"); p.active = p.col_panes[0][0]; - layout.joinCol(p); + layout.joinCol(p, 0); try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0)); } @@ -3056,7 +3165,8 @@ test "column tags render edit hover and scroll from the column edge" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); p.acknowledgePanelPresentation(&.{}); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -3112,7 +3222,8 @@ test "column tag first-cell execution keeps New local to the clicked column" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); for (0..2) |column| { p.active = p.col_panes[1 - column][0]; @@ -3189,7 +3300,7 @@ test "tiny screens keep pane space before adding a column tag" { _ = try p.render(p.scratch.allocator()); } -test "ColumnTags reclaims the row and releases hidden header focus without losing commands" { +test "a screen too short for the column row releases a column tag's focus without losing its commands" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("unchanged body\n"); @@ -3201,24 +3312,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); try std.testing.expect(p.header_focus and p.header_column != null); - p.update(.{ .command = "ColumnTags" }); - try std.testing.expect(!p.settings.column_tags); - try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); - try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); - try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); - try std.testing.expect(!p.header_focus and p.header_column == null); - try std.testing.expect(!p.header_drag); - try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0)); - - p.update(.{ .command = "ColumnTags" }); - try std.testing.expect(p.settings.column_tags); - try std.testing.expectEqual(shown, p.rects[0]); - p.acknowledgePanelPresentation(&.{}); - p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); - p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); - try std.testing.expect(p.header_column != null); p.update(.{ .resize = .{ .cols = 60, .rows = 2 } }); - try std.testing.expect(p.settings.column_tags); try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); try std.testing.expect(!p.header_focus and p.header_column == null); @@ -3268,15 +3362,17 @@ test "Enter in a header runs the word under its cursor, as the execute key does" p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Newcol").?); p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expectEqual(@as(usize, 2), p.ncol); - // and the header lets go of the keyboard, as a pane tag does - try std.testing.expect(!p.header_focus); + // and the workspace tag lets go of the keyboard: the new, empty + // column's tag has it + try std.testing.expect(p.header_focus and p.header_column.? == 1); } test "window keys walk from a pane up through the column and workspace tags and back" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); p.active = p.col_panes[0][0]; const window = struct { @@ -3563,6 +3659,7 @@ test "Newcol refuses an unsplittable restored weight before spawning" { const pane = p.panes[id].?; try p.deinitPane(pane); layout.removePane(p, id); + layout.dropColumn(p, id); p.panes[id] = null; } while (p.nextEffect()) |_| {} @@ -4099,9 +4196,15 @@ 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, + /// The column whose tag the running command came from, while it runs: + /// the column acme's commands take from their Text's col. + exec_column: ?usize = null, + /// acme's activecol (dat.c:37), by column serial, 0 for none: the + /// column last typed in (acme.c:487), clicked in with the select button + /// (acme.c:659), dropped into by a grip (acme.c:640), or given a new + /// pane (util.c:467). Where the acme placement puts new panes + /// (exec.placeNew). A Look click moves the keyboard but not this. + active_column: u32 = 0, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, locations_config: locations_config.Config = .{}, locations_cache: @import("locations_cache.zig").Cache = .{}, @@ -4347,10 +4450,12 @@ pub const Pardes = struct { p.panes[0].?.body.mode = .tty; p.oneColumn(); // A bare tty is still an editor: it opens with somewhere to put - // text under the shell. The shell keeps the keyboard. + // text under the shell. The shell keeps the keyboard. A boot + // layout, like .classic's, not a placement. if (p.boot == .tty) { - p.newScratchBelow(0); - p.active = 0; + const np = try p.newScratch(0, 1); + layout.insert(p, 0, 1, 1); + layout.splitBelow(p, 0, np); } }, .classic => { @@ -4469,7 +4574,7 @@ pub const Pardes = struct { /// The column tags' height: a row per line of the tallest one. pub fn columnBarHeight(p: *const Pardes) u16 { - if (!p.settings.column_tags or p.screen_h < 3) return 0; + if (p.screen_h < 3) return 0; var lines: usize = COLBAR_H; for (p.col_tags[0..p.ncol]) |t| if (t.own) |own| { lines = @max(lines, std.mem.count(u8, own, "\n") + 1); @@ -4609,34 +4714,56 @@ pub const Pardes = struct { return np; } - /// The empty pane that stands in for one that closed, keeping its column - /// alive and its directory. Unlike `newScratch` it inherits nothing: the - /// pane it replaces is on its way out. - fn newScratchIn(p: *Pardes, free: usize, dir: []const u8) !*Pane { - const content = try p.gpa.dupe(u8, ""); - errdefer p.gpa.free(content); - const np = try panes.Output.open(p, free, dir, .{ .cmd = .New }, "", content); - np.body.cur_pinned = true; - return np; - } - - /// New: a scratch below the calling pane, in its column. + /// New: an empty scratch in `from_id`'s directory, placed as exec.placeNew + /// places it. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { - const free = p.freeSlot() orelse return; - const sf = layout.findPane(p, from_id) orelse return; - const np = p.newScratch(from_id, free) catch return; - layout.insert(p, sf.col, sf.idx + 1, free); - layout.splitBelow(p, from_id, np); - p.active = free; + p.newScratchFor(from_id, .scratch); } - /// Newcol: a scratch in a fresh column beside the calling pane. - pub fn newScratchColumn(p: *Pardes, from_id: usize) void { + /// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`). + pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void { const free = p.freeSlot() orelse return; - if (!layout.canSplitColumn(p, from_id)) return; _ = p.newScratch(from_id, free) catch return; - std.debug.assert(layout.splitColumn(p, from_id, free, false)); - p.active = free; + exec.placeNew(p, from_id, from_id, free, kind); + } + + /// Newcol: an empty column right of the calling pane's, the keyboard + /// on its tag, for the next pane made to fill. + pub fn newColumn(p: *Pardes, from_id: usize) void { + const col = p.keyboardColumn(from_id) orelse return; + const c = layout.insertColumn(p, col, false) orelse return; + tagline.enterHeader(p, c); + } + + pub fn setActiveColumn(p: *Pardes, c: usize) void { + if (c < p.ncol) p.active_column = layout.columnSerial(p, c); + } + + /// The active column, while it is still there. + pub fn activeColumn(p: *const Pardes) ?usize { + return layout.columnBySerial(p, p.active_column); + } + + /// The column the keyboard is in: its tag's, or its pane's. + pub fn noteTyped(p: *Pardes) void { + if (p.header_focus) { + if (p.header_column) |c| p.setActiveColumn(c); + } else if (layout.findPane(p, p.active)) |f| p.setActiveColumn(f.col); + } + + /// New, run from column `c`'s tag. + pub fn newScratchIn(p: *Pardes, from_id: usize, c: usize) void { + p.exec_column = c; + defer p.exec_column = null; + p.newScratchBelow(from_id); + } + + /// The column a command acts on: the one whose tag it came from, else + /// the one whose tag has the keyboard, else `id`'s. + pub fn keyboardColumn(p: *Pardes, id: usize) ?usize { + if (p.exec_column) |c| return c; + if (p.header_focus) if (p.header_column) |c| return c; + return if (layout.findPane(p, id)) |f| f.col else null; } pub fn freeSlot(p: *Pardes) ?usize { @@ -5088,10 +5215,6 @@ pub const Pardes = struct { look.cancelLookHover(p); const reply = ctlfs.handle(p, req); if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply); - // A pane that joined a column holding only a stillborn scratch takes - // it now, before the answer: one read of index after the look shows - // the replacement done, and the log has its del with the new. - exec.replaceStillborn(p); // The request was a whole step of its own, so it settles the way a // step does: the cursor and scroll reconciled, the scripted panes // told, and the panes it made announced to /log now rather than at @@ -5103,7 +5226,6 @@ pub const Pardes = struct { } pub fn update(p: *Pardes, ev: Event) void { - defer exec.replaceStillborn(p); // A tick with nothing animating is the one event that cannot change // the screen. p.needs_frame = p.needs_frame or ev != .tick; @@ -5347,6 +5469,7 @@ pub const Pardes = struct { return; } const pane = p.panes[p.active] orelse return; + p.noteTyped(); // An open prompt takes the keys even when it answers for a header. const header = if (pane.prompt != .none) null else tagline.focusedHeader(p); if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) { @@ -5395,16 +5518,11 @@ pub const Pardes = struct { // global window ops (any mode): Alt-n new terminal below, Alt-c move // the active terminal into a fresh column if (hit(key, config.new_shell_below)) { - tagline.exitHeader(p); const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; - const src = layout.splitParent(p, p.active); - const f = layout.findPane(p, src).?; - layout.insert(p, f.col, f.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; - p.stillborn_joiner = nt.serial; + // From a tag, as a 9P new is: acme's t->w == nil. + exec.placeNew(p, p.active, if (p.header_focus) null else p.active, free, .shell); return; } for (config.jump_keys) |jk| { @@ -5483,6 +5601,9 @@ pub const Pardes = struct { const b = config.wordBounds(shown, modal.selectionCursor(shown, range)); break :word shown[b.lo..b.hi]; }; + // A column tag's command acts on that column, empty or not. + p.exec_column = if (header != null) p.header_column else null; + defer p.exec_column = null; // Out of the tag or header FIRST: Del frees the pane, Kill // and Restore the whole session. if (header != null) tagline.exitHeader(p) else pane.focus = .body; @@ -5947,59 +6068,38 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- /// Close a pane; its rows go to `to`, or where layout.absorbVWeight - /// sends them when that is null. Focus on it follows its rows: to the - /// pane that grew into them, or to the scratch left standing in an - /// emptied column. Every route that closes a pane comes through here or - /// removeColumn, so none picks focus for itself, and none by recency. + /// sends them when that is null. Focus on it follows its rows to the + /// pane that grew into them. A column outlives its panes, as acme's + /// does (cols.c:186): closing its last one leaves it empty, the keyboard + /// on its tag; only Delcol takes a column away. The session's last pane + /// closing ends the session. Every route that closes a pane comes + /// through here or removeColumn, so none picks focus for itself, and + /// none by recency. 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 - // takes a column away. const place = layout.findPane(p, id); - const last_in_column = if (place) |f| p.col_n[f.col] == 1 else false; - var dir_buf: [limits.host_path_cap]u8 = undefined; - var dir: []const u8 = ""; - if (last_in_column) { - const had = paneDir(pane); - const n = @min(had.len, dir_buf.len); - @memcpy(dir_buf[0..n], had[0..n]); - dir = dir_buf[0..n]; - } + const emptied = if (place) |f| (if (p.col_n[f.col] == 1) f.col else null) else null; // Before anything is torn down, so a failure here changes nothing. try p.deinitPane(pane); - // ...and after it, so the stand-in inherits none of the closing pane. - const heir: ?usize = heir: { - if (!last_in_column) break :heir null; - const f = place.?; - const free = p.freeSlot() orelse break :heir null; - _ = p.newScratchIn(free, dir) catch break :heir null; - layout.insert(p, f.col, f.idx + 1, free); - break :heir free; - }; const gainer = layout.absorbVWeight(p, id, to); - const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null; + const beside = if (emptied) |c| p.besideColumn(c, p.rects[id]) else null; layout.removePane(p, id); p.panes[id] = null; - if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse { - p.quit = true; - p.emit(.quit); - return; - }; + if (p.active != id) return; + p.active = gainer orelse beside orelse p.anyPane() orelse return p.quitEmpty(); + if (emptied) |c| if (!p.header_focus) tagline.enterHeader(p, c); } - pub fn removeColumn(p: *Pardes, id: usize) !void { - const place = layout.findPane(p, id) orelse return; - const had_focus = layout.findPane(p, p.active); - const beside = if (had_focus != null and had_focus.?.col == place.col) - p.besideColumn(place.col, p.active) - else - null; + /// Delcol: close column `col` and every pane in it. + pub fn removeColumn(p: *Pardes, col: usize) !void { + if (col >= p.ncol) return; + const had_focus = p.keyboardColumn(p.active) == col; + const beside = if (had_focus) p.besideColumn(col, if (p.header_focus) null else p.rects[p.active]) else null; var ids: [MAX_PANES]usize = undefined; var parents: [MAX_PANES]*Pane = undefined; - const count = p.col_n[place.col]; + const count = p.col_n[col]; for (0..count) |i| { - ids[i] = p.col_panes[place.col][i]; + ids[i] = p.col_panes[col][i]; parents[i] = p.panes[ids[i]].?; } try p.detachCwds(parents[0..count]); @@ -6008,11 +6108,14 @@ pub const Pardes = struct { layout.removePane(p, closed); p.panes[closed] = null; } - if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse { - p.quit = true; - p.emit(.quit); - return; - }; + layout.dropColumn(p, col); + if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return p.quitEmpty(); + } + + /// No pane is left: the session is over. + fn quitEmpty(p: *Pardes) void { + p.quit = true; + p.emit(.quit); } /// The pane Last should fall back to when the jumplist has nothing: the @@ -6031,14 +6134,13 @@ pub const Pardes = struct { return null; } - /// The pane that takes over `id`'s place when column `col` goes: in the - /// column layout.removePane gives the width to, the one level with the - /// middle of `id`. Asked before the column goes, while rects still hold. - fn besideColumn(p: *const Pardes, col: usize, id: usize) ?usize { + /// The pane that takes over the keyboard from column `col`: in the + /// column layout.dropColumn gives the width to, the one level with the + /// middle of `r` (of the screen without one). Asked while rects hold. + fn besideColumn(p: *const Pardes, col: usize, r: ?Rect) ?usize { if (p.ncol < 2) return null; const next = if (col > 0) col - 1 else col + 1; - const r = p.rects[id]; - const mid = r.y + r.h / 2; + const mid = if (r) |at| at.y + at.h / 2 else p.screen_h / 2; var pick: ?usize = null; for (p.col_panes[next][0..p.col_n[next]]) |k| { if (p.panes[k] == null) continue; @@ -6648,43 +6750,67 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro try std.testing.expect(pane.prompt == .none and pane.input.own == null); } -test "a stillborn pane gives its column to the first pane that joins it" { +test "Newcol makes an empty column, and the next pane made from its tag fills 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 first = p.active; + const panes_before = p.col_n[0]; + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); + try std.testing.expectEqual(panes_before, p.col_n[0]); + // The keyboard is on the new column's tag; no pane has it. + try std.testing.expect(p.header_focus and p.header_column.? == 1); + try std.testing.expectEqual(first, p.active); + p.sync(); + try std.testing.expect(p.executeBuiltinLine(first, "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); + try std.testing.expect(shell != first); + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + try std.testing.expectEqual(shell, p.col_panes[1][0]); + try std.testing.expect(!p.header_focus); - // Typed into, body or tag, it is a pane like any other. - try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); - const typed = p.active; + // The next Tty splits as it always has: nothing is empty any more. 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, "Tty")); + try std.testing.expectEqual(@as(usize, 2), p.col_n[1]); +} - try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); - const tagged = p.active; +test "a column tag's command acts on that column" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); + defer p.deinit(); + const first = p.active; + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expectEqual(@as(usize, 3), p.ncol); p.sync(); - try p.panes[tagged].?.appendTag(" make"); - try std.testing.expect(p.executeBuiltinLine(tagged, "Tty")); - p.update(.tick); - try std.testing.expect(p.panes[tagged] != null); + // New from the middle column's tag lands in it, not below `first`. + p.exec_column = 1; + try std.testing.expect(p.executeBuiltinLine(first, "New")); + p.exec_column = null; + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + try std.testing.expectEqual(p.active, p.col_panes[1][0]); + // Delcol from the empty right column's tag closes that one. + p.exec_column = 2; + try std.testing.expect(p.executeBuiltinLine(first, "Delcol")); + p.exec_column = null; + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + // A pane's own Delcol closes its column whatever tag has the keyboard. + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expect(p.header_focus); + try std.testing.expect(p.executeBuiltinLine(p.col_panes[1][0], "Delcol")); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); } 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(); + p.settings.placement = .pardes; // the neighbours below are what is under test var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const top = p.active; @@ -6781,7 +6907,8 @@ test "focus on a closed pane goes to the pane that takes its place, not the one const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); defer p.deinit(); const shell = p.active; - p.newScratchColumn(shell); + p.newColumn(shell); + p.newScratchIn(shell, p.activeColumn().?); const right = p.active; p.sync(); p.newScratchBelow(shell); @@ -6813,45 +6940,50 @@ test "focus on a closed pane goes to the pane that takes its place, not the one p.sync(); p.active = left_low; p.sync(); - try p.removeColumn(left_low); + try p.removeColumn(layout.findPane(p, left_low).?.col); p.sync(); try std.testing.expectEqual(right_low, p.active); } -test "closing the last pane in a column leaves an empty one in its place" { +test "closing the last pane in a column leaves the column empty, its tag focused" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); defer p.deinit(); const shell = p.active; - p.newScratchColumn(shell); + p.newColumn(shell); + p.newScratchIn(shell, p.activeColumn().?); const lone = p.active; try std.testing.expect(lone != shell); try std.testing.expectEqual(@as(usize, 2), p.ncol); const at = layout.findPane(p, lone).?; try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]); - const had = try std.testing.allocator.dupe(u8, p.panes[lone].?.cwdSlice()); - defer std.testing.allocator.free(had); + p.sync(); try p.removePane(lone, null); p.sync(); - // The column outlives the pane: still two columns, and the one that was - // emptied holds a fresh empty pane in the same directory. + // The column outlives the pane, as acme's does: still two columns, the + // emptied one with no pane and the keyboard on its tag. try std.testing.expectEqual(@as(usize, 2), p.ncol); - try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]); - const heir = p.col_panes[at.col][0]; - try std.testing.expect(heir != lone); + try std.testing.expectEqual(@as(usize, 0), p.col_n[at.col]); try std.testing.expect(p.panes[lone] == null); - const hp = p.panes[heir].?; - try std.testing.expectEqualStrings("", hp.file.?.content); - try std.testing.expectEqualStrings(had, hp.cwdSlice()); - try std.testing.expectEqual(heir, p.active); + try std.testing.expect(p.header_focus and p.header_column.? == at.col); + try std.testing.expectEqual(shell, p.active); try std.testing.expect(!p.quit); + // Down from it goes nowhere: nothing is below. + try std.testing.expect(p.executeBuiltinLine(shell, "Down")); + try std.testing.expect(p.header_focus and p.header_column.? == at.col); + // ...and Delcol is what actually takes the column away. - try p.removeColumn(heir); + try p.removeColumn(at.col); p.sync(); try std.testing.expectEqual(@as(usize, 1), p.ncol); + try std.testing.expectEqual(shell, p.active); + + // The session's last pane closing ends it. + try p.removePane(shell, null); + try std.testing.expect(p.quit); } test "the bare tty startup opens a text pane under the shell, unfocused" { @@ -7017,6 +7149,7 @@ test "hopping between two panes does not grow the jump stack" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); + p.settings.placement = .pardes; // two panes of room, not acme's fit to the doc's two lines defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc |
