summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 06:43:39 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit96383331f09e8af3fa24f767b3c56863309723b8 (patch)
tree12aa88b5e4832623de2c3a413b7b237f6ef34add
parent35e865eba278a1ef48a6ce160767d652a9c54ae8 (diff)
downloadpardes-96383331f09e8af3fa24f767b3c56863309723b8.tar.gz
pardes-96383331f09e8af3fa24f767b3c56863309723b8.zip
A terminal keeps its tag and two body rows through drags, squeezes and a smaller window
A terminal of one body row loses its prompt's mark and reads busy for ever. Placement already refused to make one that short; now layout never leaves one there either: a grip drag stops at the floor, and a column squeezed by weights or by the window takes the rows from its other panes. Text panes keep acme's way and can still be dragged to their tag, since acme has no terminals to follow. The shrinktag, splitparent and cornerdrag goldens are re-recorded for it, shrinktag's re-grab moved to where the grip now is. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/tags.md9
-rw-r--r--src/layout.zig69
-rw-r--r--src/mouse.zig9
-rw-r--r--src/pardes.zig6
-rw-r--r--test/panes.zig28
-rw-r--r--test/snapshots/cornerdrag.golden8
-rw-r--r--test/snapshots/shrinktag.golden20
-rw-r--r--test/snapshots/shrinktag.snap20
-rw-r--r--test/snapshots/splitparent.golden24
-rw-r--r--test/snapshots/splitparent.snap9
10 files changed, 148 insertions, 54 deletions
diff --git a/docs/tags.md b/docs/tags.md
index 3db7f465..46c2defa 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -248,6 +248,15 @@ 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
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.
+
+After placement a terminal keeps that floor too: no drag of a grip, no
+squeeze of its column by the others' weights and no smaller window takes
+it below its tag and two body rows (a one-row terminal loses its prompt's
+mark and would read busy for ever); its neighbours give the rows, and only
+a window too short for every floor leaves it less. A text pane keeps
+acme's way and can be dragged down to its tag alone; acme has no
+terminals to follow here. A folded terminal (Collapse) is a tag by choice.
+`pty/ctl`'s `winsize` likewise gives a pty two rows at least.
A `+Errors` pane goes to any column with room, the last first; with none,
what it would have shown (an Edit's `p` or `=`, a write to `errors`) is
logged as `msg` records, a line each, and the Edit still succeeds.
diff --git a/src/layout.zig b/src/layout.zig
index 644054ed..cbf0fd96 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -274,7 +274,11 @@ pub fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void {
const rb = p.rects[p.col_panes[cc][k + 1]];
const combined: f32 = @floatFromInt(ra.h + rb.h);
var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y);
- nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H));
+ // A terminal keeps its tag and two body rows (floorRows); a text pane
+ // gives up all but its tag, as acme's do.
+ const lo: f32 = @floatFromInt(@max(BOX_H, floorRows(p, p.col_panes[cc][k])));
+ const hi = combined - @as(f32, @floatFromInt(@max(BOX_H, floorRows(p, p.col_panes[cc][k + 1]))));
+ nt = std.math.clamp(nt, @min(lo, @max(@as(f32, BOX_H), hi)), @max(@as(f32, BOX_H), hi));
if (combined <= 0) return;
// Dragging a folded tag open is an explicit resize, so switch this pair
// back to visible-height weights without consuming other folded weights.
@@ -794,16 +798,62 @@ pub fn compute(p: *Pardes) void {
if (vsum <= 0) vsum = 1;
var y: u16 = p.topBarHeight() + p.columnBarHeight();
+ const top = y;
const avail_h = p.screen_h -| y;
- for (0..p.col_n[c]) |k| {
+ var hs: [MAX_PANES]u16 = undefined;
+ const n = p.col_n[c];
+ for (0..n) |k| {
const id = p.col_panes[c][k];
- const pane = p.panes[id] orelse continue;
- const lastk = k + 1 == p.col_n[c];
+ const pane = p.panes[id] orelse {
+ hs[k] = 0;
+ continue;
+ };
+ const lastk = k + 1 == n;
const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum;
const room = p.screen_h -| y;
- const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh)))));
- p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
- y +|= ch;
+ hs[k] = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh)))));
+ y +|= hs[k];
+ }
+ keepTerminalFloors(p, c, hs[0..n]);
+ y = top;
+ for (0..n) |k| {
+ const id = p.col_panes[c][k];
+ if (p.panes[id] == null) continue;
+ p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = hs[k] };
+ y +|= hs[k];
+ }
+ }
+}
+
+/// The fewest rows a pane is laid out with: a terminal keeps its tag and
+/// `min_body_rows` (a shorter one loses its prompt and reads busy for
+/// ever), a text pane may shrink to its tag, as acme's windows do.
+fn floorRows(p: *Pardes, id: usize) u16 {
+ const pane = p.panes[id] orelse return 0;
+ return if (pane.isTerminal()) @max(1, pane.tag_rows) + min_body_rows else 1;
+}
+
+/// Raises each terminal in column `c` below its floor to it, a row at a
+/// time from whichever pane has the most rows above its own floor. Where
+/// the column has not the rows, the terminal keeps what it could get.
+// ponytail: a row per step, O(panes x rows); a column holds few of both.
+fn keepTerminalFloors(p: *Pardes, c: usize, hs: []u16) void {
+ for (hs, 0..) |*h, k| {
+ const floor = floorRows(p, p.col_panes[c][k]);
+ while (h.* < floor) {
+ var giver: ?usize = null;
+ var spare: u16 = 0;
+ for (hs, 0..) |other, j| {
+ if (j == k) continue;
+ const extra = other -| floorRows(p, p.col_panes[c][j]);
+ if (extra > spare) {
+ spare = extra;
+ giver = j;
+ }
+ }
+ const g = giver orelse break;
+ hs[g] -= 1;
+ h.* += 1;
}
}
}
@@ -911,9 +961,10 @@ test "collapse local transfers remain bounded through repeated tiny window resiz
test "collapse expansion is limited to space available in one neighbor" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 42 });
defer p.deinit();
+ // Text panes: a terminal would keep its tag and two rows (floorRows).
for (1..4) |id| {
- _ = try p.newShell(id, "");
- insert(p, 0, id, id);
+ p.newScratchBelow(0);
+ try std.testing.expectEqual(id, p.col_panes[0][id]);
}
compute(p);
toggleCollapse(p, 2);
diff --git a/src/mouse.zig b/src/mouse.zig
index cb5512ad..9eefa9a1 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -109,20 +109,23 @@ test "a pane's last row of text is no resize handle; the rule over the next pane
try std.testing.expect(nudgeRightSeam(p, 2) == seam + 2);
}
-test "a pane's grip dragged up or down its own column moves its top, down to a tag alone above" {
+test "a pane's grip dragged up or down its own column moves its top, down to the floor of the pane above" {
if (platform == .web) return;
const p = try cornerFixture(std.testing.allocator);
defer p.deinit();
const upper = p.col_panes[1][0];
const lower = p.col_panes[1][1];
const grip_x = p.rects[lower].x;
- // Up, past the pane above's first row: it keeps one row, its tag.
+ // Up, past the pane above's first row: a text pane keeps its tag, a
+ // terminal its tag and two rows (layout.floorRows).
const top = p.rects[upper].y;
+ const above = p.panes[upper].?;
+ const floor: u16 = if (above.isTerminal()) above.tag_rows + layout.min_body_rows else 1;
p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = grip_x, .row = p.rects[lower].y } });
p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = grip_x, .row = top } });
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = grip_x, .row = top } });
p.sync();
- try std.testing.expectEqual(@as(u16, 1), p.rects[upper].h);
+ try std.testing.expectEqual(floor, p.rects[upper].h);
try std.testing.expectEqual(lower, p.col_panes[1][1]);
// Down, inside itself: its top follows, the pane above taking the rows.
const to = p.rects[lower].y + 6;
diff --git a/src/pardes.zig b/src/pardes.zig
index 8e9d3c7e..98b8819c 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -6598,9 +6598,9 @@ pub const Pardes = struct {
pane.tag_top = @intCast(@min(top, lines - pane.tag_rows));
} else pane.tag_top = 0;
const cols = @max(1, r.w -| config.GUTTER);
- // The tag steals the top rows. The pty is what the pane shows,
- // even one row dragged short: a taller pty would put its cursor
- // below the pane (only winsize keeps two, ctlfs.pty).
+ // The tag steals the top rows. The pty is what the pane shows
+ // (layout keeps a terminal two body rows where the window has
+ // them): a taller pty would put its cursor below the pane.
const rows = @max(1, 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
diff --git a/test/panes.zig b/test/panes.zig
index ca53af3d..42dd6b77 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -4891,7 +4891,7 @@ test {
_ = LookResetTests;
}
-test "a terminal's pty follows its pane down to its last row" {
+test "a terminal keeps its tag and two body rows through a squeeze, a drag and a smaller window; a text pane does not" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
defer p.deinit();
p.presentation.enabled = false;
@@ -4903,16 +4903,34 @@ test "a terminal's pty follows its pane down to its last row" {
p.newScratchBelow(term);
p.sync();
while (p.nextEffect()) |_| {}
- // The terminal squeezed to its tag alone, folded by nobody.
+ const col = layout.findPane(p, term).?.col;
+ const floor = p.panes[term].?.tag_rows + layout.min_body_rows;
+ // Squeezed by the weights: the others give it its floor.
p.panes[term].?.vweight = 0.01;
- for (p.col_panes[0][0..p.col_n[0]]) |k| if (k != term) {
+ for (p.col_panes[col][0..p.col_n[col]]) |k| if (k != term) {
p.panes[k].?.vweight = 1000;
};
p.sync();
- try std.testing.expect(p.rects[term].h <= p.panes[term].?.tag_rows);
+ try std.testing.expectEqual(floor, p.rects[term].h);
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);
+ try std.testing.expectEqual(@as(?u16, layout.min_body_rows), rows);
+ // A drag of the grip under it up to its tag stops at the floor; one
+ // the other way takes a text pane down to its tag alone.
+ const k = layout.findPane(p, term).?.idx;
+ if (k + 1 < p.col_n[col]) {
+ layout.applyRowSplit(p, col, k, p.rects[term].y);
+ p.sync();
+ try std.testing.expectEqual(floor, p.rects[term].h);
+ const text = p.col_panes[col][k + 1];
+ layout.applyRowSplit(p, col, k, p.screen_h);
+ p.sync();
+ try std.testing.expect(p.rects[text].h <= p.panes[text].?.tag_rows);
+ }
+ // A window too short for all: the terminal keeps its floor still.
+ p.update(.{ .resize = .{ .cols = 100, .rows = 10 } });
+ p.sync();
+ try std.testing.expect(p.rects[term].h >= p.panes[term].?.tag_rows + layout.min_body_rows);
}
diff --git a/test/snapshots/cornerdrag.golden b/test/snapshots/cornerdrag.golden
index 730cc2bf..30c1830a 100644
--- a/test/snapshots/cornerdrag.golden
+++ b/test/snapshots/cornerdrag.golden
@@ -243,7 +243,7 @@
|24:
|25:
|26:
-|27:
-|28:
-|29:
-|30: /tmp/pardes-snap/cornerdrag/cwd Tty+bash Save Mode Filter
+|27: /tmp/pardes-snap/cornerdrag/cwd Tty+bash Save Mode Filter
+|28: Collapse Del
+|29: w.txt
+|30:
diff --git a/test/snapshots/shrinktag.golden b/test/snapshots/shrinktag.golden
index 552722f9..9aa29e11 100644
--- a/test/snapshots/shrinktag.golden
+++ b/test/snapshots/shrinktag.golden
@@ -21,23 +21,25 @@
| ls
| w.txt
== snap shrunk grid=100x31 cursor=2,9
-|3: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo de Filter Collapse Del
-|4: de Filter Collapse Del ls
-|5: ls w.txt
-|6: w.txt
-|8:
+|4: w.txt ls
+|5: w.txt
+|6: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo
+|7: de Filter Collapse Del
+|8: ls
+|9: w.txt
|17:
|18:
|19:
|20:
== snap move-preview grid=100x31 cursor=2,9
-|8: ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌▌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+|8: ls ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌▌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
== snap restored grid=100x31 cursor=2,9
-|3: de Filter Collapse Del de Filter Collapse Del
|4: ls ls
|5: w.txt w.txt
|6:
+|7:
|8: echo one; echo two
+|9:
|17: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo
|18: de Filter Collapse Del
|19: ls
@@ -47,4 +49,6 @@
|18:
|19:
|20:
-|30: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo
+|27: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo
+|28: de Filter Collapse Del
+|29: w.txt
diff --git a/test/snapshots/shrinktag.snap b/test/snapshots/shrinktag.snap
index 31edad14..5264be74 100644
--- a/test/snapshots/shrinktag.snap
+++ b/test/snapshots/shrinktag.snap
@@ -1,8 +1,8 @@
-# a pane can shrink to just its tagline: the pane below dragged by its grip up
-# to the tag row (0 body rows) -- a terminal has no rule between panes, so
-# the grip is the handle, as acme's button is -- the gutter box still grabs a
-# move, and dragging back down restores the body intact (the hidden pane's
-# pty/vt is never resized)
+# a terminal shrinks only to its tag and 2 body rows (a text pane would go
+# to its tag alone): the pane below dragged by its grip up to the tag row
+# stops there -- a terminal has no rule between panes, so the grip is the
+# handle, as acme's button is -- the gutter box still grabs a move, and
+# dragging back down restores the body intact
file w.txt x
start 31 100 -n 3
wait 8000 w.txt
@@ -18,7 +18,7 @@ key esc
settle 100
stable 400 5000
snap before
-# drag the lower pane's grip up under pane 0's tag row: tag-only pane
+# drag the lower pane's grip up under pane 0's tag row: it keeps 2 body rows
press left 1 18
drag left 1 3
release left 1 3
@@ -34,14 +34,16 @@ snap move-preview
drag left 20 3
release left 20 3
stable 400 5000
-# re-enlarge: the lower pane's grip, now under pane 0's tag, pulled back down
-press left 1 4
+# re-enlarge: the lower pane's grip, now under pane 0's 2 body rows, pulled
+# back down
+press left 1 7
drag left 1 18
release left 1 18
motion 40 25
stable 500 8000
snap restored
-# the bottom pane can shrink to ITS tag too: its grip dragged past the bottom
+# the bottom pane stops at ITS tag and 2 rows too: its grip dragged past the
+# bottom
press left 1 18
drag left 1 31
release left 1 31
diff --git a/test/snapshots/splitparent.golden b/test/snapshots/splitparent.golden
index 48dcf0c8..e626cea7 100644
--- a/test/snapshots/splitparent.golden
+++ b/test/snapshots/splitparent.golden
@@ -2,13 +2,16 @@
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-| /tmp/pardes-snap/splitparent/cwd Tty+bash Save Mode Filter Collapse Del
-| Mode Filter Collapse Del ls
+| Mode Filter Collapse Del Mode Filter Collapse Del
+| ls ls
+|
+| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
+| Mode Filter Collapse Del
| ls
-== snap swap-parent grid=100x31 cursor=4,10
-|7: /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-|8: Mode Filter Collapse Del
-|9: ls
+== snap swap-parent grid=100x31 cursor=4,13
+|10: /tmp/pardes-snap/splitparent/cwd Tty+bash Save
+|11: Mode Filter Collapse Del
+|12: ls
== snap tallest grid=100x6 cursor=4,5
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
@@ -16,12 +19,15 @@
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save Mode Filter Collapse Del
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save ls
| e in that column: each keeps its tag and 2 rows
-== snap regrown grid=100x31 cursor=4,10
+== snap regrown grid=100x31 cursor=4,13
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-| /tmp/pardes-snap/splitparent/cwd Tty+bash Save Mode Filter Collapse Del
-| Mode Filter Collapse Del ls
+| Mode Filter Collapse Del Mode Filter Collapse Del
+| ls ls
+|
+| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
+| Mode Filter Collapse Del
| ls
|
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
diff --git a/test/snapshots/splitparent.snap b/test/snapshots/splitparent.snap
index 14391467..93ed9e21 100644
--- a/test/snapshots/splitparent.snap
+++ b/test/snapshots/splitparent.snap
@@ -1,4 +1,4 @@
-# a new pane never opens under a too-short parent: Alt-n from a tag-only pane
+# a new pane never opens under a too-short parent: Alt-n from a pane too short to halve
# splits the tall sibling in its column instead; when NO pane could leave the
# newcomer 2 body rows, the tallest one anywhere is split, and when that one
# cannot keep its own tag and 2 rows either, the pane is refused
@@ -6,15 +6,16 @@
config Placement pardes
start 31 100 -n 3
stable 700 20000
-# shrink pane 0 (top-left) to just its tagline: the pane below's grip dragged
-# up under it (a terminal has no rule between panes; the grip is the handle)
+# shrink pane 0 (top-left), a terminal, as far as it goes, its tag and 2 body
+# rows: the pane below's grip dragged up under its tag (a terminal has no
+# rule between panes; the grip is the handle)
press left 1 18
drag left 1 3
release left 1 3
motion 40 25
stable 500 8000
snap shrunk
-# Alt-n from the tag-only pane: the new shell opens below the tall sibling
+# Alt-n from the short pane: the new shell opens below the tall sibling
# underneath it, not as another 1-row sliver
key a-n
stable 700 15000