aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-16 19:13:28 +0200
committeruser <user@clank>2026-08-16 19:13:28 +0200
commitc58d873b409c7a166a64ceabf178692b0b4467c2 (patch)
tree45ed8190996bfa1c7b59309c1b07d50f5dadae19 /src
parentsl0ppty: the prompt speaks config, not a dialect of it (diff)
downloadslosh-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.
Diffstat (limited to 'src')
-rw-r--r--src/app.c161
-rw-r--r--src/app.h19
-rw-r--r--src/cmd.c15
3 files changed, 183 insertions, 12 deletions
diff --git a/src/app.c b/src/app.c
index 62ad8ce..af7d77f 100644
--- a/src/app.c
+++ b/src/app.c
@@ -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;
diff --git a/src/app.h b/src/app.h
index 52720dc..5295750 100644
--- a/src/app.h
+++ b/src/app.h
@@ -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);
diff --git a/src/cmd.c b/src/cmd.c
index 556b32b..a8af4a7 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -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");