From 84d23aef86c0bcb6ee14bb69b1e4d6d65205e2f6 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 03:39:43 -0300 Subject: size says its limits, keeps every pane its rows, and ptys follow their panes size refused an out-of-range line without saying the range, took a size too small for the panes (leaving bodies of no rows), and a terminal squeezed to its tag kept a pty of its old size. The refusal now names 20x6 to 4096x4096 (a bare size is EINVAL like any malformed line), a size that would leave any pane less than its tag and 2 rows is refused and undone, and a terminal's pty is resized with its pane down to one row (only a folded or unplaced one keeps its grid). Co-Authored-By: Claude Opus 5.5 --- docs/fs.md | 4 +++- src/layout.zig | 14 ++++++++++++++ src/ninep/ctl.zig | 32 +++++++++++++++++++++++++++----- src/pardes.zig | 5 ++++- test/panes.zig | 26 ++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 7 deletions(-) diff --git a/docs/fs.md b/docs/fs.md index cf6acb60..03851d8d 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -106,7 +106,9 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. which makes its column the active one as a click there would /ctl the settings, one a line as a write takes them; write a setting or a session builtin; `size ` sets the screen of a session no frontend is attached to - (`--detach`, 80x24 until then; refused while a frontend owns the size) + (`--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 /commands every builtin: word, `arg` if it takes one, `root` or `pane` (the ctl that takes it), a setting's values /layout one line per column, left to right: serial index x width current|notcurrent empty|full pane-serials...; then active : the active column, where diff --git a/src/layout.zig b/src/layout.zig index 4c974fb7..bdef9675 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -433,6 +433,20 @@ 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` 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 { diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index dd2d9ed0..dd382290 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -473,11 +473,22 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply { while (it.next()) |line| { if (line.len == 0) continue; if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) { - const size = sizeOf(line) orelse return refuse(p, req, "size takes ", line); + const size = sizeOf(line) orelse return refuse(p, req, e_size, line); // A frontend's window is the size; only a session none is // attached to (--detach, unattended) takes one written. if (!p.unattended) return refuse(p, req, "size: a frontend is attached and owns the size", line); - if (apply) p.update(.{ .resize = .{ .cols = size[0], .rows = size[1] } }); + if (apply) { + const was: [2]u16 = .{ p.screen_w, p.screen_h }; + 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)) { + 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); + } + } continue; } if (!apply) { @@ -493,12 +504,16 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -/// `size `, each at least a few cells and at most 4096. +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] }); + +/// `size `, within size_min and size_max. fn sizeOf(line: []const u8) ?[2]u16 { var it = std.mem.tokenizeAny(u8, line["size".len..], " \t"); const cols = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; const rows = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; - if (it.next() != null or cols < 20 or rows < 4 or cols > 4096 or rows > 4096) return null; + if (it.next() != null or cols < size_min[0] or rows < size_min[1] or cols > size_max[0] or rows > size_max[1]) return null; return .{ cols, rows }; } @@ -1490,5 +1505,12 @@ test "size sets the screen of a session no frontend is attached to, and only of try testing.expectEqual(@as(u16, 120), p.screen_w); try testing.expectEqual(@as(u16, 40), p.screen_h); try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120\n").errno()); - try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 5 2\n").errno()); + try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size\n").errno()); + try testing.expect(std.mem.indexOf(u8, wr(p, root_ctl, "size 5 2\n").reply.ename, "at least 20x6, at most 4096x4096") != null); + // Too small for the panes it has, each its tag and two rows: refused, + // the size as it was. + for (0..4) |_| p.newScratchBelow(p.active); + p.sync(); + try testing.expect(std.mem.indexOf(u8, wr(p, root_ctl, "size 80 8\n").reply.ename, "too small for the panes") != null); + try testing.expectEqual(@as(u16, 40), p.screen_h); } diff --git a/src/pardes.zig b/src/pardes.zig index 72fd4d46..c103b9d7 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -6527,7 +6527,10 @@ pub const Pardes = struct { } else pane.tag_top = 0; const cols = @max(1, r.w -| config.GUTTER); const rows = @max(1, r.h -| pane.tag_rows); // the tag steals the top rows - if ((cols != pane.cols or rows != pane.rows) and r.h > pane.tag_rows) { + // A terminal's pty follows its pane down to a row, whatever the + // resize; only a folded one keeps the size it had, and one not + // placed yet the grid it was made with. + if ((cols != pane.cols or rows != pane.rows) and (r.h > pane.tag_rows or (pane.isTerminal() and !pane.collapsed and r.h > 0))) { // doc panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows if (pane.isTerminal()) { diff --git a/test/panes.zig b/test/panes.zig index 25f0f6db..ca53af3d 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -4890,3 +4890,29 @@ const LookResetTests = struct { test { _ = LookResetTests; } + +test "a terminal's pty follows its pane down to its last row" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + p.presentation.enabled = false; + p.sync(); + while (p.nextEffect()) |_| {} + const term = for (p.panes, 0..) |slot, i| { + if (slot) |pane| if (pane.isTerminal()) break i; + } else return error.NoTerminal; + p.newScratchBelow(term); + p.sync(); + while (p.nextEffect()) |_| {} + // The terminal squeezed to its tag alone, folded by nobody. + p.panes[term].?.vweight = 0.01; + for (p.col_panes[0][0..p.col_n[0]]) |k| if (k != term) { + p.panes[k].?.vweight = 1000; + }; + p.sync(); + try std.testing.expect(p.rects[term].h <= p.panes[term].?.tag_rows); + var rows: ?u16 = null; + while (p.nextEffect()) |effect| if (effect == .resize_pty and effect.resize_pty.pane == term) { + rows = effect.resize_pty.rows; + }; + try std.testing.expectEqual(@as(?u16, 1), rows); +} -- cgit v1.3