aboutsummaryrefslogtreecommitdiffstats
path: root/tests
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 /tests
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.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_hints.py4
-rw-r--r--tests/test_tabs.py213
2 files changed, 215 insertions, 2 deletions
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())