summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/fs.md4
-rw-r--r--docs/tags.md7
-rw-r--r--src/Messages.zig11
-rw-r--r--src/exec.zig17
-rw-r--r--src/layout.zig63
-rw-r--r--src/ninep/tree.zig26
-rw-r--r--src/pardes.zig58
-rw-r--r--test/fs.py5
-rw-r--r--test/snapshots/lookpanes.golden34
-rw-r--r--test/snapshots/splitparent.golden32
-rw-r--r--test/snapshots/splitparent.snap9
11 files changed, 200 insertions, 66 deletions
diff --git a/docs/fs.md b/docs/fs.md
index 95ab4de0..e81ea18d 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -119,7 +119,9 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
64 panes (16 on the board); at that, every route that would open one -- this
open, look, exec, New, Tty -- fails with `no space for a pane: 64 max` (ENOSPC through 9ns, which has
no word for ENFILE) and an err
- record, and look reads back empty
+ record, and look reads back empty; a column with no rows left for one
+ (each pane keeps its tag and 2 rows) refuses it the same way,
+ `no space for a pane in that column` (docs/tags.md)
/pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll
errors event look exec, plus pty/{ctl,status,data} on terminals
/os/ the host filesystem
diff --git a/docs/tags.md b/docs/tags.md
index b8322aa9..5c4492bc 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -234,6 +234,13 @@ Every new pane goes through one placement, chosen by the `Placement` setting:
it, in an init file, a tag or the root ctl; bare `Placement` flips it; `SPC c
p` is its leader path, and `Config` reports it.
+Under either, no placement leaves a pane, new or split, shorter than its tag
+and two body rows (acme's minht keeps one). Where the place chosen has not
+that room, the column's tallest pane is halved instead; where no pane of the
+column can give it, the new pane is refused and closed, with `no space for a
+pane in that column: each keeps its tag and 2 rows` (a 9P write or open of
+`pane/new` fails with it, ENOSPC). A pane alone in its column always fits.
+
`Placement acme` is acme's makenewwindow (util.c:449-495). The core keeps
acme's *active column* (activecol, dat.c:37): the column last typed in
(acme.c:487), clicked in with the select button (acme.c:659), dropped into by
diff --git a/src/Messages.zig b/src/Messages.zig
index b197e715..daf195bb 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -444,18 +444,23 @@ pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage {
return &m.log[(first + i) % limits.message_log];
}
-/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
/// What a route that would open a pane says when every slot is taken.
/// "no space": 9ns's word for ENOSPC (it has none for ENFILE).
pub const no_pane_slots = "no space for a pane: {d} max";
+/// ...and when the column it would go in has no rows for one.
+pub const no_pane_room = "no space for a pane in that column: each keeps its tag and {d} rows";
+/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
w.print("{s}: ", .{operation}) catch {};
// Every route that opens a pane says the cap the same way.
- if (err == error.NoPaneSlots) {
- w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {};
+ if (err == error.NoPaneSlots or err == error.NoPaneRoom) {
+ if (err == error.NoPaneSlots)
+ w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {}
+ else
+ w.print(no_pane_room, .{pardes.layout.min_body_rows}) catch {};
// A 9P look or exec that would open the pane fails with it too.
p.fs.no_pane_slot = true;
@memcpy(p.fs.ename[0..w.buffered().len], w.buffered());
diff --git a/src/exec.zig b/src/exec.zig
index d4480e00..6de7991c 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -808,6 +808,8 @@ pub const NewPane = enum { scratch, fs, shell, doc, command, errors };
/// t->w == nil).
pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPane) void {
const asked = if (p.exec_column != null) null else origin;
+ layout.compute(p);
+ const before = p.rects;
switch (p.settings.placement) {
.acme => switch (kind) {
// New: the bottom half of the last pane in its own column
@@ -821,6 +823,21 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan
},
.pardes => pardesPlace(p, from, id, kind),
}
+ // No pane is made shorter than its tag and two rows: where the place
+ // chosen has not the room, the column's tallest pane gives it, and
+ // where none can, the pane is refused, and closed at the step's end
+ // (Pardes.unplaced) once whatever made it is done with it.
+ if (!layout.placedRoomy(p, id, &before)) if (layout.findPane(p, id)) |f| {
+ layout.removePane(p, id);
+ for (p.col_panes[f.col][0..p.col_n[f.col]]) |k| p.rects[k] = before[k];
+ layout.snapColWeights(p, f.col);
+ if (!layout.splitTallest(p, f.col, id) or !layout.placedRoomy(p, id, &before)) {
+ if (layout.findPane(p, id) == null) layout.insert(p, f.col, p.col_n[f.col], id);
+ p.unplaced.set(id);
+ p.reportError(from, "placement", error.NoPaneRoom);
+ return;
+ }
+ };
if (kind == .command) return;
tagline.exitHeader(p);
p.active = id;
diff --git a/src/layout.zig b/src/layout.zig
index b9b24e79..4c974fb7 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -408,6 +408,46 @@ pub fn acmePlace(p: *Pardes, c: usize, origin: ?usize, id: usize) void {
splitAt(p, c, id, y);
}
+/// The body rows a placement leaves every pane it makes or shrinks, under
+/// its tag (acme's minht keeps one).
+pub const min_body_rows = 2;
+
+fn minRows(p: *Pardes, id: usize) u16 {
+ return p.wantedTagRows(id) + min_body_rows;
+}
+
+/// After placing `id`: whether it, and every pane of its column that the
+/// placement shrank, still has its tag and `min_body_rows` (from `before`,
+/// the rects as they were). A pane alone in its column always has room: the
+/// window can be no taller.
+pub fn placedRoomy(p: *Pardes, id: usize, before: *const [MAX_PANES]Rect) bool {
+ compute(p);
+ const f = findPane(p, id) orelse return true;
+ if (p.col_n[f.col] == 1) return true;
+ for (p.col_panes[f.col][0..p.col_n[f.col]]) |k| {
+ const pane = p.panes[k] orelse continue;
+ if (pane.collapsed) continue;
+ if (k != id and p.rects[k].h >= before[k].h) continue;
+ if (p.rects[k].h < minRows(p, k)) return false;
+ }
+ return true;
+}
+
+/// Places `id` in column `c` by halving its tallest unfolded pane, when
+/// that leaves both their rows; false, and `id` in no column, when not.
+pub fn splitTallest(p: *Pardes, c: usize, id: usize) bool {
+ compute(p);
+ var tall: ?usize = null;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ if (k == id or p.panes[k].?.collapsed) continue;
+ if (tall == null or p.rects[k].h > p.rects[tall.?].h) tall = k;
+ }
+ const v = tall orelse return false;
+ if (p.rects[v].h < minRows(p, v) + minRows(p, id)) return false;
+ splitAt(p, c, id, p.rects[v].y + p.rects[v].h / 2);
+ return true;
+}
+
/// acme's coladd at row `y` (cols.c:66-126): the new pane goes under the
/// pane that `y` falls in, which keeps its rows above `y`.
fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void {
@@ -419,9 +459,9 @@ fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void {
snapColWeights(p, c);
insert(p, c, k + 1, id);
const pane = p.panes[id].?;
- // Each keeps a tag and a body row, as acme's minht does.
- const lo = r.y +| BOX_H + 1;
- const hi = (r.y + r.h) -| (BOX_H + 1);
+ // Each keeps a tag and min_body_rows, where acme's minht keeps one.
+ const lo = r.y +| minRows(p, v);
+ const hi = (r.y + r.h) -| minRows(p, id);
const row = splitRowForExtent(r.y, r.h, if (lo <= hi) std.math.clamp(y, lo, hi) else r.y + r.h / 2) orelse {
pane.vweight = BOX_H + 1;
return;
@@ -550,7 +590,7 @@ pub fn absorbVWeight(p: *Pardes, id: usize, to: ?usize) ?usize {
}
pub fn splitParent(p: *Pardes, want: usize) usize {
- const need = 2 * BOX_H + 3;
+ const need = 2 * (BOX_H + min_body_rows);
if (p.rects[want].h >= need) return want;
if (findPane(p, want)) |f| for (0..p.col_n[f.col]) |k| {
if (p.rects[p.col_panes[f.col][k]].h >= need) return p.col_panes[f.col][k];
@@ -567,23 +607,28 @@ pub fn splitParent(p: *Pardes, want: usize) usize {
pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void {
const src = p.panes[src_id] orelse return;
const src_h = p.rects[src_id].h;
- const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
+ // Its tag may wrap to more than one row: the rows under it are its body.
+ const tag_h = @max(BOX_H, src.tag_rows);
+ const body: u16 = if (src_h > tag_h) src_h - tag_h else 1;
const cur: u16 = if (!src.isTerminal()) body / 2 else panes.Terminal.gridCursor(src).y + 1;
// cap keep so a content-full source still leaves the new pane a tag +
// a few body rows (an Alt-n from a full shell was born 0 rows tall)
- const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3)));
+ // ...and the source min_body_rows of its own (exec.placeNew), when it
+ // has them: a boot layout splits before any row is laid out.
+ const least = @min(min_body_rows, body);
+ const keep = std.math.clamp(cur, least, @max(least, body -| (BOX_H + 3)));
if (findPane(p, src_id)) |f| for (0..p.col_n[f.col]) |k| {
const pid = p.col_panes[f.col][k];
if (p.panes[pid]) |pp| if (pp != nw and !pp.collapsed) {
pp.vweight = @floatFromInt(@max(1, p.rects[pid].h));
};
};
- if (!src.collapsed) src.vweight = @floatFromInt(BOX_H + keep);
- nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep)));
+ if (!src.collapsed) src.vweight = @floatFromInt(tag_h + keep);
+ nw.vweight = @floatFromInt(@max(1, src_h -| (tag_h + keep)));
if (nw.file) |f| if (!panes.Output.fileTraits(f.output).doc) {
// trimmed: every row ends in a newline, and the empty line after
// the last one is not a result
- const want: f32 = @floatFromInt(BOX_H + panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n")));
+ const want: f32 = @floatFromInt(BOX_H + @max(min_body_rows, panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n"))));
if (want < nw.vweight) {
src.vweight += nw.vweight - want;
nw.vweight = want;
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index aec3cab7..414a0b6e 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -713,8 +713,11 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// closing it leaves the pane alone.
.new => {
const slot = p.freeSlot() orelse return failText(req.tag, E.NOSPC, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no space for a pane");
+ p.fs.no_pane_slot = false;
p.newScratchFor(p.active, .fs);
const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE);
+ // No room in its column: it closes at the step's end.
+ if (p.unplaced.isSet(slot)) return failText(req.tag, E.NOSPC, p.fs.ename[0..p.fs.no_pane_slot_len]);
return .{ .tag = req.tag, .handle = made.serial };
},
.screen => .{ .snapshot = null },
@@ -1427,6 +1430,8 @@ test "editor paths resolve to the same nodes the wire serves" {
test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
+ // Tall enough that the column holds every pane at its tag and two rows.
+ p.update(.{ .resize = .{ .cols = 80, .rows = pardes.MAX_PANES * 4 } });
const new = @intFromEnum(TopFile.new);
while (p.freeSlot() != null) try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
var want: [64]u8 = undefined;
@@ -1496,3 +1501,24 @@ test "look and exec read back what their own open's write touched; a fresh open
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 9, .op = .read, .node = exec, .handle = fresh, .size = 64 }).bytes);
for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true });
}
+
+test "no placement leaves a pane shorter than its tag and two rows; a full column refuses the next" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const new = @intFromEnum(TopFile.new);
+ var made: usize = 1;
+ const refused = while (made < pardes.MAX_PANES) : (made += 1) {
+ const a = call(p, .{ .tag = 1, .op = .open, .node = new });
+ if (a.reply.status == .err) break a;
+ } else return error.NeverRefused;
+ try testing.expectEqual(E.NOSPC, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "no space for a pane in that column") != null);
+ p.sync();
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(made, count); // the refused one is gone
+ for (0..p.ncol) |c| for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ if (p.col_n[c] > 1 and !p.panes[k].?.collapsed)
+ try testing.expect(p.rects[k].h >= p.panes[k].?.tag_rows + pardes.layout.min_body_rows);
+ };
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index f2791e33..ac6d52cd 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4271,6 +4271,10 @@ pub const Pardes = struct {
/// 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,
+ /// Panes made this step that found no room in their column
+ /// (exec.placeNew): closed at the step's end, once whatever made them
+ /// is done with them.
+ unplaced: std.StaticBitSet(MAX_PANES) = .initEmpty(),
/// 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
@@ -6388,7 +6392,26 @@ pub const Pardes = struct {
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
/// deferred greetings. The mirror of the prototype's loop epilogue.
+ /// The rows pane `id`'s tag takes at its width, wrapped as `sync` wraps
+ /// it: what a placement must leave it above its body rows.
+ pub fn wantedTagRows(p: *Pardes, id: usize) u16 {
+ const pane = p.panes[id] orelse return BOX_H;
+ if (pane.collapsed or !pane.tag_expanded) return BOX_H;
+ if (p.rects[id].w == 0) return pane.tag_rows; // never laid out
+ var wrap_room: [32 * 1024]u8 = undefined;
+ var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room);
+ const wa = wrap_fba.allocator();
+ const prefix = tagline.prefixIn(wa, pane) catch return pane.tag_rows;
+ const whole = std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) }) catch return pane.tag_rows;
+ const shown = tagline.wrapTag(wa, whole, tagline.tagWidth(p, id)) catch return pane.tag_rows;
+ return @intCast(@max(BOX_H, @min(shown.rows(), MAX_TAG_ROWS)));
+ }
+
pub fn sync(p: *Pardes) void {
+ while (p.unplaced.findFirstSet()) |id| {
+ p.unplaced.unset(id);
+ if (p.panes[id] != null) p.removePane(id, null) catch {};
+ }
tagline.flattenHeaders(p);
if (p.header_column != null and p.columnBarHeight() == 0) {
tagline.exitHeader(p);
@@ -7042,7 +7065,10 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(low, p.active); // focus follows the rows
// Esc takes the question back and the pane stays; so does any other key,
- // and so does a click.
+ // and so does a click. (Top gets rows back first: split at its cursor,
+ // it kept its tag and two, too few to give a pane below its own.)
+ p.panes[top].?.vweight = p.panes[low].?.vweight;
+ p.sync();
p.newScratchBelow(top);
const again = p.active;
p.sync();
@@ -7096,17 +7122,6 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer -\n").reply.status);
try std.testing.expect(p.panes[asked] != null and p.panes[asked].?.prompt == .none);
try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\n", .{serial})));
- // A pane closed while its question stands ends the question in the log.
- p.newScratchBelow(top);
- const closing = p.active;
- p.sync();
- const closing_serial = p.panes[closing].?.serial;
- p.update(.{ .key = .{ .cp = ' ' } });
- p.update(.{ .key = .{ .cp = 'd' } });
- try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[closing].?.prompt);
- try p.removePane(closing, null);
- try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\ndel {d}", .{ closing_serial, closing_serial })));
- p.active = asked;
p.update(.{ .key = .{ .cp = ' ' } });
p.update(.{ .key = .{ .cp = 'd' } });
try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer k\n").reply.status);
@@ -7891,3 +7906,22 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" {
p.advance(p.stepped_ns + frame);
try std.testing.expectEqual(@as(?u64, null), p.nextWake());
}
+
+test "a pane closed while its question stands ends the question in the log" {
+ const th = @import("ninep/testing.zig");
+ const p = try th.withFile(std.testing.allocator, "x\n");
+ defer p.deinit();
+ p.settings.placement = .pardes;
+ p.newScratchBelow(0);
+ const closing = p.active;
+ p.newScratchBelow(closing);
+ p.sync();
+ p.active = closing; // a middle pane: Del from a key asks which side
+ const serial = p.panes[closing].?.serial;
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[closing].?.prompt);
+ try p.removePane(closing, null);
+ var want: [64]u8 = undefined;
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\ndel {d}", .{ serial, serial })));
+}
diff --git a/test/fs.py b/test/fs.py
index bdfc3fcb..68da6c51 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -121,6 +121,10 @@ def look(client, path, source=None):
client.write(f'/pane/{serial}/look', f'{path}\n'.encode())
results = [int(row) for row in client.read('/look').split()]
assert len(results) == 1 and results[0] != serial, (path, results)
+ # The scratch it looked from goes: a column holds only so many panes,
+ # each its tag and two rows.
+ if source is None:
+ client.remove(f'/pane/{serial}')
return results[0]
@@ -936,6 +940,7 @@ def test(binary, quic=False):
assert path.read_bytes() == b'written through long filename\n'
opened = look(client, '/n/os' + str(path))
assert client.read(f'/pane/{opened}/body') == path.read_bytes()
+ client.remove(f'/pane/{opened}') # a long name's tag takes rows
opened = look(client, '/n/os' + str(directory))
listing = client.read(f'/pane/{opened}/body')
for path in directory.iterdir():
diff --git a/test/snapshots/lookpanes.golden b/test/snapshots/lookpanes.golden
index 3c407401..6c0ddb84 100644
--- a/test/snapshots/lookpanes.golden
+++ b/test/snapshots/lookpanes.golden
@@ -14,18 +14,18 @@
|6: /tmp/pardes-snap/lookpanes/cwd/alpha/zqonly.txt Save Tty Colla
|7: pse Del /tmp/pardes-snap/lookpanes/cwd/alpha Tty+bash Save Mode Filter
|8: 1 ONLY-IN-ALPHA Collapse Del
-== snap clicked-wins grid=130x51 cursor=7,11
-|9: /tmp/pardes-snap/lookpanes/cwd/beta/both.txt Save Tty Collapse ls
-|10: Del amark.txt both.txt pick.txt twin.txt zqonly.txt
-|11: 1 BOTH-FROM-BETA
+== snap clicked-wins grid=130x51 cursor=7,12
+|10: /tmp/pardes-snap/lookpanes/cwd/beta/both.txt Save Tty Collapse amark.txt both.txt pick.txt twin.txt zqonly.txt
+|11: Del
+|12: 1 BOTH-FROM-BETA
== snap mru-alpha grid=130x51 cursor=72,14
-|12: /tmp/pardes-snap/lookpanes/cwd/alpha/pick.txt Save Tty Collaps
+|12: 1 BOTH-FROM-BETA /tmp/pardes-snap/lookpanes/cwd/alpha/pick.txt Save Tty Collaps
|13: e Del
|14: 1 PICK-FROM-ALPHA
-== snap mru-beta grid=130x51 cursor=7,14
-|12: /tmp/pardes-snap/lookpanes/cwd/beta/twin.txt Save Tty Collapse /tmp/pardes-snap/lookpanes/cwd/alpha/pick.txt Save Tty Collaps
-|13: Del e Del
-|14: 1 TWIN-FROM-BETA 1 PICK-FROM-ALPHA
+== snap mru-beta grid=130x51 cursor=7,16
+|14: /tmp/pardes-snap/lookpanes/cwd/beta/twin.txt Save Tty Collapse 1 PICK-FROM-ALPHA
+|15: Del
+|16: 1 TWIN-FROM-BETA
== style mru-beta grid=130x51
|0: 0-70 #b2c3b8,#1d2824, 71-129 d,#1d2824,
|1: 0-1 #ffffff,#887455, 2-2 d,#26362d, 3-34 #becfc4,#26362d, 35-64 d,#26362d, 65-66 #ffffff,#887455, 67-67 d,#1d2824, 68-99 #b2c3b8,#1d2824, 100-129 d,#1d2824,
@@ -36,14 +36,14 @@
|6: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-39 #b2c3b8,#1d2824, 40-49 #d3b0d9,#1d2824, 50-64 #b2c3b8,#1d2824, 65-65 d,#608269, 66-129 d,#0d1410,
|7: 0-2 d,#1d2824, 3-9 #b2c3b8,#1d2824, 10-64 d,#1d2824, 65-66 #ffffff,#496656, 67-67 d,#1d2824, 68-104 #b2c3b8,#1d2824, 105-112 #d3b0d9,#1d2824, 113-129 #b2c3b8,#1d2824,
|8: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-19 #e5eee5,#0d1410, 20-64 d,#0d1410, 65-67 d,#1d2824, 68-80 #b2c3b8,#1d2824, 81-129 d,#1d2824,
-|9: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-38 #b2c3b8,#1d2824, 39-46 #d3b0d9,#1d2824, 47-64 #b2c3b8,#1d2824, 65-65 d,#608269, 66-66 d,#0d1410, 67-129 #e5eee5,#0d1410,
-|10: 0-2 d,#1d2824, 3-6 #b2c3b8,#1d2824, 7-64 d,#1d2824, 65-65 d,#608269, 66-66 d,#0d1410, 67-129 #e5eee5,#0d1410,
-|11: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-20 #e5eee5,#0d1410, 21-64 d,#0d1410, 65-65 d,#608269, 66-129 d,#0d1410,
-|12: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-38 #becfc4,#26362d, 39-46 #dfbce5,#26362d, 47-64 #becfc4,#26362d, 65-66 #ffffff,#496656, 67-67 d,#1d2824, 68-104 #b2c3b8,#1d2824, 105-112 #d3b0d9,#1d2824, 113-129 #b2c3b8,#1d2824,
-|13: 0-2 d,#26362d, 3-6 #becfc4,#26362d, 7-64 d,#26362d, 65-67 d,#1d2824, 68-72 #b2c3b8,#1d2824, 73-129 d,#1d2824,
-|14: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-20 #e5eee5,#0d1410, 21-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-86 #e5eee5,#0d1410, 87-129 d,#0d1410,
-|15: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
-|16: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
+|9: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-129 #e5eee5,#0d1410,
+|10: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-38 #b2c3b8,#1d2824, 39-46 #d3b0d9,#1d2824, 47-64 #b2c3b8,#1d2824, 65-65 d,#608269, 66-66 d,#0d1410, 67-129 #e5eee5,#0d1410,
+|11: 0-2 d,#1d2824, 3-6 #b2c3b8,#1d2824, 7-64 d,#1d2824, 65-65 d,#608269, 66-129 d,#0d1410,
+|12: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-20 #e5eee5,#0d1410, 21-64 d,#0d1410, 65-66 #ffffff,#496656, 67-67 d,#1d2824, 68-104 #b2c3b8,#1d2824, 105-112 #d3b0d9,#1d2824, 113-129 #b2c3b8,#1d2824,
+|13: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-67 d,#1d2824, 68-72 #b2c3b8,#1d2824, 73-129 d,#1d2824,
+|14: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-38 #becfc4,#26362d, 39-46 #dfbce5,#26362d, 47-64 #becfc4,#26362d, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-86 #e5eee5,#0d1410, 87-129 d,#0d1410,
+|15: 0-2 d,#26362d, 3-6 #becfc4,#26362d, 7-64 d,#26362d, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
+|16: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-20 #e5eee5,#0d1410, 21-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
|17: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
|18: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
|19: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
diff --git a/test/snapshots/splitparent.golden b/test/snapshots/splitparent.golden
index e64df572..48dcf0c8 100644
--- a/test/snapshots/splitparent.golden
+++ b/test/snapshots/splitparent.golden
@@ -5,35 +5,25 @@
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save Mode Filter Collapse Del
| Mode Filter Collapse Del ls
| ls
-== snap swap-parent grid=100x31 cursor=4,9
-|5:
-|6: /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-|7: Mode Filter Collapse Del
-|8: ls
-== snap tallest grid=100x6 cursor=54,5
+== snap swap-parent grid=100x31 cursor=4,10
+|7: /tmp/pardes-snap/splitparent/cwd Tty+bash Save
+|8: Mode Filter Collapse Del
+|9: ls
+== snap tallest grid=100x6 cursor=4,5
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-| /tmp/pardes-snap/splitparent/cwd Tty+bash Save /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-== snap regrown grid=100x31 cursor=54,20
+| /tmp/pardes-snap/splitparent/cwd Tty+bash Save Mode Filter Collapse Del
+| /tmp/pardes-snap/splitparent/cwd Tty+bash Save ls
+| e in that column: each keeps its tag and 2 rows
+== snap regrown grid=100x31 cursor=4,10
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save /tmp/pardes-snap/splitparent/cwd Tty+bash Save
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save Mode Filter Collapse Del
| Mode Filter Collapse Del ls
+| ls
|
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
| Mode Filter Collapse Del
-| ls
-|
-|
-|
-|
-|
-|
-|
-|
-| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-| Mode Filter Collapse Del
-| ls
+| e in that column: each keeps its tag and 2 rows
diff --git a/test/snapshots/splitparent.snap b/test/snapshots/splitparent.snap
index b9038af2..14391467 100644
--- a/test/snapshots/splitparent.snap
+++ b/test/snapshots/splitparent.snap
@@ -1,6 +1,7 @@
# a new pane never opens under a too-short parent: Alt-n from a tag-only pane
# splits the tall sibling in its column instead; when NO pane could leave the
-# newcomer 2 body rows, the tallest one anywhere is split
+# newcomer 2 body rows, the tallest one anywhere is split, and when that one
+# cannot keep its own tag and 2 rows either, the pane is refused
# pins the pardes placement rule (Placement pardes), which it covers
config Placement pardes
start 31 100 -n 3
@@ -18,8 +19,10 @@ snap shrunk
key a-n
stable 700 15000
snap swap-parent
-# squeeze the grid until no pane is 5 rows tall, then Alt-n again: the
-# tallest pane (right column) is split even though the active one is left
+# squeeze the grid until no pane can give a new one its tag and 2 rows, then
+# Alt-n again: the new shell is refused, and the message row says why (before
+# every pane had to keep only a tag and one row, the tallest pane anywhere was
+# split here)
resize 6 100
stable 700 15000
key a-n