aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 22:26:55 +0200
committeruser <user@clank>2026-08-14 22:26:55 +0200
commit7247c2e8967bc31776ec6468f5e2a2abf37e0ba8 (patch)
tree95294e1fb79c08fd4aeeee9adce2e178a03bf988
parentsl0ppty: the new-tab button is a bare +, and yours to choose (diff)
downloadslosh-7247c2e8967bc31776ec6468f5e2a2abf37e0ba8.tar.gz
slosh-7247c2e8967bc31776ec6468f5e2a2abf37e0ba8.tar.xz
slosh-7247c2e8967bc31776ec6468f5e2a2abf37e0ba8.zip
sl0ppty: a tab is something you can name and move
Tabs had names -- a layout could set one, the control API could set one -- and no way to type one. A tab you made yourself was stuck being a number. And the strip's order was the order tabs happened to be created in, forever. Both are the same gesture as the pane equivalent, on the other thing that has a name: double-click a tab rename it in place, in its own cell in the strip drag a tab move it along the strip They land together because they share the machinery they are built on, and splitting them would have meant writing the shared half twice. The rename editor is the pane's, generalised from "renaming a pane" to "renaming a pane or a tab" -- one editor with a subject rather than two that would drift. Enter keeps, Escape abandons, clicking away commits, empty gives the tab back its number. It is seeded with the name only: a pane seeds from its title because a title is always something, but a tab shows its *number* until it is named, and offering "3" to backspace over would be offering to edit the wrong thing. The drag is the existing drag machine's third verb, and it reorders **as you go**. The strip is the only thing that could show an insertion point, and a strip already showing the result needs no such invention -- no ghost tab, no caret, nothing remembered between frames. Dragging off the strip is not a cancel: it is simply not a place to put a tab. Two things that were not obvious until they were written down: - pane ids and tab ids are separate sequences, so pane 2 and tab 2 both exist. The double-click detector remembered only the id, so a click on one followed by a click on the other read as a double-click on whichever came second. It remembers the kind too now, and the test asserts the ids really do collide before asserting they are told apart -- otherwise it would quietly stop testing anything the day the sequences diverge. - `cur` is a *position*, not an identity, so a move has to recompute it. Carried across, you would be looking at whichever tab slid into the index you used to be at. `{"cmd":"move-tab","id":N,"index":M}` does the same thing from a script, and is what tests the arithmetic from a tab that is not the one being moved -- the case a drag can never produce, because a drag selects what it grabs.
-rw-r--r--DESIGN.md15
-rw-r--r--README.md5
-rw-r--r--src/app.c193
-rw-r--r--src/app.h3
-rw-r--r--src/cmd.c7
-rw-r--r--tests/test_hints.py4
-rw-r--r--tests/test_tabs.py213
7 files changed, 406 insertions, 34 deletions
diff --git a/DESIGN.md b/DESIGN.md
index 677f233..9ec565e 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -298,20 +298,29 @@ layout from before it, until the mouse moved and corrected itself. Same lesson
as the hit list, one level up: do not remember what you can derive from the
frame you are drawing.
-### One drag machine, two verbs (M8)
+### One drag machine, three verbs (M8)
Pane sizes are **weights**, so an even split is simply equal weights and
resizing is not a special case of anything: the layout pass stays the same
pure function of the tree and the rect, and a resized layout survives a
collapse/expand cycle for free.
-Both mouse verbs start from the hit list, so neither can disagree with what is
-on screen:
+Every mouse verb starts from the hit list, so none of them can disagree with
+what is on screen:
| target | painted by | drag does |
|---|---|---|
| a frame's top row | `draw_frame` | swap this pane with the one you drop it on |
| the gap between two children | `draw_node`, from the rects they were just given | move that boundary |
+| a tab in the strip | `draw_tab_strip` | move it to where you drop it |
+
+The tab drag reorders **as you go** rather than dropping at the end. The strip
+is the only thing that could show an insertion point, and a strip already
+showing the result needs no such invention -- so there is no ghost tab, no
+caret, and nothing remembered between frames. Dragging off the strip is not a
+cancel: it is simply not a place to put a tab, so it stays where it last was.
+`cur` is a *position*, so a move has to recompute it; carrying it across would
+leave you looking at whichever tab slid into the index you used to be at.
The drop target is highlighted while dragging, and **any keystroke ends a
drag** — a release can go missing (the pointer leaves the terminal, the client
diff --git a/README.md b/README.md
index f338932..5a6b982 100644
--- a/README.md
+++ b/README.md
@@ -65,7 +65,10 @@ connection goes away. Reattach with the same command.
drag the crossing to move both at once.
- **Drag a pane by its title** onto another to swap them. Everything it could
land on greys out; the pane in your hand does not.
-- **Double-click a pane's title to rename it**, in place.
+- **Double-click a name to rename it, in place** — a pane's title, or a tab in
+ the strip. Enter keeps it, Escape abandons it, empty gives it back.
+- **Drag a tab along the strip** to reorder it; the strip rearranges as you go
+ rather than dropping it somewhere at the end.
- Click a tab to switch, `+` to open one, a pane to focus it.
- Hovering anything says what it does, in a word at the bottom of the screen.
diff --git a/src/app.c b/src/app.c
index 785d028..3ea5a55 100644
--- a/src/app.c
+++ b/src/app.c
@@ -188,7 +188,14 @@ struct app {
* on something the hit list says is draggable, so neither can disagree with
* what is on screen. */
struct {
- enum { DRAG_NONE, DRAG_TITLE, DRAG_EDGE, DRAG_SELECT, DRAG_BORDER } kind;
+ enum {
+ DRAG_NONE,
+ DRAG_TITLE,
+ DRAG_EDGE,
+ DRAG_SELECT,
+ DRAG_BORDER,
+ DRAG_TAB,
+ } kind;
uint32_t src; /* pane being dragged, or the split being resized */
uint32_t target; /* pane under the pointer, for the drop highlight */
size_t edge; /* which boundary of that split */
@@ -237,16 +244,22 @@ struct app {
char query[64];
size_t sel;
- /* Renaming a pane in place: the title cell becomes the editor, so the name
- * is typed where it will live rather than in a dialog somewhere else. */
- bool renaming;
+ /* Renaming in place: the title cell becomes the editor, so the name is typed
+ * where it will live rather than in a dialog somewhere else. A pane's title
+ * and a tab's label are the same gesture on two different things, so this is
+ * one editor with a subject rather than two machines that would drift. */
+ enum { RENAME_NONE = 0, RENAME_PANE, RENAME_TAB } renaming;
uint32_t rename_id;
/* As wide as a pane title, so seeding the editor never truncates one (and so
* never truncates one mid-UTF-8). */
char rename_buf[256];
- /* The first click of a candidate double-click on a title. */
+ /* The first click of a candidate double-click on a title. The *kind* is
+ * remembered with it: pane ids and tab ids are separate sequences, so pane 2
+ * and tab 2 both exist, and without this a click on one followed by a click
+ * on the other reads as a double-click on neither. */
int64_t name_click_ms;
uint32_t name_click_id;
+ int name_click_kind;
const char *const *argv;
/* What this session is called. The server knows it because it opened the
* socket under that name; the app only knows it because it is worth saying
@@ -415,7 +428,10 @@ static const char *hint_for(app_t *a, const char *action) {
if (strncmp(action, "corner:", 7) == 0) return "drag to resize both ways";
if (strncmp(action, "focus:", 6) == 0) return "open this pane";
if (strncmp(action, "find:", 5) == 0) return "go to this pane";
- if (strncmp(action, "tab:", 4) == 0) return "switch to this tab";
+ /* Not "click to switch": that is the one of the three nobody needs telling.
+ * Same rule as a pane's title, which advertises the rename and the drag. */
+ if (strncmp(action, "tab:", 4) == 0)
+ return "double-click to rename \u00b7 drag to reorder";
if (strcmp(action, "newtab") == 0) return "new tab";
return NULL; /* a pane's own content, and anything not worth a word */
}
@@ -1445,6 +1461,46 @@ bool app_set_tab_purpose(app_t *a, uint32_t id, const char *purpose,
return false;
}
+static tab_t *tab_by_id(app_t *a, uint32_t id) {
+ for (size_t i = 0; i < a->ntabs; i++)
+ if (a->tabs[i].id == id) return &a->tabs[i];
+ return NULL;
+}
+
+static size_t tab_index(app_t *a, uint32_t id) {
+ for (size_t i = 0; i < a->ntabs; i++)
+ if (a->tabs[i].id == id) return i;
+ return (size_t)-1;
+}
+
+/* Move a tab to another position in the strip.
+ *
+ * A list operation on a list: the strip *is* the order, so there is no
+ * separate ordering to keep in sync with it. `cur` is a position rather than
+ * an identity, so it has to be recomputed here — carrying it across a move
+ * would leave you looking at whichever tab slid into the index you used to
+ * be at, which is the kind of bug that only shows up in front of someone. */
+static void move_tab(app_t *a, size_t from, size_t to) {
+ if (from == to || from >= a->ntabs || to >= a->ntabs) return;
+ tab_t moved = a->tabs[from];
+ if (from < to)
+ memmove(&a->tabs[from], &a->tabs[from + 1], (to - from) * sizeof *a->tabs);
+ else
+ memmove(&a->tabs[to + 1], &a->tabs[to], (from - to) * sizeof *a->tabs);
+ a->tabs[to] = moved;
+
+ if (a->cur == from) a->cur = to;
+ else if (a->cur > from && a->cur <= to) a->cur--;
+ else if (a->cur >= to && a->cur < from) a->cur++;
+}
+
+bool app_move_tab(app_t *a, uint32_t id, size_t index) {
+ size_t from = tab_index(a, id);
+ if (from == (size_t)-1 || index >= a->ntabs) return false;
+ move_tab(a, from, index);
+ return true;
+}
+
bool app_set_tab_name(app_t *a, uint32_t id, const char *name) {
for (size_t i = 0; i < a->ntabs; i++)
if (a->tabs[i].id == id) {
@@ -2168,7 +2224,7 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
}
const char *title = pane_title(leaf->pane);
- bool editing = a->renaming && a->rename_id == leaf->id;
+ bool editing = a->renaming == RENAME_PANE && a->rename_id == leaf->id;
if ((editing || (title && *title)) && avail >= 3) {
char buf[320]; /* a full-length name, plus the caret and its spaces */
int len = editing ? snprintf(buf, sizeof buf, " %s\u2588 ", a->rename_buf)
@@ -2527,6 +2583,14 @@ static void draw_tab_strip(app_t *a, screen_t *s) {
if (nm[0]) snprintf(label, sizeof label, " %zu:%s ", i + 1, nm);
else snprintf(label, sizeof label, " %zu ", i + 1);
+ /* Renaming: the tab's own cell becomes the editor, in the editor's
+ * colours, so a half-typed name can never be mistaken for the tab's real
+ * one. The caret is part of the label, so the width below — and therefore
+ * the hit — is the width of what is actually drawn. */
+ bool editing = a->renaming == RENAME_TAB && a->rename_id == t->id;
+ if (editing)
+ snprintf(label, sizeof label, " %s\u2588 ", a->rename_buf);
+
bool active = i == a->cur;
uint16_t attrs = active ? ATTR_BOLD : 0;
/* Two independent signals: weight says which tab you are in, colour says
@@ -2535,8 +2599,11 @@ static void draw_tab_strip(app_t *a, screen_t *s) {
* costs a repaint of a few cells and guarantees the lit cells are the
* registered ones. */
uint16_t w = screen_text(s, x, y, label,
- active ? TAB_ACTIVE_FG : TAB_IDLE,
- active ? TAB_ACTIVE_BG : NO_COLOR, attrs);
+ editing ? RENAME_FG
+ : (active ? TAB_ACTIVE_FG : TAB_IDLE),
+ editing ? RENAME_BG
+ : (active ? TAB_ACTIVE_BG : NO_COLOR),
+ editing ? ATTR_BOLD : attrs);
/* Hovering keeps the active tab's fill — it is still the tab you are in —
* so its feedback lands on the text instead. An inactive tab has no fill
* to keep, and brightens. */
@@ -2550,7 +2617,7 @@ static void draw_tab_strip(app_t *a, screen_t *s) {
active ? TAB_ACTIVE_BG : NO_COLOR,
ATTR_BOLD));
}
- if (ptr_on(a, x, y, w, 1))
+ if (!editing && ptr_on(a, x, y, w, 1))
screen_text(s, x, y, label, active ? TAB_ACTIVE_HOVER_FG : TAB_HOVER,
active ? TAB_ACTIVE_BG : NO_COLOR, attrs | ATTR_BOLD);
char action[48];
@@ -2580,7 +2647,6 @@ static void draw_tab_strip(app_t *a, screen_t *s) {
hit_add(&s->hits, x, y, w, 1, "newtab");
}
}
-
}
/* The space between two of a split's children, or false if they are flush.
@@ -2959,15 +3025,16 @@ static bool finder_key(app_t *a, const input_event_t *ev) {
return true;
}
-/* ---- renaming a pane ---------------------------------------------------- */
+/* ---- renaming a pane or a tab ------------------------------------------- */
-/* The editor lives in the title cell itself (drawn by draw_frame), so the name
- * is typed where it is going to live. There is no dialog to place, nothing to
- * dismiss, and the pane it belongs to cannot be in doubt. */
+/* The editor lives in the label itself — a pane's title cell (draw_frame) or a
+ * tab's cell in the strip (draw_tab_strip) — so the name is typed where it is
+ * going to live. There is no dialog to place, nothing to dismiss, and what is
+ * being renamed cannot be in doubt. */
static void rename_begin(app_t *a, uint32_t id) {
node_t *n = pane_by_id(a, id);
if (!n) return;
- a->renaming = true;
+ a->renaming = RENAME_PANE;
a->rename_id = id;
/* Seed with what is on screen: a rename is usually an edit, not a retype. */
const char *shown = pane_title(n->pane);
@@ -2977,14 +3044,38 @@ static void rename_begin(app_t *a, uint32_t id) {
app_focus_pane(a, id);
}
+static void rename_tab_begin(app_t *a, uint32_t id) {
+ tab_t *t = tab_by_id(a, id);
+ if (!t) return;
+ a->renaming = RENAME_TAB;
+ a->rename_id = id;
+ /* Seeded with the name only. A pane seeds with its title because a title is
+ * always something; a tab shows its *number* until it is named, and a number
+ * is not a name you are editing — offering "3" to backspace over would be
+ * offering to edit the wrong thing. A purpose is not seeded either: it comes
+ * from a layout and outranks what is typed here. */
+ snprintf(a->rename_buf, sizeof a->rename_buf, "%s", t->name);
+ a->name_click_ms = 0;
+ a->name_click_id = 0;
+ app_select_tab_id(a, id);
+}
+
static void rename_end(app_t *a, bool keep) {
if (!a->renaming) return;
- node_t *n = pane_by_id(a, a->rename_id);
- if (keep && n) {
- pane_set_name(n->pane, a->rename_buf);
- app_toast(a, a->rename_buf[0] ? "renamed" : "name cleared");
+ if (keep) {
+ bool done = false;
+ if (a->renaming == RENAME_PANE) {
+ node_t *n = pane_by_id(a, a->rename_id);
+ if (n) {
+ pane_set_name(n->pane, a->rename_buf);
+ done = true;
+ }
+ } else {
+ done = app_set_tab_name(a, a->rename_id, a->rename_buf);
+ }
+ if (done) app_toast(a, a->rename_buf[0] ? "renamed" : "name cleared");
}
- a->renaming = false;
+ a->renaming = RENAME_NONE;
a->rename_id = 0;
a->rename_buf[0] = 0;
}
@@ -3194,13 +3285,14 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
* second would be renaming a pane that had already moved. */
if (on_name) {
int64_t now = now_ms_();
- if (a->name_click_id == id &&
+ if (a->name_click_id == id && a->name_click_kind == RENAME_PANE &&
now - a->name_click_ms <= (int64_t)CFG.double_click_ms) {
rename_begin(a, id);
return; /* a rename, not a grab: start no drag */
}
a->name_click_ms = now;
a->name_click_id = id;
+ a->name_click_kind = RENAME_PANE;
}
/* The top border is both a drag handle and an edge: whether this is a
* move or a split-upward is decided by whether the pointer moves. The
@@ -3286,8 +3378,33 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
return;
}
if (strncmp(action, "tab:", 4) == 0) {
- if (ev->maction == MOUSE_PRESS)
- app_select_tab_id(a, (uint32_t)strtoul(action + 4, NULL, 10));
+ if (ev->maction != MOUSE_PRESS) return;
+ uint32_t id = (uint32_t)strtoul(action + 4, NULL, 10);
+ /* Two clicks on a tab, close together, rename it — the same gesture as a
+ * pane's title, on the other thing that has a name. The first click still
+ * switches to it, which is what you want either way: you rename the tab
+ * you are now in. */
+ int64_t now = now_ms_();
+ if (a->name_click_id == id && a->name_click_kind == RENAME_TAB &&
+ now - a->name_click_ms <= (int64_t)CFG.double_click_ms) {
+ rename_tab_begin(a, id);
+ return;
+ }
+ a->name_click_ms = now;
+ a->name_click_id = id;
+ a->name_click_kind = RENAME_TAB;
+ app_select_tab_id(a, id);
+ /* And it is now held: moving the pointer onto another tab reorders the
+ * strip. Armed on every press that is not a rename, exactly like a pane's
+ * title — a press that never moves stays the click that switched tabs,
+ * because the reorder only happens on motion. */
+ a->drag.kind = DRAG_TAB;
+ a->drag.src = id;
+ a->drag.target = id;
+ a->drag.moved = false;
+ a->drag.side = 0; /* shared with the border drag, which owns that field */
+ a->drag.x = ev->mx;
+ a->drag.y = ev->my;
return;
}
/* Chrome activates on press. Forwarding to a pane does not: an app wants the
@@ -3435,8 +3552,11 @@ static bool prefix_command(app_t *a, const input_event_t *ev) {
void app_event(app_t *a, const input_event_t *ev) {
if (!a->ntabs || !cur(a)->focus) return;
- /* A rename whose pane went away must not keep the keyboard. */
- if (a->renaming && !pane_by_id(a, a->rename_id)) rename_end(a, false);
+ /* A rename whose subject went away must not keep the keyboard. */
+ if (a->renaming == RENAME_PANE && !pane_by_id(a, a->rename_id))
+ rename_end(a, false);
+ if (a->renaming == RENAME_TAB && !tab_by_id(a, a->rename_id))
+ rename_end(a, false);
/* The rename editor and the finder each own the keyboard while open; the
* mouse still routes through the hit list, whose topmost entries are the
@@ -3500,11 +3620,13 @@ void app_event(app_t *a, const input_event_t *ev) {
const char *action = hit_test(&a->painted->hits, ev->mx, ev->my);
/* Clicking away keeps the name, the way leaving a field commits it.
- * Clicking the pane's own name again does not, so a stray second
+ * Clicking the thing's own label again does not, so a stray second
* double-click lands in the editor instead of closing it. */
if (a->renaming && ev->maction == MOUSE_PRESS) {
char own[48];
- snprintf(own, sizeof own, "panetitle:%u", a->rename_id);
+ snprintf(own, sizeof own,
+ a->renaming == RENAME_PANE ? "panetitle:%u" : "tab:%u",
+ a->rename_id);
if (!action || strcmp(action, own) != 0) rename_end(a, true);
}
@@ -3542,6 +3664,21 @@ void app_event(app_t *a, const input_event_t *ev) {
? (int)ev->mx - (int)a->drag.x
: (int)ev->my - (int)a->drag.y;
drag_edge(a, sp, a->drag.edge, cells);
+ } else if (a->drag.kind == DRAG_TAB) {
+ /* Reordered as you drag, rather than dropped at the end: the
+ * strip is the only thing that could show an insertion point, and
+ * a strip that already shows the result needs no such invention.
+ * Off the strip entirely, nothing moves — dragging away is not a
+ * cancel, it is simply not a move. */
+ size_t from = tab_index(a, a->drag.src);
+ if (from != (size_t)-1 && action) {
+ size_t to = (size_t)-1;
+ if (strncmp(action, "tab:", 4) == 0)
+ to = tab_index(a, (uint32_t)strtoul(action + 4, NULL, 10));
+ else if (strcmp(action, "newtab") == 0)
+ to = a->ntabs - 1; /* past the last tab means last */
+ if (to != (size_t)-1) move_tab(a, from, to);
+ }
} else if (action && strncmp(action, "title:", 6) == 0) {
a->drag.target = (uint32_t)strtoul(action + 6, NULL, 10);
} else if (action && strncmp(action, "panetitle:", 10) == 0) {
diff --git a/src/app.h b/src/app.h
index 33756e7..e118691 100644
--- a/src/app.h
+++ b/src/app.h
@@ -69,6 +69,9 @@ void app_cycle_tab(app_t *a, int delta);
size_t app_tab_count(const app_t *a);
bool app_close_tab(app_t *a, uint32_t id);
bool app_set_tab_name(app_t *a, uint32_t id, const char *name);
+/* Put a tab at a different position in the strip, 0-based. The strip is the
+ * order, so this is what dragging one does; you stay in the tab you were in. */
+bool app_move_tab(app_t *a, uint32_t id, size_t index);
/* Purposes (D8). `declared` means "from a layout/control API": it outranks an
* in-band purpose and locks it, so a pane cannot relabel itself afterwards.
diff --git a/src/cmd.c b/src/cmd.c
index a3e0fdf..f23c6f4 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -168,6 +168,13 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
? jok_int(NULL, 0)
: jerr("no such tab");
}
+ if (strcmp(cmd, "move-tab") == 0) {
+ long index = jv_geti(req, "index", 0);
+ if (index < 1) return jerr("index is 1-based");
+ return app_move_tab(a, (uint32_t)jv_geti(req, "id", 0), (size_t)(index - 1))
+ ? jok_raw("tabs", app_tabs_json(a))
+ : jerr("no such tab");
+ }
if (strcmp(cmd, "set-name") == 0) {
return app_set_tab_name(a, (uint32_t)jv_geti(req, "id", 0),
jv_gets(req, "name", ""))
diff --git a/tests/test_hints.py b/tests/test_hints.py
index e3d2dc7..f39e874 100644
--- a/tests/test_hints.py
+++ b/tests/test_hints.py
@@ -91,8 +91,8 @@ def test_the_gap_and_the_strip_and_the_bar():
t = hit(s, "tab:")
hover(s, t["x"] + 1, t["y"])
- check("a tab offers to switch", "switch to this tab" in bar(s),
- repr(bar(s)))
+ check("a tab offers the two things you would not guess",
+ "rename" in bar(s) and "reorder" in bar(s), repr(bar(s)))
n = hit(s, "newtab")
hover(s, n["x"] + 1, n["y"])
check("the new-tab button offers a new one", "new tab" in bar(s), repr(bar(s)))
diff --git a/tests/test_tabs.py b/tests/test_tabs.py
index 255d8ac..641853b 100644
--- a/tests/test_tabs.py
+++ b/tests/test_tabs.py
@@ -292,6 +292,211 @@ def test_leaving_the_strip_puts_it_out_and_hover_switches_nothing():
os.unlink(lay)
+def dbl(s, h):
+ """Two clicks on the same cell, close enough together to be a double."""
+ s.click(h["x"] + 1, h["y"])
+ s.click(h["x"] + 1, h["y"])
+
+
+def press(s, x, y):
+ s.send(rf"\e[<0;{x + 1};{y + 1}M")
+
+
+def motion(s, x, y):
+ s.send(rf"\e[<32;{x + 1};{y + 1}M")
+
+
+def release(s, x, y):
+ s.send(rf"\e[<0;{x + 1};{y + 1}m")
+
+
+def named_tabs(s, *names):
+ """A session with one unnamed tab and one per name, in order."""
+ for n in names:
+ s.key("c")
+ s.settle()
+ s.api("set-name", id=s.tabs()[-1]["id"], name=n)
+ return [t["id"] for t in s.tabs()]
+
+
+def order(s):
+ return [t["name"] for t in s.tabs()]
+
+
+def active(s):
+ return [t["name"] for t in s.tabs() if t["active"]][0]
+
+
+def test_dragging_a_tab_reorders_the_strip():
+ with Session(SH, cols=80, rows=12) as s:
+ s.settle()
+ ids = named_tabs(s, "one", "two", "three")
+ check("the order to start", order(s) == ["", "one", "two", "three"],
+ str(order(s)))
+
+ hits = strip_hits(s.snapshot())
+ src = hits[f"tab:{ids[3]}"]
+ dst = hits[f"tab:{ids[1]}"]
+ press(s, src["x"] + 1, src["y"])
+ motion(s, dst["x"] + 1, dst["y"])
+ release(s, dst["x"] + 1, dst["y"])
+
+ check("the dragged tab lands where it was dropped",
+ order(s) == ["", "three", "one", "two"], str(order(s)))
+ check("and you are still in it", active(s) == "three", active(s))
+
+
+def test_dragging_past_the_last_tab_puts_it_last():
+ with Session(SH, cols=80, rows=12) as s:
+ s.settle()
+ ids = named_tabs(s, "one", "two")
+ hits = strip_hits(s.snapshot())
+ src = hits[f"tab:{ids[0]}"]
+ end = hits["newtab"]
+ press(s, src["x"] + 1, src["y"])
+ motion(s, end["x"] + 1, end["y"])
+ release(s, end["x"] + 1, end["y"])
+ check("dropping past the end means last",
+ order(s) == ["one", "two", ""], str(order(s)))
+
+
+def test_dragging_off_the_strip_moves_nothing():
+ """Not a cancel and not a move: leaving the strip is simply not a place to
+ put a tab, so the last position it reached is where it stays."""
+ with Session(SH, cols=80, rows=12) as s:
+ s.settle()
+ ids = named_tabs(s, "one", "two")
+ hits = strip_hits(s.snapshot())
+ src = hits[f"tab:{ids[2]}"]
+ press(s, src["x"] + 1, src["y"])
+ motion(s, 20, 6) # into a pane, well below the strip
+ release(s, 20, 6)
+ check("the order is untouched", order(s) == ["", "one", "two"],
+ str(order(s)))
+
+
+def test_a_click_that_never_moves_only_switches():
+ with Session(SH, cols=80, rows=12) as s:
+ s.settle()
+ ids = named_tabs(s, "one", "two")
+ hits = strip_hits(s.snapshot())
+ h = hits[f"tab:{ids[1]}"]
+ press(s, h["x"] + 1, h["y"])
+ release(s, h["x"] + 1, h["y"])
+ check("still in order", order(s) == ["", "one", "two"], str(order(s)))
+ check("and it switched", active(s) == "one", active(s))
+
+
+def test_move_tab_over_the_api_keeps_you_where_you_are():
+ """`cur` is a position, not an identity, so a move has to recompute it.
+ Asserted from a tab that is *not* the one being moved, which is the case
+ the drag can never produce and the arithmetic can still get wrong."""
+ with Session(SH, cols=80, rows=12) as s:
+ s.settle()
+ ids = named_tabs(s, "a", "b", "c")
+ s.api("select-tab", index=2) # sit in "a"
+ check("sitting in a", active(s) == "a", active(s))
+
+ r = s.api("move-tab", id=ids[3], index=1) # move "c" to the front
+ check("the api reports ok", r.get("ok") is True, str(r))
+ check("the strip is reordered", order(s) == ["c", "", "a", "b"],
+ str(order(s)))
+ check("and you are still in the tab you were in", active(s) == "a",
+ active(s))
+
+ check("an unknown tab is refused",
+ s.api("move-tab", id=999, index=1).get("ok") is False)
+ check("and so is an index off the end",
+ s.api("move-tab", id=ids[0], index=99).get("ok") is False)
+
+
+def test_double_clicking_a_tab_renames_it():
+ """The same gesture as a pane's title, on the other thing that has a name.
+
+ Tabs could be named by a layout or the control API and by nothing else, so
+ a tab you made yourself was stuck being a number."""
+ with Session(SH, cols=70, rows=12) as s:
+ s.settle()
+ s.key("c")
+ s.settle()
+ tab2 = "tab:" + str(s.tabs()[1]["id"])
+
+ dbl(s, strip_hits(s.snapshot())[tab2])
+ snap = s.snapshot()
+ check("the editor opens in the tab's own cell",
+ "\u2588" in snap.line(1), repr(snap.line(1)))
+
+ s.send("logs")
+ check("typing goes into it", "logs\u2588" in s.snapshot().line(1),
+ repr(s.snapshot().line(1)))
+
+ s.send(r"\r")
+ check("enter keeps it", s.tabs()[1]["name"] == "logs",
+ str(s.tabs()))
+ check("and the strip shows it", "2:logs" in s.snapshot().line(1),
+ repr(s.snapshot().line(1)))
+
+
+def test_escape_abandons_and_clicking_away_keeps():
+ with Session(SH, cols=70, rows=12) as s:
+ s.settle()
+ s.key("c")
+ s.settle()
+ tab2 = "tab:" + str(s.tabs()[1]["id"])
+
+ dbl(s, strip_hits(s.snapshot())[tab2])
+ s.send("throwaway")
+ s.send(r"\x1b")
+ check("escape leaves the name alone", s.tabs()[1]["name"] == "",
+ str(s.tabs()))
+ check("and closes the editor", "\u2588" not in s.snapshot().screen())
+
+ dbl(s, strip_hits(s.snapshot())[tab2])
+ s.send("kept")
+ s.click(2, 6) # somewhere that is not this tab
+ check("clicking away commits, the way leaving a field does",
+ s.tabs()[1]["name"] == "kept", str(s.tabs()))
+
+ # And an empty name gives the tab back to its number.
+ dbl(s, strip_hits(s.snapshot())[tab2])
+ for _ in range(len("kept")):
+ s.send(r"\x7f")
+ s.send(r"\r")
+ check("an empty name clears it", s.tabs()[1]["name"] == "",
+ str(s.tabs()))
+
+
+def test_a_tab_and_a_pane_with_the_same_id_are_not_the_same_thing():
+ """Pane ids and tab ids are separate sequences, so both `2`s exist. A click
+ on one followed by a click on the other must not read as a double-click on
+ either."""
+ # A pane only registers a `panetitle:` hit when it has a title to draw.
+ titled = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; stty raw -echo; cat']
+ with Session(titled, cols=70, rows=12) as s:
+ s.settle()
+ s.key("c")
+ s.until(lambda sn: any(h["action"].startswith("panetitle:")
+ for h in sn.hits))
+ snap = s.snapshot()
+ pane_id = s.focused()["id"] # the new tab's pane, not tab 1's
+ tab_id = s.tabs()[1]["id"]
+ check("the ids really do collide", pane_id == tab_id,
+ f"pane {pane_id} tab {tab_id}")
+
+ title = [h for h in snap.hits
+ if h["action"] == f"panetitle:{pane_id}"][0]
+ strip = strip_hits(snap)[f"tab:{tab_id}"]
+
+ s.click(strip["x"] + 1, strip["y"])
+ s.click(title["x"] + 1, title["y"])
+ check("one click on each opens no editor",
+ "\u2588" not in s.snapshot().screen(), s.snapshot().screen())
+
+ dbl(s, title)
+ check("but two on the pane still open its own",
+ "\u2588" in s.snapshot().screen(), s.snapshot().screen())
+
+
if __name__ == "__main__":
test_tabs()
test_background_tabs_keep_running()
@@ -302,4 +507,12 @@ if __name__ == "__main__":
test_hovering_the_strip_lights_what_is_under_the_pointer()
test_weight_says_active_and_colour_says_pointer()
test_leaving_the_strip_puts_it_out_and_hover_switches_nothing()
+ test_double_clicking_a_tab_renames_it()
+ test_escape_abandons_and_clicking_away_keeps()
+ test_a_tab_and_a_pane_with_the_same_id_are_not_the_same_thing()
+ test_dragging_a_tab_reorders_the_strip()
+ test_dragging_past_the_last_tab_puts_it_last()
+ test_dragging_off_the_strip_moves_nothing()
+ test_a_click_that_never_moves_only_switches()
+ test_move_tab_over_the_api_keeps_you_where_you_are()
sys.exit(report())