aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 17:01:47 +0200
committeruser <user@clank>2026-08-14 17:01:47 +0200
commit954989dc58c6f98593cc97cb1cc43a3833cb8d22 (patch)
tree4f7414b153d1dfca1f473015ee4e4e0f572b78cd
parentsl0ppty: the tab strip answers the pointer (diff)
downloadslosh-954989dc58c6f98593cc97cb1cc43a3833cb8d22.tar.gz
slosh-954989dc58c6f98593cc97cb1cc43a3833cb8d22.tar.xz
slosh-954989dc58c6f98593cc97cb1cc43a3833cb8d22.zip
sl0ppty: a pane has to clear the floor in both directions
Two panes stacked on top of each other could be squeezed to four columns wide and nothing collapsed, at any width. The fit check lived only on split nodes, and a split node only ever asks about the dimension it divides: a row split asks about height and never about width. A leaf was asked nothing at all. So for a stacked layout there was no node in the tree responsible for noticing that its panes had become unusably narrow — the check existed, it was just looking the other way. A leaf is the only node that can see both of its own sides, so it now checks both. The floor is per pane and the same in every arrangement, which is what made this a hole rather than a missing feature. That immediately made resizing dangerous, which is the more interesting half. Dragging a divider one cell too far could push a pane under the floor and collapse the entire tab into a list — a drastic answer to a nudge, and it looked like the session falling over rather than a limit being reached. So the transfer stops at the floor instead, the way every other resizable thing stops. The clamp went into transfer_weight rather than into the caller, because the mouse reaches it through drag_edge and the keyboard through resize_focus, and my first attempt clamped only the mouse. A limit that one of two paths respects is worse than none: it looks fixed until you use the other hand. It also has to round the weight *up*. The weight that buys exactly `floor_` cells is a fraction, and rounding it down buys one cell fewer — which is precisely the cell that puts the pane under the floor. The first version rounded down and collapsed anyway, one drag later than before. Two tests changed with real content rather than arithmetic: one used a seventy-six by eight screen whose panes were four rows tall, which is under the floor and should always have collapsed, and one asserted a squeezed pane stayed "at least 3 columns" when it now stops at 24 exactly.
-rw-r--r--src/app.c44
-rw-r--r--tests/test_drag.py33
-rw-r--r--tests/test_layout.py2
-rw-r--r--tests/test_responsive.py49
4 files changed, 123 insertions, 5 deletions
diff --git a/src/app.c b/src/app.c
index cc650bd..e7cc7fd 100644
--- a/src/app.c
+++ b/src/app.c
@@ -754,6 +754,14 @@ static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) {
};
if (ctx->apply && !n->hidden)
pane_resize(n->pane, n->content.w, n->content.h);
+
+ /* A pane has to clear the floor in *both* directions, whatever arrangement
+ * produced it. The check on a split node only ever asks about the
+ * dimension that node divides — a row split asks about height and never
+ * about width — so a column of stacked panes could be squeezed to four
+ * cells wide with nothing in the tree responsible for noticing. The leaf
+ * is the only place that can see both of its own sides. */
+ if (r.w < MIN_PANE_COLS || r.h < MIN_PANE_ROWS) ctx->overflow = true;
return;
}
@@ -1324,9 +1332,40 @@ static size_t index_in_parent(node_t *n) {
}
/* Move weight between two adjacent siblings, refusing to squeeze either out. */
+/* `from` always shrinks. It stops at the floor rather than being pushed under
+ * one: a pane below the floor collapses the whole tab into a list, and having
+ * that happen because you nudged a divider one cell too far would read as the
+ * session falling over rather than as a limit being reached. Every other
+ * resizable thing simply stops, so this does too.
+ *
+ * The clamp lives here because both ways of resizing end up here — the mouse
+ * through drag_edge and the keyboard through resize_focus — and a limit that
+ * only one of them respected would be worse than none. */
static void transfer_weight(node_t *from, node_t *to, int amount) {
- if (from->weight - amount < WEIGHT_MIN) amount = from->weight - WEIGHT_MIN;
- if (amount <= 0) return;
+ long min_weight = WEIGHT_MIN;
+
+ node_t *sp = from->parent;
+ if (sp && sp->nkids >= 2) {
+ uint16_t floor_ = sp->dir == SPLIT_COLS ? MIN_PANE_COLS : MIN_PANE_ROWS;
+ uint16_t gap = sp->dir == SPLIT_COLS ? (uint16_t)(CFG.gap * CFG.gap_aspect)
+ : CFG.gap;
+ uint16_t span = sp->dir == SPLIT_COLS ? sp->rect.w : sp->rect.h;
+ uint16_t gaps = (uint16_t)(gap * (sp->nkids - 1));
+ uint16_t avail = span > gaps ? (uint16_t)(span - gaps) : span;
+ long total = 0;
+ for (size_t k = 0; k < sp->nkids; k++) total += sp->kids[k]->weight;
+ /* Rounded up: the weight that buys exactly `floor_` cells is a fraction,
+ * and rounding down buys one cell fewer — which is the cell that puts the
+ * pane under the floor. */
+ if (avail && total > 0) {
+ long need = ((long)floor_ * total + avail - 1) / avail;
+ if (need > min_weight) min_weight = need;
+ }
+ }
+
+ if (from->weight - amount < min_weight)
+ amount = (int)(from->weight - min_weight);
+ if (amount <= 0) return; /* already at the floor: the nudge does nothing */
from->weight -= amount;
to->weight += amount;
}
@@ -2369,6 +2408,7 @@ static void drag_edge(app_t *a, node_t *sp, size_t i, int cells) {
int amount = (int)((long)labs(cells) * total / (span ? span : 1));
if (amount <= 0) amount = 1;
+
if (cells > 0) transfer_weight(sp->kids[i + 1], sp->kids[i], amount);
else transfer_weight(sp->kids[i], sp->kids[i + 1], amount);
layout(a);
diff --git a/tests/test_drag.py b/tests/test_drag.py
index b2867d9..f3bef62 100644
--- a/tests/test_drag.py
+++ b/tests/test_drag.py
@@ -61,8 +61,13 @@ def test_keyboard_resize():
s.send(r"\x01L")
s.settle()
squeezed = widths(s)
- check("a pane can be squeezed but not squeezed out", squeezed[1] >= 3,
- str(squeezed))
+ # It stops at min_pane rather than being pushed under it. Going under
+ # would put a pane below the floor, which collapses the whole tab into
+ # a list -- a drastic answer to "you nudged the divider once more".
+ check("a pane is squeezed down to the floor and no further",
+ squeezed[1] == 24, str(squeezed))
+ check("and nothing collapsed on the way",
+ not any(p["hidden"] for p in s.panes()), str(s.panes()))
check("the total is still the screen",
sum(squeezed) + 2 + 4 == 80, str(squeezed))
@@ -393,8 +398,32 @@ def test_a_row_boundary_names_its_own_verb():
pos and top["y"] + top["h"] <= pos[1] < bottom["y"], str(pos))
+def test_dragging_a_boundary_also_stops_at_the_floor():
+ """The mouse and the keyboard resize through the same clamp; a limit only
+ one of them respected would be worse than none."""
+ with Session(SH, cols=80, rows=16, config=FAST) as s:
+ s.settle()
+ s.key("\\\\")
+ s.settle()
+ left, right = s.panes()
+ gx, gy = left["x"] + left["w"], left["y"] + 3
+
+ # drag the boundary far past where the right pane could survive
+ s.send(rf"\e[<0;{gx + 1};{gy + 1}M")
+ s.send(rf"\e[<32;{gx + 60};{gy + 1}M")
+ s.send(rf"\e[<0;{gx + 60};{gy + 1}m")
+ s.settle(60)
+
+ panes = s.panes()
+ check("the dragged-against pane stops at the floor",
+ panes[1]["w"] == 24, str([p["w"] for p in panes]))
+ check("and the tab did not collapse into a list",
+ not any(p["hidden"] for p in panes), str(panes))
+
+
if __name__ == "__main__":
test_keyboard_resize()
+ test_dragging_a_boundary_also_stops_at_the_floor()
test_vertical_resize()
test_resize_survives_a_collapse_cycle()
test_edge_drag()
diff --git a/tests/test_layout.py b/tests/test_layout.py
index 87339d4..5992168 100644
--- a/tests/test_layout.py
+++ b/tests/test_layout.py
@@ -122,7 +122,7 @@ def test_frames_and_gap():
pane["content_x"] == pane["x"] + 1 and pane["content_y"] == pane["y"] + 1,
str(pane))
- with Session(SH, cols=76, rows=8) as s:
+ with Session(SH, cols=76, rows=12) as s:
s.settle()
s.key("\\\\")
s.settle()
diff --git a/tests/test_responsive.py b/tests/test_responsive.py
index e68c773..363a5f7 100644
--- a/tests/test_responsive.py
+++ b/tests/test_responsive.py
@@ -441,6 +441,53 @@ def test_the_title_inset_is_configurable():
os.unlink(lay.name)
+def test_width_collapses_a_stacked_layout_too():
+ """The floor is per pane and both-ways, not per split.
+
+ A split node only ever asks about the dimension it divides, so a column of
+ stacked panes asked about height and never about width -- and could be
+ squeezed to a few cells wide with nothing in the tree responsible for
+ noticing. Whatever arrangement produced a pane, it has to clear the floor
+ on both of its own sides.
+ """
+ with Session(SH, cols=100, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="rows") # stacked: width is the perpendicular axis
+ s.settle(20)
+ check("two stacked panes, both open, at a comfortable width",
+ len([p for p in s.panes() if not p["hidden"]]) == 2, str(s.panes()))
+
+ s.resize(60, 24)
+ s.settle(20)
+ check("still open while each pane clears the floor",
+ not any(p["hidden"] for p in s.panes()), str(s.panes()))
+
+ s.resize(26, 24)
+ s.settle(20)
+ check("narrow enough, and a stacked layout collapses like any other",
+ any(p["hidden"] for p in s.panes()), str(s.panes()))
+
+ s.resize(100, 24)
+ s.settle(20)
+ check("and comes back when there is room again",
+ not any(p["hidden"] for p in s.panes()), str(s.panes()))
+
+
+def test_height_collapses_a_side_by_side_layout_too():
+ """The same hole, the other way round."""
+ with Session(SH, cols=100, rows=24) as s:
+ s.settle(20)
+ s.api("split", dir="cols") # side by side: height is perpendicular
+ s.settle(20)
+ check("two panes side by side at a comfortable height",
+ not any(p["hidden"] for p in s.panes()), str(s.panes()))
+
+ s.resize(100, 9)
+ s.settle(20)
+ check("short enough, and a side-by-side layout collapses",
+ any(p["hidden"] for p in s.panes()), str(s.panes()))
+
+
if __name__ == "__main__":
test_collapse()
test_collapse_expand_cycle()
@@ -457,4 +504,6 @@ if __name__ == "__main__":
test_hovering_a_row_does_not_open_it()
test_a_header_is_shaped_like_a_pane_top()
test_the_title_inset_is_configurable()
+ test_width_collapses_a_stacked_layout_too()
+ test_height_collapses_a_side_by_side_layout_too()
sys.exit(report())