summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 07:15:20 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit03773fac517a2a26dc2f2813db9906d3bf7e91db (patch)
treea048a062c7994cec1b676f04300ce0a5e0af9ffd
parentd13ae04008e678ed992e6181487dbfd126a4154f (diff)
downloadpardes-03773fac517a2a26dc2f2813db9906d3bf7e91db.tar.gz
pardes-03773fac517a2a26dc2f2813db9906d3bf7e91db.zip
`size` is refused only when the panes' own minima do not fit, so it is monotonic
The check read how the weights shared the rows after the resize, not whether the rows could hold every pane's tag and two rows: a pane the proportional squeeze took short made `size` refuse, growing included, and refuse sizes it took a moment before. It now asks layout.minRows, the minimum placement asks, per column; a size they fit is taken, and a pane the resize took under its minimum gets its rows back. An out of range size says `invalid size`, which 9ns reads as EINVAL, as the doc had. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/fs.md8
-rw-r--r--src/layout.zig77
-rw-r--r--src/ninep/ctl.zig55
3 files changed, 120 insertions, 20 deletions
diff --git a/docs/fs.md b/docs/fs.md
index 2149fbcf..23c87216 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -109,8 +109,12 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
/ctl the settings, one a line as a write takes them; write a setting or a session builtin;
`size <cols> <rows>` sets the screen of a session no frontend is attached to
(`--detach`, 80x24 until then; refused while a frontend owns the size),
- from 20x6 to 4096x4096, and refused when the panes it has would not each
- keep their tag and 2 rows; a terminal's pty follows its pane on every resize
+ from 20x6 to 4096x4096 (outside that, `invalid size`, EINVAL), and refused
+ when a column has not the rows for its panes' minima, each its tag and 2
+ rows, the minimum placement keeps; so a size once taken is taken again,
+ and growing is never refused. A pane the resize took under its minimum
+ gets its rows back from its column's others; a terminal's pty follows its
+ pane on every resize
/commands every builtin: word, `arg` if it takes one, `root`, `pane` or `both` (the ctl that takes it:
Edit is both, at the active pane from the root; a pane's word such as Undo or Msg
is refused at the root), a setting's values, then ` -- ` and what it does
diff --git a/src/layout.zig b/src/layout.zig
index cbf0fd96..09cf23b3 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -416,10 +416,71 @@ pub fn acmePlace(p: *Pardes, c: usize, origin: ?usize, id: usize) void {
/// its tag (acme's minht keeps one).
pub const min_body_rows = 2;
-fn minRows(p: *Pardes, id: usize) u16 {
+/// The rows a placement, and a `size`, leave a pane: its tag at its width
+/// and `min_body_rows`. The one minimum both ask.
+pub fn minRows(p: *Pardes, id: usize) u16 {
return p.wantedTagRows(id) + min_body_rows;
}
+/// Whether every column has the rows for each of its unfolded panes'
+/// `minRows` (a folded one its tag row): the panes' own minima, not how
+/// the weights happen to share the rows, so a size that fits keeps fitting
+/// when it grows. One pane alone in its column always fits.
+pub fn minimaFit(p: *Pardes) bool {
+ compute(p);
+ const avail = p.screen_h -| (p.topBarHeight() + p.columnBarHeight());
+ for (0..p.ncol) |c| {
+ if (p.col_n[c] < 2) continue;
+ var need: u32 = 0;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ const pane = p.panes[k] orelse continue;
+ need += if (pane.collapsed) BOX_H else minRows(p, k);
+ }
+ if (need > avail) return false;
+ }
+ 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
+/// dragged short before keeps what it had.
+pub fn restoreMinima(p: *Pardes, before: *const [MAX_PANES]Rect) void {
+ compute(p);
+ for (0..p.ncol) |c| {
+ const n = p.col_n[c];
+ if (n < 2) continue;
+ var hs: [MAX_PANES]u16 = undefined;
+ var mins: [MAX_PANES]u16 = undefined;
+ var short = false;
+ for (p.col_panes[c][0..n], 0..) |k, i| {
+ const pane = p.panes[k] orelse return;
+ if (pane.collapsed) break;
+ hs[i] = p.rects[k].h;
+ mins[i] = minRows(p, k);
+ if (hs[i] < mins[i] and before[k].h >= mins[i]) short = true;
+ } else {
+ if (!short) continue;
+ for (p.col_panes[c][0..n], 0..) |k, i| {
+ if (hs[i] >= mins[i] or before[k].h < mins[i]) continue;
+ while (hs[i] < mins[i]) {
+ var giver: ?usize = null;
+ var spare: u16 = 0;
+ for (0..n) |j| if (j != i and hs[j] -| mins[j] > spare) {
+ spare = hs[j] - mins[j];
+ giver = j;
+ };
+ const g = giver orelse break;
+ hs[g] -= 1;
+ hs[i] += 1;
+ }
+ }
+ for (p.col_panes[c][0..n], 0..) |k, i| p.panes[k].?.vweight = @floatFromInt(@max(1, hs[i]));
+ }
+ }
+ compute(p);
+}
+
/// 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
@@ -437,20 +498,6 @@ pub fn placedRoomy(p: *Pardes, id: usize, before: *const [MAX_PANES]Rect) bool {
return true;
}
-/// Whether every unfolded pane sharing its column has its tag and
-/// `min_body_rows` (one alone in its column always fits).
-pub fn everyPaneRoomy(p: *Pardes) bool {
- compute(p);
- for (0..p.ncol) |c| {
- if (p.col_n[c] < 2) continue;
- 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;
-}
-
/// Places `id` last in column `c`, where acme's coladd puts a new window,
/// with half the rows of the column's tallest unfolded pane, when that
/// leaves both their rows; false, and `id` in no column, when not.
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 02201de0..665913fc 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -521,15 +521,20 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
if (!p.unattended) return refuse(p, req, "size: a frontend is attached and owns the size", line);
if (apply) {
const was: [2]u16 = .{ p.screen_w, p.screen_h };
+ const before = p.rects;
p.update(.{ .resize = .{ .cols = size[0], .rows = size[1] } });
p.sync();
// Every pane keeps its tag and two rows, as placement
- // leaves them: a size too small for that is refused.
- if (!pardes.layout.everyPaneRoomy(p)) {
+ // leaves them (layout.minRows, the one minimum): a size
+ // too small for the panes' minima is refused, and one
+ // they fit gives back rows the resize took under them.
+ if (!pardes.layout.minimaFit(p)) {
p.update(.{ .resize = .{ .cols = was[0], .rows = was[1] } });
p.sync();
return refuse(p, req, "size: too small for the panes, each its tag and 2 rows", line);
}
+ pardes.layout.restoreMinima(p, &before);
+ p.sync();
}
continue;
}
@@ -548,7 +553,8 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
const size_min = [2]u16{ 20, 6 };
const size_max = [2]u16{ 4096, 4096 };
-const e_size = std.fmt.comptimePrint("size takes <cols> <rows>, at least {d}x{d}, at most {d}x{d}", .{ size_min[0], size_min[1], size_max[0], size_max[1] });
+// "invalid": the word 9ns reads as EINVAL (enameToErrno), as winsize's.
+const e_size = std.fmt.comptimePrint("invalid size: takes <cols> <rows>, at least {d}x{d}, at most {d}x{d}", .{ size_min[0], size_min[1], size_max[0], size_max[1] });
/// `size <cols> <rows>`, within size_min and size_max.
fn sizeOf(line: []const u8) ?[2]u16 {
@@ -1703,6 +1709,49 @@ test "size sets the screen of a session no frontend is attached to, and only of
try testing.expectEqual(@as(u16, 40), p.screen_h);
}
+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();
+ while (p.nextEffect()) |_| {}
+ p.unattended = true;
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const new = @intFromEnum(tree.TopFile.new);
+ // The dogfood's repro: Newcol, three pane/new, a Tty, then pane/new
+ // until a column has no room (ENOSPC).
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
+ for (0..3) |_| try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .ctl), "Tty\n").reply.status);
+ p.sync();
+ while (call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status == .ok) p.sync();
+ p.sync();
+ var line: [32]u8 = undefined;
+ // Down a row at a time (59, 58, 57, 56 and on) until one is refused:
+ // once refused, every smaller one is too.
+ var taken: [60]bool = @splat(false);
+ var rows: u16 = 59;
+ var refused = false;
+ while (rows >= 20) : (rows -= 1) {
+ taken[rows] = wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{rows})).reply.status == .ok;
+ if (refused) try testing.expect(!taken[rows]);
+ if (!taken[rows]) refused = true;
+ }
+ try testing.expect(refused); // the columns are full: some size is too small
+ // Back up: each size is taken again exactly when it was on the way down.
+ rows = 20;
+ while (rows <= 59) : (rows += 1) {
+ const ok = wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{rows})).reply.status == .ok;
+ try testing.expectEqual(taken[rows], ok);
+ }
+ for ([_]u16{ 60, 61, 64, 70, 100 }) |grown| {
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{grown})).reply.status);
+ try testing.expectEqual(grown, p.screen_h);
+ }
+ // Out of range is EINVAL, in words 9ns reads as that.
+ const bad = wr(p, root_ctl, "size 5 2\n");
+ try testing.expectEqual(E.INVAL, bad.errno());
+ try testing.expectStringStartsWith(bad.reply.ename, "invalid size");
+}
+
test "Repl with no such language names a few whole, and where the rest are" {
const p = try th.withTerm(testing.allocator);
defer p.deinit();