From 03773fac517a2a26dc2f2813db9906d3bf7e91db Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 07:15:20 -0300 Subject: `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 --- src/layout.zig | 77 ++++++++++++++++++++++++++++++++++++++++++++----------- src/ninep/ctl.zig | 55 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 114 insertions(+), 18 deletions(-) (limited to 'src') 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 , 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 , at least {d}x{d}, at most {d}x{d}", .{ size_min[0], size_min[1], size_max[0], size_max[1] }); /// `size `, 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(); -- cgit v1.3