diff options
| author | user <user@clank> | 2026-08-14 16:12:17 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-14 16:12:17 +0200 |
| commit | b2d7da47ef233c8175e27309ab0f741949420283 (patch) | |
| tree | d485554f11ba14f8f5f90bc837b6616f95d2ca37 | |
| parent | sl0ppty: collapsing flattens the tree instead of nesting headers (diff) | |
| download | slosh-b2d7da47ef233c8175e27309ab0f741949420283.tar.gz slosh-b2d7da47ef233c8175e27309ab0f741949420283.tar.xz slosh-b2d7da47ef233c8175e27309ab0f741949420283.zip | |
sl0ppty: a tab is laid out or it is a list, never half of each
Collapsing was a local decision: the subtree that could not fit flattened and
everything else carried on. In practice that produces a screen with a
full-height pane on the left and a stack of headers on the right, which
explains neither what just happened nor what to do about it. Seeing it is
enough to know it is wrong.
The question is now asked of the whole tab and answered once. If any node
cannot give its children their floor, every pane in the tab becomes a row and
the focused one opens below them — including the panes that would have fitted
perfectly well on their own.
That costs something real and it is worth being honest about it: a wide screen
with one cramped column now flattens entirely, where before it would have kept
the rest of the layout. The trade is that there is one collapsed state instead
of a spectrum of partial ones, and a rule you can hold in your head is worth
more here than squeezing the last working layout out of a bad size.
Mechanically this made layout simpler rather than more complex. layout_node no
longer decides anything about presentation; a node that cannot fit sets a flag
and returns. Laying out happens twice — once to ask, once to do — and the probe
is the real layout with resizing switched off rather than a second copy of the
fitting arithmetic, because two copies of "does this fit" is exactly how the
split guide and the layout would begin to disagree. The probe not resizing is
the load-bearing part: a pane it lays out normally may be a header by the time
the real pass runs, and a pane about to be collapsed must not be told a size it
will never show.
| -rw-r--r-- | DESIGN.md | 2 | ||||
| -rw-r--r-- | src/app.c | 69 | ||||
| -rw-r--r-- | tests/test_responsive.py | 36 |
3 files changed, 89 insertions, 18 deletions
@@ -148,7 +148,7 @@ unreadable. | **D3** | **Clean JSON control API** (one JSON object per line on a unix socket): `{"cmd":"new-tab","purpose":"project:x.deadbeef"}` -> `{"ok":true,"id":2}`. We do *not* mimic `zellij action`'s surface. A bare-verb form (`panes`, `snapshot text`) is kept as a human/harness alias and runs the same code, so a script cannot drift from what the API does. The `sl0ppi` CLI is ported on top later; `up`'s idempotency and D9's fail-open property carry over. | | **D4** | **Scrollback yes** (free from lib-vt), **copy-mode UI later.** | | **D5** | Binary, session dir, socket and config are all named **`sl0ppty`**. | -| **D6** | **Responsive layout is a pure function** — see below. A subtree that does not fit **flattens**: every pane under it becomes one row in a flat list with the focused one opened below them, rather than a nest of headers. Nesting is exactly what there is no room to express, and in that state a reader wants *which pane*, not *which arrangement*. It also makes every pane reachable — collapsing a subtree to a single header gave every leaf inside it the same rect and one hit for the first of them, so the rest could not be clicked at all. | +| **D6** | **Responsive layout is a pure function** — see below. If *any* node cannot give its children their floor, the **whole tab** flattens — asked of the tab, answered once, because half a screen of panes beside half a screen of headers explains neither what happened nor what to do about it. The check is the real layout run with resizing switched off, not a second implementation of the same arithmetic. Flattening means: every pane under it becomes one row in a flat list with the focused one opened below them, rather than a nest of headers. Nesting is exactly what there is no room to express, and in that state a reader wants *which pane*, not *which arrangement*. It also makes every pane reachable — collapsing a subtree to a single header gave every leaf inside it the same rect and one hit for the first of them, so the rest could not be clicked at all. | | **D7** | **Server renders, client is dumb.** The client forwards raw bytes and paints what it is sent — it does not even decode, so the decoder can change without redeploying clients, and a bug there costs a frame rather than a session. The same socket is the scripting API. Detach/reattach is table stakes because agents keep running. | | **D8** | **Panes and tabs carry `purpose=`**, sl0ppi's semantics kept verbatim, including the trust model: *declared purposes outrank in-band ones and cannot be overridden*, so `cat hostile.txt` cannot relabel a project tab. Declared means "from a layout or an operator", i.e. the control path; the in-band path (OSC, M4) always passes `declared:false` and is refused against a locked slot. Sanitised on ingest to `[A-Za-z0-9_.:/-]`. | | **D9** | **No wasm plugins.** Status bar and pane finder are built in. A "plugin" is a subprocess speaking the control protocol. | @@ -550,7 +550,17 @@ static node_t *first_leaf_of(node_t *n) { return n; } -static void layout_node(node_t *n, rect_t r, node_t *focus); +/* Laying out happens twice: once to ask whether the tab fits at all, and once + * to do it. The probe must not resize any pane, because a pane it lays out + * normally may be a header by the time the real pass is done, and a pane that + * is about to be collapsed must not be told a size it will never show. */ +typedef struct { + node_t *focus; + bool apply; /* false during the probe */ + bool overflow; /* some node could not give its children their floor */ +} layout_ctx_t; + +static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx); /* The collapse: the hierarchy stops existing for as long as it does not fit. * @@ -572,14 +582,14 @@ static void layout_node(node_t *n, rect_t r, node_t *focus); * * Recomputed from the rect every frame, so there is no state to go stale — * which is the whole argument for D6 over swap layouts. */ -static void layout_stack(node_t *n, rect_t r, node_t *focus) { +static void layout_stack(node_t *n, rect_t r, layout_ctx_t *ctx) { node_t *leaves[64]; size_t nl = collect_leaves(n, leaves, 64, 0); if (!nl) return; size_t expanded = 0; for (size_t i = 0; i < nl; i++) - if (focus && leaves[i] == focus) expanded = i; + if (ctx->focus && leaves[i] == ctx->focus) expanded = i; span_splits(n, r); @@ -592,28 +602,28 @@ static void layout_stack(node_t *n, rect_t r, node_t *focus) { collapse_leaf(leaves[i], (rect_t){r.x, y, r.w, 1}); y = (uint16_t)(y + 1); } - layout_node(leaves[expanded], (rect_t){r.x, y, r.w, body}, focus); + layout_node(leaves[expanded], (rect_t){r.x, y, r.w, body}, ctx); } /* Not even room for one row per pane plus something to look at: show the * focused pane and nothing else. The alternative is rects that do not fit on * the screen, and a pane one cell tall helps no one. */ -static void layout_solo(node_t *n, rect_t r, node_t *focus) { +static void layout_solo(node_t *n, rect_t r, layout_ctx_t *ctx) { node_t *leaves[64]; size_t nl = collect_leaves(n, leaves, 64, 0); if (!nl) return; size_t expanded = 0; for (size_t i = 0; i < nl; i++) - if (focus && leaves[i] == focus) expanded = i; + if (ctx->focus && leaves[i] == ctx->focus) expanded = i; span_splits(n, r); for (size_t i = 0; i < nl; i++) if (i != expanded) collapse_leaf(leaves[i], (rect_t){r.x, r.y, 0, 0}); - layout_node(leaves[expanded], r, focus); + layout_node(leaves[expanded], r, ctx); } -static void layout_node(node_t *n, rect_t r, node_t *focus) { +static void layout_node(node_t *n, rect_t r, layout_ctx_t *ctx) { n->rect = r; n->collapsed = false; if (n->kind == NODE_LEAF) n->hidden = false; @@ -628,7 +638,8 @@ static void layout_node(node_t *n, rect_t r, node_t *focus) { .w = (uint16_t)(r.w > 2 * bx ? r.w - 2 * bx : 1), .h = (uint16_t)(r.h > 2 * by ? r.h - 2 * by : 1), }; - if (!n->hidden) pane_resize(n->pane, n->content.w, n->content.h); + if (ctx->apply && !n->hidden) + pane_resize(n->pane, n->content.w, n->content.h); return; } @@ -642,18 +653,18 @@ static void layout_node(node_t *n, rect_t r, node_t *focus) { uint16_t floor_ = n->dir == SPLIT_COLS ? MIN_PANE_COLS : MIN_PANE_ROWS; uint16_t need = (uint16_t)(k * floor_ + gap * (k - 1)); if (total < need) { - /* The stack is one row per pane in the whole subtree, not per child, plus - * a body worth opening into — three rows, since a frame is two of them. */ - size_t nl = count_leaves(n); - if (r.h >= (uint16_t)(nl + 2)) layout_stack(n, r, focus); - else layout_solo(n, r, focus); + /* Report and stop. Whether this becomes a stack is not this node's call + * any more: a tab is either laid out or it is a list, and half a screen of + * each is the state nobody wants to be looking at. layout() decides, once, + * for the whole tab. */ + ctx->overflow = true; return; } uint16_t gaps = (uint16_t)(gap * (k - 1)); uint16_t avail = total > gaps ? (uint16_t)(total - gaps) : (uint16_t)k; if (avail / k == 0) { /* below one cell each, nothing sensible is left */ - layout_solo(n, r, focus); + ctx->overflow = true; return; } @@ -688,7 +699,7 @@ static void layout_node(node_t *n, rect_t r, node_t *focus) { rect_t cr = n->dir == SPLIT_COLS ? (rect_t){.x = pos, .y = r.y, .w = size, .h = r.h} : (rect_t){.x = r.x, .y = pos, .w = r.w, .h = size}; - layout_node(n->kids[i], cr, focus); + layout_node(n->kids[i], cr, ctx); pos = (uint16_t)(pos + size + gap); } } @@ -703,7 +714,31 @@ static void layout(app_t *a) { .y = top, .w = (uint16_t)(a->cols > 2 * gx ? a->cols - 2 * gx : a->cols), .h = (uint16_t)(a->rows > top + gy ? a->rows - top - gy : 1)}; - if (cur(a)->root) layout_node(cur(a)->root, r, cur(a)->focus); + node_t *root = cur(a)->root; + if (!root) return; + + /* Ask first, then do it. A tab is either laid out or it is a list of panes; + * there is no third state where one half of the screen is a stack and the + * other half is not, because that is the arrangement that explains neither + * what happened nor what to do about it. + * + * So the question is asked of the whole tab: if *any* node cannot give its + * children their floor, the entire tab flattens. The probe is the real + * layout with resizing switched off, rather than a second implementation of + * the same arithmetic — two copies of "does this fit" is exactly how the + * guide and the layout would start disagreeing. */ + layout_ctx_t probe = {.focus = cur(a)->focus, .apply = false}; + layout_node(root, r, &probe); + + layout_ctx_t real = {.focus = cur(a)->focus, .apply = true}; + if (!probe.overflow) { + layout_node(root, r, &real); + } else if (r.h >= (uint16_t)(count_leaves(root) + 2)) { + /* One row per pane, plus a body worth opening into: a frame is two rows. */ + layout_stack(root, r, &real); + } else { + layout_solo(root, r, &real); + } } void app_write_focused(app_t *a, const void *buf, size_t len) { diff --git a/tests/test_responsive.py b/tests/test_responsive.py index 0e5a347..985191b 100644 --- a/tests/test_responsive.py +++ b/tests/test_responsive.py @@ -258,6 +258,41 @@ def test_a_flattened_stack_offers_no_resize_handles(): os.unlink(lay) +def test_a_tab_is_laid_out_or_it_is_a_list_never_both(): + """The whole tab flattens, not just the subtree that could not fit. + + Half a screen of panes next to half a screen of headers explains neither + what happened nor what to do about it, so it is not a state we produce. + """ + lay = nested_layout() + # min_pane tuned so the *right column* alone cannot fit while the left + # pane, on its own, comfortably could. Locally, only the right would + # collapse; globally, everything does. + conf = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + conf.write("min_pane cols=24 rows=17\n") + conf.close() + with Session(SH, cols=116, rows=52, config=conf.name, layout=lay) as s: + s.settle(20) + panes = s.panes() + shown = [p for p in panes if not p["hidden"]] + hidden = [p for p in panes if p["hidden"]] + + check("exactly one pane is open", len(shown) == 1, str(panes)) + check("every other pane is a header, including the one that did fit", + len(hidden) == len(panes) - 1, str(panes)) + check("the headers all span the full width", + len({p["w"] for p in hidden}) == 1 + and hidden[0]["w"] == shown[0]["w"], + str([(p["id"], p["w"]) for p in panes])) + # The give-away for the old behaviour: a full-height pane beside a + # stack. Nothing may sit next to a header any more. + check("no pane sits beside a header", + all(p["x"] == hidden[0]["x"] for p in panes), + str([(p["id"], p["x"]) for p in panes])) + os.unlink(conf.name) + os.unlink(lay) + + if __name__ == "__main__": test_collapse() test_collapse_expand_cycle() @@ -268,4 +303,5 @@ if __name__ == "__main__": test_collapsing_flattens_the_tree() test_every_collapsed_pane_is_clickable() test_a_flattened_stack_offers_no_resize_handles() + test_a_tab_is_laid_out_or_it_is_a_list_never_both() sys.exit(report()) |
