summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 02:32:33 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit672b2643583d13feef5d47600e0f6c15f276fba2 (patch)
tree3d556d0f30e72c8b2ed7bd4177a802e2a0697992 /src
parent45a66bd1c062a5cf4d63b1df68e050b059cdfa1d (diff)
downloadpardes-672b2643583d13feef5d47600e0f6c15f276fba2.tar.gz
pardes-672b2643583d13feef5d47600e0f6c15f276fba2.zip
No placement leaves a pane shorter than its tag and two rows
Placement could split a pane down to a tag and one row, or give a new one less. Every placement now checks the pane it made and each pane it shrank against its tag (wrapped at its width) plus two rows: where they lack the room, the column's tallest pane is halved instead, and where none can give it the new pane is refused with "no space for a pane in that column" (ENOSPC over 9P) and closed at the step's end, once its maker is done with it. splitBelow and splitParent leave two body rows (a boot layout, split before any row exists, is as before). Re-recorded by name: lookpanes (panes one or two rows lower) and splitparent (the 6-row squeeze now refuses); fs.py closes the scratches and long-named panes it no longer needs, since its 24-row session filled. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-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
5 files changed, 151 insertions, 24 deletions
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 })));
+}