From f207f1d4d56d1012766e6206e713f2b3da70579d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 10:11:14 -0300 Subject: A column takes panes until their minimums fill it, not until none can be halved Placement halved the tallest pane or refused, so 11 panes filled 48 rows that hold 16 at their tag and two rows each: the refusal did not match the documented arithmetic. When no pane can be halved but the minimums with the new pane's fit, the column's rows are shared out again; and a refused pane's removal leaves no pane under its minimum. Co-Authored-By: Claude Opus 5.5 --- docs/tags.md | 7 +++++-- src/exec.zig | 13 ++++++++++++- src/layout.zig | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/ninep/ctl.zig | 18 ++++++++++++++++++ src/pardes.zig | 6 ++++++ 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/docs/tags.md b/docs/tags.md index 46c2defa..018536e6 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -244,8 +244,11 @@ 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 +that room, the column's tallest pane is halved instead; where no one pane +can give it but the column's rows hold every pane's tag and two rows with +the new one's, the rows are shared out again, each at least its minimum; +only when they do not -- the arithmetic, not the halving, decides -- is +the new pane 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. diff --git a/src/exec.zig b/src/exec.zig index 79717963..60c4172d 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -977,9 +977,12 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan 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); + // No pane to halve, but the column holds every pane's minimum + // with the new one's: the rows are shared out again, so what is + // refused is only what the documented arithmetic cannot hold. // +Errors goes wherever there is room, last column first; with // none, its maker says the text some other way (appendErrors). - if (kind == .errors) { + if (layout.shareColumn(p, f.col)) {} else if (kind == .errors) { layout.removePane(p, id); var c = p.ncol; while (c > 0) { @@ -999,12 +1002,20 @@ pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPan return; } } else { + // Refused, it closes at the step's end holding no weight, so + // its column's rows come back as they were, none rounded off. + p.panes[id].?.vweight = 0; p.unplaced.set(id); p.reportError(from, "placement", error.NoPaneRoom); return; } } }; + // Placed: no pane of its column left under its minimum by the rows + // rounding or an earlier placement gave it; shared out again if so. + if (layout.findPane(p, id)) |f| if (!layout.columnAtMinimums(p, f.col)) { + _ = layout.shareColumn(p, f.col); + }; if (kind == .command) return; tagline.exitHeader(p); p.active = id; diff --git a/src/layout.zig b/src/layout.zig index 09cf23b3..16af1ec5 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -441,6 +441,52 @@ pub fn minimaFit(p: *Pardes) bool { return true; } +/// Whether every unfolded pane of column `c` has its `minRows` (a pane +/// alone in its column always does). +pub fn columnAtMinimums(p: *Pardes, c: usize) bool { + compute(p); + if (p.col_n[c] < 2) return true; + for (p.col_panes[c][0..p.col_n[c]]) |k| { + const pane = p.panes[k] orelse continue; + if (!pane.collapsed and p.rects[k].h < minRows(p, k)) return false; + } + return true; +} + +/// Shares column `c`'s rows out again so every unfolded pane has its +/// `minRows`, as evenly as that allows; false, changing nothing, when the +/// column has not the rows for all of them (or holds a folded pane). +pub fn shareColumn(p: *Pardes, c: usize) bool { + compute(p); + const n = p.col_n[c]; + if (n == 0) return false; + const avail = p.screen_h -| (p.topBarHeight() + p.columnBarHeight()); + var mins: [MAX_PANES]u16 = undefined; + var need: u32 = 0; + for (p.col_panes[c][0..n], 0..) |k, i| { + const pane = p.panes[k] orelse return false; + if (pane.collapsed) return false; + p.rects[k].w = p.col_w[c]; + mins[i] = minRows(p, k); + need += mins[i]; + } + if (need > avail) return false; + // Each its minimum, then what is left a row at a time to the smallest. + var hs: [MAX_PANES]u16 = undefined; + @memcpy(hs[0..n], mins[0..n]); + var left = avail - need; + while (left > 0) : (left -= 1) { + var low: usize = 0; + for (1..n) |i| if (hs[i] < hs[low]) { + low = i; + }; + hs[low] += 1; + } + for (p.col_panes[c][0..n], 0..) |k, i| p.panes[k].?.vweight = @floatFromInt(hs[i]); + compute(p); + return true; +} + /// After a resize the minima fit: each unfolded pane the resize took below /// its `minRows` (it had them in `before`) gets them back, a row at a time /// from whichever pane of its column has the most above its own. A pane diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index fbda6113..6631e5f9 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -2088,6 +2088,24 @@ test "a bad event record says what is wrong with it" { _ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h }); } +test "a column takes panes until their documented minimums fill it, not until none can be halved" { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 50 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const new = @intFromEnum(tree.TopFile.new); + while (call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status == .ok) p.sync(); + p.sync(); + const avail = p.screen_h - (p.topBarHeight() + p.columnBarHeight()); + for (0..p.ncol) |c| { + var need: u32 = 0; + for (p.col_panes[c][0..p.col_n[c]]) |k| need += layout.minRows(p, k); + // Refused only when one more pane's tag and two rows would not fit. + if (p.col_n[c] > 1) try testing.expect(avail - need < 3); + // Every pane keeps its minimum. + for (p.col_panes[c][0..p.col_n[c]]) |k| try testing.expect(p.rects[k].h >= layout.minRows(p, k)); + } +} + test "size is monotonic: growing is never refused, and a size once taken is taken again" { const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 60 }); defer p.deinit(); diff --git a/src/pardes.zig b/src/pardes.zig index 225dbf9e..f7aee16c 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -6630,10 +6630,16 @@ pub const Pardes = struct { } pub fn sync(p: *Pardes) void { + var refused = false; while (p.unplaced.findFirstSet()) |id| { p.unplaced.unset(id); if (p.panes[id] != null) p.removePane(id, null) catch {}; + refused = true; } + // A refused pane gone, no pane it squeezed stays under its minimum. + if (refused) for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) { + _ = layout.shareColumn(p, c); + }; tagline.flattenHeaders(p); if (p.header_column != null and p.columnBarHeight() == 0) { tagline.exitHeader(p); -- cgit v1.3