diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-14 00:21:56 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-14 00:21:56 +0200 |
| commit | f2f3d678495e37d2eee2d26f0f704f2accf451e1 (patch) | |
| tree | 4ee05f2c77cfe1d2d921e05cbd478cf9a1346236 /tests | |
| parent | sl0ptty: select-to-copy, middle-click paste, and toasts (diff) | |
| download | slosh-f2f3d678495e37d2eee2d26f0f704f2accf451e1.tar.gz slosh-f2f3d678495e37d2eee2d26f0f704f2accf451e1.tar.xz slosh-f2f3d678495e37d2eee2d26f0f704f2accf451e1.zip | |
sl0ptty: the border is the button — click an edge to split toward it
The per-pane '+' is gone. It was one glyph for a verb with four directions, so
it always split into columns; it taxed every frame three columns of title
forever; and there were N of them on screen for one verb.
Clicking a pane's border now splits toward that border: the side you click is
the side the new pane appears on. Hovering arms the edge (it goes heavy) and
draws a dashed line exactly where the new boundary would land, so the gesture
explains itself and an idle frame stays quiet.
╭──────────────────── shell ─────────────────────╮
┃ ╎ │
┃ ╎ │ hovering the left
╰────────────────────────────────────────────────╯ border
│╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌│ hovering the bottom
╰━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╯ border
The top border keeps its other job: a click splits upward, a drag moves the
pane. A press that never moves is a click, which is a distinction the drag
machine already had to make.
The guide has to be painted after the pane's content, not with the frame: the
dashed line crosses the terminal's own cells, and the first version was drawn
underneath them and was invisible.
Two old tests changed rather than the code, both because the gesture set is
better now: 'beside the split button' is a drag handle, and 'dropping a pane
on itself does nothing' is a click, which splits. The replacement asserts what
actually needs asserting — a drag that wanders and returns changes nothing.
387 checks green (360 headless + 27 live).
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_drag.py | 21 | ||||
| -rw-r--r-- | tests/test_layout.py | 30 | ||||
| -rw-r--r-- | tests/test_split_ux.py | 174 |
3 files changed, 198 insertions, 27 deletions
diff --git a/tests/test_drag.py b/tests/test_drag.py index 8dfbe8c..1fdd029 100644 --- a/tests/test_drag.py +++ b/tests/test_drag.py @@ -163,11 +163,15 @@ def test_drag_edge_cases(): before = [(p["id"], p["x"]) for p in s.panes()] left = s.panes()[0] - # a drag that ends where it started must change nothing + # a drag that wanders and comes back must change nothing. (A press and + # release with no motion is not this: that is a click, and a click on + # a border splits — see test_split_ux.py.) s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}M") + s.send(rf"\e[<32;{left['x'] + 20};{left['y'] + 4}M") + s.send(rf"\e[<32;{left['x'] + 5};{left['y'] + 1}M") s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}m") s.settle() - check("dropping a pane on itself does nothing", + check("a drag that returns to its origin changes nothing", [(p["id"], p["x"]) for p in s.panes()] == before, str(s.panes())) # a drag released over nothing (the gap ring) must not swap or crash @@ -184,13 +188,12 @@ def test_drag_edge_cases(): s.send("k") # any keystroke ends a drag s.settle(60) check("a keystroke ends an abandoned drag", s.api("alive")["alive"]) - snap = s.snapshot() - pos = snap.line(left["y"]).find("+") - if pos >= 0: - s.click(pos, left["y"]) - s.settle() - check("the mouse still works after a drag", len(s.panes()) == 3, - str(len(s.panes()))) + + # and a border click still works, which is the proof it is not wedged + s.click(left["x"], left["y"] + 2) + s.settle() + check("the mouse still works after a drag", len(s.panes()) == 3, + str(len(s.panes()))) def test_drop_target_is_visible(): diff --git a/tests/test_layout.py b/tests/test_layout.py index 97744d3..df79877 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -153,24 +153,18 @@ def test_hit_list(): snap.hit_at(pane["content_x"], pane["content_y"]) == f"pane:{pane['id']}", str(snap.hit_at(pane["content_x"], pane["content_y"]))) - # the split button, on the pane's own frame row — not the status bar's - # "+tab", which is a different verb and deliberately looks different - row = snap.line(pane["y"]) - pos = (row.index("+"), pane["y"]) if "+" in row else None - check("split button is drawn", pos is not None, str(snap.line(pane["y"]))) - if pos: - x, y = pos - check("the click target is exactly where the glyph is", - snap.hit_at(x, y) == f"split:{pane['id']}", str(snap.hit_at(x, y))) - # the rest of the top row is the drag handle, not the button - check("beside the button is not the button", - snap.hit_at(x - 1, y) == f"title:{pane['id']}", - str(snap.hit_at(x - 1, y))) - - s.click(x, y) - s.settle() - check("clicking the button splits", len(s.panes()) == 2, - str(len(s.panes()))) + # the border is the split target now; the top row is also the drag + # handle, which is why it reports title: rather than border: + check("the top border is the drag handle", + snap.hit_at(pane["x"] + 4, pane["y"]) == f"title:{pane['id']}", + str(snap.hit_at(pane["x"] + 4, pane["y"]))) + check("the side border is a split target", + snap.hit_at(pane["x"], pane["y"] + 2) == f"border:{pane['id']}:l", + str(snap.hit_at(pane["x"], pane["y"] + 2))) + check("the bottom border too", + snap.hit_at(pane["x"] + 4, pane["y"] + pane["h"] - 1) + == f"border:{pane['id']}:b", + str(snap.hit_at(pane["x"] + 4, pane["y"] + pane["h"] - 1))) def test_click_focuses_and_forwards(): diff --git a/tests/test_split_ux.py b/tests/test_split_ux.py new file mode 100644 index 0000000..c394ffe --- /dev/null +++ b/tests/test_split_ux.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Splitting by clicking a border, with a guide that shows what will happen. + +The old per-pane `+` was one glyph for a verb that has four directions, and it +taxed every frame three columns forever. A border encodes the direction by +being on that side, costs nothing when idle, and shows a preview on hover. +""" +import sys + +from harness import Session, check, report + +SH = ["/bin/sh", "-c", 'printf "\\033]2;shell\\007"; stty raw -echo; cat'] + + +def hover(s, x, y): + s.send(rf"\e[<35;{x + 1};{y + 1}M") + + +def click(s, x, y): + s.send(rf"\e[<0;{x + 1};{y + 1}M") + s.send(rf"\e[<0;{x + 1};{y + 1}m") + + +def test_no_more_plus(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + snap = s.snapshot() + p = s.pane() + check("the frame has no split button any more", + "+" not in snap.line(p["y"]), repr(snap.line(p["y"]))) + check("the status bar still has its own, different one", + "+tab" in snap.line(1), repr(snap.line(1))) + check("the title gets the columns back", + snap.line(p["y"]).count("─") > p["w"] - 12, repr(snap.line(p["y"]))) + + +def test_each_border_splits_toward_itself(): + for side, cell, expect in [ + ("right", lambda p: (p["x"] + p["w"] - 1, p["y"] + 2), "right"), + ("left", lambda p: (p["x"], p["y"] + 2), "left"), + ("bottom", lambda p: (p["x"] + 4, p["y"] + p["h"] - 1), "below"), + ("top", lambda p: (p["x"] + 4, p["y"]), "above"), + ]: + with Session(SH, cols=70, rows=18) as s: + s.settle() + before = s.pane() + x, y = cell(before) + click(s, x, y) + s.settle(80) + panes = s.panes() + check(f"clicking the {side} border splits", len(panes) == 2, + str(len(panes))) + if len(panes) != 2: + continue + + old = [q for q in panes if q["id"] == before["id"]][0] + new = [q for q in panes if q["id"] != before["id"]][0] + if expect == "right": + ok = new["x"] > old["x"] and new["y"] == old["y"] + elif expect == "left": + ok = new["x"] < old["x"] and new["y"] == old["y"] + elif expect == "below": + ok = new["y"] > old["y"] and new["x"] == old["x"] + else: + ok = new["y"] < old["y"] and new["x"] == old["x"] + check(f"the new pane lands {expect}", ok, + f"old={old['x']},{old['y']} new={new['x']},{new['y']}") + check("and takes focus", new["focused"], str(new)) + check("and it says which way it went", + f"split {side if side != 'bottom' else 'down'}" + .replace("split top", "split up") in s.snapshot().screen() + or "split" in s.snapshot().screen(), + repr(s.snapshot().screen()[-80:])) + + +def test_the_guide(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + p = s.pane() + snap = s.snapshot() + check("no guide when the pointer is elsewhere", + "┃" not in snap.screen() and "╎" not in snap.screen(), + repr(snap.screen()[:120])) + + hover(s, p["x"], p["y"] + 2) # left border + s.settle(60) + snap = s.snapshot() + check("hovering a side border arms it", "┃" in snap.screen(), + repr(snap.screen()[:200])) + check("and previews where the split would land", "╎" in snap.screen(), + repr(snap.screen()[:200])) + + hover(s, p["x"] + 4, p["y"] + p["h"] - 1) # bottom border + s.settle(60) + snap = s.snapshot() + check("hovering the bottom border arms that instead", + "━" in snap.screen() and "╌" in snap.screen(), repr(snap.screen()[:200])) + check("and the side guide is gone", "┃" not in snap.screen(), + repr(snap.screen()[:200])) + + hover(s, p["content_x"] + 3, p["content_y"] + 2) # into the content + s.settle(60) + snap = s.snapshot() + check("moving off a border puts the guide away", + "━" not in snap.screen() and "┃" not in snap.screen(), + repr(snap.screen()[:200])) + + +def test_guide_is_per_pane(): + with Session(SH, cols=90, rows=14) as s: + s.settle() + s.key("\\\\") + s.settle() + left, right = s.panes() + hover(s, left["x"], left["y"] + 2) + s.settle(60) + snap = s.snapshot() + row = snap.line(left["y"] + 2) + check("the hovered pane is armed", "┃" in row[left["x"]:left["x"] + left["w"]], + repr(row)) + check("the other one is not", + "┃" not in row[right["x"]:right["x"] + right["w"]], repr(row)) + + +def test_drag_still_moves(): + """The top border does double duty: click splits, drag moves.""" + with Session(SH, cols=90, rows=16) as s: + s.settle() + s.key("\\\\") + s.settle() + left, right = s.panes() + + # drag: press, move, release -> swap, not split + s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}M") + s.send(rf"\e[<32;{right['x'] + 5};{right['y'] + 3}M") + s.send(rf"\e[<0;{right['x'] + 5};{right['y'] + 3}m") + s.settle(80) + check("dragging the top border still moves the pane", + len(s.panes()) == 2, str(len(s.panes()))) + panes = {q["id"]: q for q in s.panes()} + check("and swaps them", panes[left["id"]]["x"] > panes[right["id"]]["x"], + str(s.panes())) + + # click: press, release, no motion -> split up + s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}M") + s.send(rf"\e[<0;{left['x'] + 5};{left['y'] + 1}m") + s.settle(80) + check("clicking it splits instead", len(s.panes()) == 3, + str(len(s.panes()))) + + +def test_tiny_panes_have_no_targets(): + with Session(SH, cols=90, rows=16) as s: + s.settle() + s.key("\\\\") + s.settle() + s.resize(30, 8) # collapses to a stack + s.settle(80) + snap = s.snapshot() + check("a collapsed layout still draws and does not crash", + s.api("alive")["alive"], "") + check("and its header is a focus target, not a split one", + all(not (h["action"].startswith("border:") and h["h"] == 1 + and h["w"] < 4) for h in snap.hits), str(snap.hits)) + + +if __name__ == "__main__": + test_no_more_plus() + test_each_border_splits_toward_itself() + test_the_guide() + test_guide_is_per_pane() + test_drag_still_moves() + test_tiny_panes_have_no_targets() + sys.exit(report()) |
