diff options
| author | user <user@clank> | 2026-08-16 19:13:28 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-16 19:13:28 +0200 |
| commit | c58d873b409c7a166a64ceabf178692b0b4467c2 (patch) | |
| tree | 45ed8190996bfa1c7b59309c1b07d50f5dadae19 | |
| parent | sl0ppty: the prompt speaks config, not a dialect of it (diff) | |
| download | slosh-c58d873b409c7a166a64ceabf178692b0b4467c2.tar.gz slosh-c58d873b409c7a166a64ceabf178692b0b4467c2.tar.xz slosh-c58d873b409c7a166a64ceabf178692b0b4467c2.zip | |
sl0ppty: a pane can change tabs
sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2}'
sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2,"dir":"rows"}'
sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":0,"name":"logs"}'
The mechanism only. There is no key and no gesture for it yet -- that is the next
conversation, and guessing at it now would mean building the wrong one twice.
**The pane keeps running.** Measured, not assumed: `pid=3062557` before the move
and `pid=3062557` after, in a different tab, with its scrollback intact and still
answering what it is sent. That is the whole reason to move a pane rather than open
one where you wanted it and close this one.
It is tree surgery and it reuses the surgery that was already here. `place_beside`
is the second half of `split_node_with` -- everything after "make the leaf" --
lifted out so that a leaf which already exists somewhere else can be grafted the
same way, flat sibling or new split, on either side. It takes the tab rather than
`cur(a)` now, because replacing a root has to land on the right one and `cur(a)` was
only right while the sole caller was splitting the pane you were looking at.
`detach_leaf` is `close_leaf` without the funeral.
Three decisions worth naming:
**The destination is a tab id, not an index.** A tab whose last pane leaves has
nothing to be, so it is removed -- and every index after it moves. An id survives
that; an index quietly means a different tab. The move re-finds the destination by
id *after* the removal for exactly this reason, and there is a test that empties a
tab and checks the pane landed where it was sent.
**A move drops what the old tab thought about the pane** -- a zoom naming it, and
its minimised flag. Both are the tab's opinion and not the pane's: carried across,
the first zooms a pane that has left and the second files the arrival in a strip
nobody asked for.
**It does not follow the pane.** Which tab you are looking at is a question about
intent, and this is the mechanism; `move-pane` answers with the tab id so a caller
can select it if that is what it meant. "Into a tab of its own" is its own call
rather than new-tab-then-move, because a pane that is the only thing in its tab
would otherwise have its tab removed from under the new one, and the order those
two happen in is precisely the bug.
Verified: `tests/test_move_pane.py`, 25 checks -- the pane still running and still
talking afterwards, placement beside and under the destination's focus, focus
landing on the arrival, the emptied tab, a tab of its own, every refusal (no such
pane, no such tab, already there, already alone), the dropped zoom and minimise,
and after three moves a layout with no overlaps that dumps and rebuilds as itself.
| -rw-r--r-- | docs/layouts.md | 20 | ||||
| -rw-r--r-- | docs/scripting.md | 1 | ||||
| -rw-r--r-- | src/app.c | 161 | ||||
| -rw-r--r-- | src/app.h | 19 | ||||
| -rw-r--r-- | src/cmd.c | 15 | ||||
| -rwxr-xr-x | tests/test_move_pane.py | 248 |
6 files changed, 452 insertions, 12 deletions
diff --git a/docs/layouts.md b/docs/layouts.md index 36029ab..7166751 100644 --- a/docs/layouts.md +++ b/docs/layouts.md @@ -64,6 +64,26 @@ sl0ppty -s work cmd '{"cmd":"apply-layout","kdl":"layout { tab { pane } }","repl Without `replace`, the tabs the file describes are added to what is already there. +## Moving a pane between tabs + +```bash +sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2}' # beside that tab's focus +sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2,"dir":"rows"}' # under it instead +sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":0,"name":"logs"}' # into a tab of its own +``` + +The pane keeps running: same pty, same scrollback, same process — a move is tree +surgery, not a new pane and a funeral. The destination is a tab **id** rather than +an index, because a tab whose last pane leaves is removed and every index after it +shifts; an id survives that. + +Two things a move deliberately drops, both of them the old tab's opinion rather +than the pane's: a zoom that named it, and its minimised flag. Carried across, the +first would zoom a pane that has left and the second would file the arrival in a +strip nobody asked for. Which tab you are *looking* at does not change. + +There is no key for this yet — the mechanism first, the presentation after. + ## Purposes A pane or tab can carry a `purpose=` label — `agent:main`, `logs`, `db` — for diff --git a/docs/scripting.md b/docs/scripting.md index de7e712..b311a3e 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -31,6 +31,7 @@ Panes and tabs are addressed by **id**, so a background tab is scriptable. | `close` `rerun` | `id`, or 0 for the focused pane | | `clear-shaders` | `id`, or 0 for the focused pane; answers `cleared:0\|1` — the way back from a pane that painted itself unreadable | | `new-tab` `select-tab` `close-tab` `move-tab` `set-name` | tabs, by `id` or `index` | +| `move-pane` | `id` of the pane, `tab` id to move it into (`0` for a tab of its own, with an optional `name`), `dir:"cols"\|"rows"`. The pane keeps running — same pty, same scrollback | | `set-purpose` | `target:"pane"\|"tab"`, `id`, `purpose` | | `dump-layout` `apply-layout` | see [layouts](layouts.md) | | `notify` | put a line in the session's status area | @@ -1505,13 +1505,15 @@ static void split_node(app_t *a, node_t *leaf, split_dir_t dir, bool before) { split_node_with(a, leaf, dir, before, NULL, NULL); } -static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir, - bool before, const char *const *argv, - const char *label) { - if (!leaf) return NULL; - node_t *fresh = argv ? leaf_new_ex(a, argv, NULL, false, label) : leaf_new(a); - if (!fresh) return NULL; - +/* Put `node` beside `leaf`, splitting `dir`, on the side `before` names. + * + * The tree surgery of a split without the pane: `split_node_with` hands it a leaf + * it just made, and moving a pane between tabs hands it one that already exists + * somewhere else. `t` is the tab `leaf` lives in, because replacing a root has to + * land on the right one -- `cur(a)` was right only while the only caller was + * splitting the pane you were looking at. */ +static void place_beside(app_t *a, tab_t *t, node_t *leaf, node_t *node, + split_dir_t dir, bool before) { /* Growing an existing split in the same direction keeps the tree flat, so * three vertical splits are three equal columns rather than 1/2 + 1/4 + 1/4. */ if (leaf->parent && leaf->parent->dir == dir) { @@ -1522,8 +1524,8 @@ static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir, size_t slot = before ? at : at + 1; memmove(&p->kids[slot + 1], &p->kids[slot], (p->nkids - slot) * sizeof *p->kids); - p->kids[slot] = fresh; - fresh->parent = p; + p->kids[slot] = node; + node->parent = p; p->nkids++; } else { node_t *sp = calloc(1, sizeof *sp); @@ -1531,18 +1533,28 @@ static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir, sp->id = ++a->next_id; sp->weight = leaf->weight; /* the new split inherits the pane's share */ leaf->weight = WEIGHT_UNIT; + node->weight = WEIGHT_UNIT; sp->dir = dir; sp->nkids = 2; sp->kids = malloc(2 * sizeof *sp->kids); sp->kids[before ? 1 : 0] = leaf; - sp->kids[before ? 0 : 1] = fresh; + sp->kids[before ? 0 : 1] = node; sp->parent = leaf->parent; if (leaf->parent) replace_child(leaf->parent, leaf, sp); - else cur(a)->root = sp; + else t->root = sp; leaf->parent = sp; - fresh->parent = sp; + node->parent = sp; } +} +static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir, + bool before, const char *const *argv, + const char *label) { + if (!leaf) return NULL; + node_t *fresh = argv ? leaf_new_ex(a, argv, NULL, false, label) : leaf_new(a); + if (!fresh) return NULL; + + place_beside(a, cur(a), leaf, fresh, dir, before); cur(a)->focus = fresh; layout(a); return fresh; @@ -1894,6 +1906,131 @@ bool app_split_pane(app_t *a, uint32_t id, bool rows) { return true; } +/* Take a leaf out of its tree and leave it whole: the surgery `close_leaf` does, + * without the funeral. The pane keeps running the whole time -- nothing is + * re-spawned by a move, which is the entire point of moving it rather than opening + * one somewhere else and closing this. + * + * `*emptied` says the tab has nothing left in it. Removing it is the caller's, not + * because that is tidier but because removing a tab shifts every index after it, + * and a caller holding one has to know. */ +static bool detach_leaf(app_t *a, node_t *leaf, size_t *from, bool *emptied) { + size_t ti = tab_of(a, leaf); + if (ti == (size_t)-1) return false; + tab_t *t = &a->tabs[ti]; + *from = ti; + *emptied = false; + + /* Nothing about this pane should still say where it used to be. A zoom is the + * tab's opinion about one of its panes, and a minimised pane is one this tab put + * away; carried across, the first would zoom a pane that has left and the second + * would land the arrival in a strip nobody asked for. */ + if (t->zoom == leaf->id) t->zoom = 0; + leaf->minimized = false; + + node_t *p = leaf->parent; + if (!p) { + t->root = NULL; + t->focus = NULL; + *emptied = true; + return true; + } + + size_t at = 0; + while (at < p->nkids && p->kids[at] != leaf) at++; + memmove(&p->kids[at], &p->kids[at + 1], + (p->nkids - at - 1) * sizeof *p->kids); + p->nkids--; + leaf->parent = NULL; + + node_t *survivor = p->kids[0]; + if (p->nkids == 1) { /* a split with one child is just that child */ + survivor->parent = p->parent; + if (p->parent) replace_child(p->parent, p, survivor); + else t->root = survivor; + free(p->kids); + free(p); + } + if (t->focus == leaf || t->focus == p) t->focus = first_leaf(survivor); + return true; +} + +/* Move a pane into another tab, beside whatever that tab has focused. + * + * 0 means the focused pane, as everywhere. The destination is named by *id* rather + * than index on purpose: emptying the source tab removes it, and every index after + * it moves -- an id survives that and an index quietly means a different tab. + * + * False when there is nothing to do or nowhere to do it: no such pane, no such tab, + * or the pane is already in that tab. The tab you are looking at does not change; + * whether a caller follows the pane is a question about intent, and this is the + * mechanism. */ +bool app_move_pane_to_tab(app_t *a, uint32_t pane_id, uint32_t tab_id, bool rows) { + node_t *leaf = pane_id ? pane_by_id(a, pane_id) : cur(a)->focus; + if (!leaf || leaf->kind != NODE_LEAF) return false; + tab_t *dest = tab_by_id(a, tab_id); + if (!dest || !dest->root) return false; + size_t src_ti = tab_of(a, leaf); + if (src_ti == (size_t)-1 || &a->tabs[src_ti] == dest) return false; + + uint32_t dest_id = dest->id, cur_id = a->tabs[a->cur].id; + size_t from; + bool emptied = false; + if (!detach_leaf(a, leaf, &from, &emptied)) return false; + if (emptied) tab_remove(a, from); + + /* Re-found by id, because the removal above may have moved it. */ + dest = tab_by_id(a, dest_id); + if (!dest) { /* cannot happen: the destination is not the tab we emptied */ + node_free(leaf); + return false; + } + node_t *beside = dest->focus ? dest->focus : first_leaf(dest->root); + place_beside(a, dest, beside, leaf, rows ? SPLIT_ROWS : SPLIT_COLS, false); + dest->focus = leaf; + /* A zoomed destination would hide the arrival behind the pane filling it, which + * looks exactly like the move having failed. */ + dest->zoom = 0; + + /* Stay where we were looking, unless that tab is the one that just went. */ + for (size_t i = 0; i < a->ntabs; i++) + if (a->tabs[i].id == cur_id) a->cur = i; + layout(a); + return true; +} + +/* The same, into a tab of its own. Returns the new tab's id, or 0. + * + * Worth having as its own call rather than "move to a tab you make first": a pane + * that is the only thing in its tab would otherwise have its tab removed from under + * the new one, and the order in which those two happen is exactly the bug. */ +uint32_t app_move_pane_to_new_tab(app_t *a, uint32_t pane_id, const char *name) { + node_t *leaf = pane_id ? pane_by_id(a, pane_id) : cur(a)->focus; + if (!leaf || leaf->kind != NODE_LEAF) return 0; + size_t src_ti = tab_of(a, leaf); + if (src_ti == (size_t)-1) return 0; + /* A pane alone in its tab is already in a tab of its own. */ + if (!leaf->parent) return 0; + + uint32_t cur_id = a->tabs[a->cur].id; + size_t from; + bool emptied = false; + if (!detach_leaf(a, leaf, &from, &emptied)) return 0; + if (emptied) tab_remove(a, from); + + tab_t *t = tab_add(a, name); + t->root = leaf; + t->focus = leaf; + leaf->parent = NULL; + leaf->weight = WEIGHT_UNIT; + uint32_t id = t->id; + + for (size_t i = 0; i < a->ntabs; i++) + if (a->tabs[i].id == cur_id) a->cur = i; + layout(a); + return id; +} + bool app_close_pane(app_t *a, uint32_t id) { node_t *n = id ? pane_by_id(a, id) : cur(a)->focus; if (!n) return false; @@ -108,6 +108,25 @@ void app_set_session(app_t *a, const char *name); * focusing it, which is what clicking its row does. */ bool app_minimize(app_t *a, uint32_t id); +/* Move a pane into another tab, beside whatever that tab has focused. 0 means the + * focused pane; `rows` puts it under rather than beside. The destination is a tab + * *id*, not an index: emptying the source tab removes it and shifts every index + * after it, which an id survives. + * + * The pane keeps running -- nothing is re-spawned, which is the point of moving one + * rather than opening another and closing this. False when there is nothing to do: + * no such pane, no such tab, or it is already there. Which tab you are looking at + * does not change. + * + * A move drops what the old tab thought about the pane: a zoom naming it, and its + * minimised flag. Carried across, the first would zoom a pane that has left and the + * second would file the arrival in a strip nobody asked for. */ +bool app_move_pane_to_tab(app_t *a, uint32_t pane_id, uint32_t tab_id, bool rows); + +/* The same, into a tab of its own; returns that tab's id, or 0 -- including for a + * pane that is already alone in its tab, which has nowhere to go. */ +uint32_t app_move_pane_to_new_tab(app_t *a, uint32_t pane_id, const char *name); + /* Fill the tab with one pane, or put it back. 0 means the focused one. */ bool app_toggle_zoom(app_t *a, uint32_t id); bool app_pane_zoomed(app_t *a, uint32_t id); @@ -176,6 +176,21 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, bool had = app_clear_pane_shaders(a, (uint32_t)jv_geti(req, "id", 0)); return jok_int("cleared", had ? 1 : 0); } + /* `tab` is an id, `0` a tab of its own -- the same "0 means the obvious thing" + * the pane arguments use. `dir` decides beside or under, like `split`. */ + if (strcmp(cmd, "move-pane") == 0) { + uint32_t pid = (uint32_t)jv_geti(req, "id", 0); + uint32_t tid = (uint32_t)jv_geti(req, "tab", 0); + bool rows = strcmp(jv_gets(req, "dir", "cols"), "rows") == 0; + if (!tid) { + uint32_t made = app_move_pane_to_new_tab(a, pid, jv_gets(req, "name", "")); + if (!made) return jerr("cannot move it to a tab of its own"); + return jok_int("tab", (long long)made); + } + if (!app_move_pane_to_tab(a, pid, tid, rows)) + return jerr("cannot move it there"); + return jok_int("tab", (long long)tid); + } if (strcmp(cmd, "new-tab") == 0) { uint32_t id = app_new_tab(a, jv_gets(req, "name", "")); if (!id) return jerr("cannot create tab"); diff --git a/tests/test_move_pane.py b/tests/test_move_pane.py new file mode 100755 index 0000000..d77e18b --- /dev/null +++ b/tests/test_move_pane.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Moving a pane from one tab to another. + +The mechanism, not the presentation: there is no key for this yet, deliberately. +What matters here is that the tree surgery is sound and that **the program in the +pane never notices** -- a move is the same pty in a different tab, not a new pane +somewhere else and a funeral here. Every check below is about one of those two. +""" +import os +import sys +import tempfile +import time + +from harness import Session, check, report + +# A pane that says something, then echoes what it is sent: enough to prove the same +# program is still running on the other side of a move. +ECHO = ["/bin/sh", "-c", 'printf "alive\\n"; stty raw -echo; exec cat'] + + +def cfg(text): + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write(text) + f.close() + return f.name + + +def by_tab(s): + """Which panes are in which tab, by id.""" + out = {t["id"]: [] for t in s.tabs()} + for p in s.panes(): + out.setdefault(p["tab"], []).append(p["id"]) + return {k: sorted(v) for k, v in out.items()} + + +def two_tabs(s): + """Two panes in the first tab, one in a second. Returns (tab ids, pane ids).""" + s.settle() + s.key("\\\\") # split: two panes here + s.settle(40) + s.key("c") # a second tab, with one + s.settle(40) + tabs = [t["id"] for t in s.tabs()] + return tabs + + +def test_a_pane_moves_and_keeps_running(): + """The whole point. The pane prints before the move and answers after it, so the + thing on the other side is the same program and not a fresh one.""" + with Session(ECHO, cols=90, rows=16) as s: + tabs = two_tabs(s) + s.until_text("alive") + before = by_tab(s) + mover = before[tabs[0]][1] + check("two tabs to start with", len(tabs) == 2, str(before)) + + reply = s.api("move-pane", id=mover, tab=tabs[1]) + s.settle(60) + after = by_tab(s) + check("the move is accepted", reply.get("ok") is True, str(reply)) + check("the pane is in the other tab now", + mover in after[tabs[1]] and mover not in after[tabs[0]], str(after)) + check("and no pane was created or lost", + sum(len(v) for v in after.values()) == sum(len(v) for v in before.values()), + "%s -> %s" % (before, after)) + + # Talk to it where it landed: same pty, same shell, still reading. + s.api("select-tab", id=tabs[1]) + s.settle(40) + s.api("focus", id=mover) + s.raw("still-here\\n") + snap = s.until_text("still-here") + pane = [p for p in s.panes() if p["id"] == mover][0] + check("the program in it is the one that was there before", + "still-here" in snap.pane_text(pane), repr(snap.pane_text(pane)[:120])) + check("and it is alive", pane["alive"] is True, str(pane)) + + +def test_it_lands_beside_the_destination_focus(): + """Beside what that tab was looking at, and focused -- you moved it there on + purpose. `dir` says beside or under, as it does for a split.""" + with Session(ECHO, cols=90, rows=16) as s: + tabs = two_tabs(s) + s.until_text("alive") + mover = by_tab(s)[tabs[0]][1] + host = by_tab(s)[tabs[1]][0] + + s.api("move-pane", id=mover, tab=tabs[1], dir="cols") + s.api("select-tab", id=tabs[1]) + s.settle(60) + rects = {p["id"]: (p["x"], p["y"], p["w"], p["h"]) for p in s.panes() + if p["tab"] == tabs[1]} + check("both panes are in the destination", len(rects) == 2, str(rects)) + check("side by side, the arrival on the right", + rects[mover][0] > rects[host][0], str(rects)) + check("and the arrival has the focus", + [p["focused"] for p in s.panes() if p["id"] == mover] == [True], + str([p for p in s.panes() if p["id"] == mover])) + + s.api("move-pane", id=mover, tab=tabs[0], dir="rows") + s.api("select-tab", id=tabs[0]) + s.settle(60) + rects = {p["id"]: (p["x"], p["y"], p["w"], p["h"]) for p in s.panes() + if p["tab"] == tabs[0]} + check("`rows` puts it underneath", + rects[mover][1] > min(r[1] for r in rects.values()), str(rects)) + + +def test_emptying_a_tab_removes_it(): + """A tab whose last pane leaves has nothing to be, and the destination must + still be the tab that was asked for -- which is why the argument is an id. + Naming it by index would quietly mean a different tab once the removal shifted + everything after it.""" + with Session(ECHO, cols=90, rows=16) as s: + tabs = two_tabs(s) + s.until_text("alive") + lonely = by_tab(s)[tabs[1]][0] # the only pane of the *second* tab + + reply = s.api("move-pane", id=lonely, tab=tabs[0]) + s.settle(60) + after = by_tab(s) + check("the move worked", reply.get("ok") is True, str(reply)) + check("the tab it left is gone", tabs[1] not in [t["id"] for t in s.tabs()], + str([t["id"] for t in s.tabs()])) + check("and everything is in the one that is left", + sorted(after[tabs[0]]) == sorted(sum(after.values(), [])), str(after)) + check("three panes, none lost", len(after[tabs[0]]) == 3, str(after)) + + +def test_a_pane_can_be_moved_into_a_tab_of_its_own(): + with Session(ECHO, cols=90, rows=16) as s: + tabs = two_tabs(s) + s.until_text("alive") + mover = by_tab(s)[tabs[0]][1] + watching = [t["id"] for t in s.tabs() if t.get("active")] + + reply = s.api("move-pane", id=mover, tab=0, name="solo") + s.settle(60) + made = reply.get("tab") + check("it answers with the tab it made", reply.get("ok") and made, str(reply)) + check("the pane is the only thing in it", by_tab(s).get(made) == [mover], + str(by_tab(s))) + check("and the tab has the name it was given", + [t["name"] for t in s.tabs() if t["id"] == made] == ["solo"], + str(s.tabs())) + # A move does not follow the pane: which tab you are looking at is a + # question about intent, and this is the mechanism. + check("we are still looking at the tab we were looking at", + [t["id"] for t in s.tabs() if t.get("active")] == watching, + str([t["id"] for t in s.tabs() if t.get("active")])) + + +def test_the_refusals(): + """Nothing to do, or nowhere to do it. Each says no rather than half-doing it.""" + with Session(ECHO, cols=90, rows=16) as s: + tabs = two_tabs(s) + s.until_text("alive") + here = by_tab(s)[tabs[0]][0] + lonely = by_tab(s)[tabs[1]][0] + + check("no such pane", s.api("move-pane", id=999, tab=tabs[1]).get("ok") + is False, "") + check("no such tab", s.api("move-pane", id=here, tab=999).get("ok") + is False, "") + check("already in that tab", + s.api("move-pane", id=here, tab=tabs[0]).get("ok") is False, "") + # A pane alone in its tab is already in a tab of its own; making it another + # one would remove the tab it is in and add an identical one. + check("a pane alone in its tab has nowhere of its own to go", + s.api("move-pane", id=lonely, tab=0).get("ok") is False, "") + check("and none of that moved anything", + by_tab(s) == {tabs[0]: sorted(by_tab(s)[tabs[0]]), + tabs[1]: [lonely]}, str(by_tab(s))) + + +def test_a_move_drops_what_the_old_tab_thought(): + """A zoom names one of a tab's panes and a minimised pane is one that tab put + away. Carried across, the first would zoom a pane that has left and the second + would file the arrival in a strip nobody asked for.""" + with Session(ECHO, cols=90, rows=16) as s: + tabs = two_tabs(s) + s.until_text("alive") + mover = by_tab(s)[tabs[0]][1] + + s.api("focus", id=mover) + s.key("m") # minimise it + s.settle(40) + check("it is put away", [p for p in s.panes() if p["id"] == mover][0]["h"] <= 1, + str([p for p in s.panes() if p["id"] == mover])) + + s.api("move-pane", id=mover, tab=tabs[1]) + s.api("select-tab", id=tabs[1]) + s.settle(60) + pane = [p for p in s.panes() if p["id"] == mover][0] + check("it arrives laid out, not filed away", pane["h"] > 1, str(pane)) + check("and the tab it left is not zoomed onto a pane that has gone", + all(p["h"] > 1 for p in s.panes() if p["tab"] == tabs[0]), + str([p for p in s.panes() if p["tab"] == tabs[0]])) + + +def test_the_layout_survives_a_move(): + """A tree that has been cut and grafted has to be a tree: no overlaps, nothing + off screen, and it dumps and reloads as itself.""" + with Session(ECHO, cols=100, rows=20) as s: + tabs = two_tabs(s) + s.until_text("alive") + s.key("-") # a third pane in the first tab + s.settle(40) + + for target in (tabs[1], tabs[0], tabs[1]): + movable = [p["id"] for p in s.panes() if p["tab"] != target] + if not movable: + continue + s.api("move-pane", id=movable[0], tab=target) + s.settle(60) + + rs = [(p["x"], p["y"], p["w"], p["h"]) for p in s.panes() + if p["tab"] == [t["id"] for t in s.tabs() if t.get("active")][0]] + for i in range(len(rs)): + for j in range(i + 1, len(rs)): + ax, ay, aw, ah = rs[i] + bx, by, bw, bh = rs[j] + if ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah: + check("panes do not overlap after moving", False, + "%s vs %s" % (rs[i], rs[j])) + return + check("the visible tab is a sane layout after three moves", True, str(rs)) + + dump = s.api("dump-layout").get("kdl", "") + check("and it dumps as a layout", "layout {" in dump and "pane" in dump, + repr(dump[:120])) + path = cfg(dump) + reply = s.api("apply-layout", path=path, replace=True) + s.settle(60) + check("that the session can be rebuilt from", reply.get("ok") is True, + str(reply)) + os.unlink(path) + + +if __name__ == "__main__": + test_a_pane_moves_and_keeps_running() + test_it_lands_beside_the_destination_focus() + test_emptying_a_tab_removes_it() + test_a_pane_can_be_moved_into_a_tab_of_its_own() + test_the_refusals() + test_a_move_drops_what_the_old_tab_thought() + test_the_layout_survives_a_move() + sys.exit(report()) |
