diff options
| author | user <user@clank> | 2026-08-18 15:24:25 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-18 15:24:25 +0200 |
| commit | 8231838e92e5a31e85f53f8efa1b2a1e407e7cd1 (patch) | |
| tree | e81381faaa6505e09c75824c04ea749512dd04c2 /tests | |
| parent | sl0ppty: the make tools the last commit described (diff) | |
| download | slosh-8231838e92e5a31e85f53f8efa1b2a1e407e7cd1.tar.gz slosh-8231838e92e5a31e85f53f8efa1b2a1e407e7cd1.tar.xz slosh-8231838e92e5a31e85f53f8efa1b2a1e407e7cd1.zip | |
sl0ppty: the middle of a border is the button, not all of it
Clicking a pane's border to split toward it is the right gesture and was the
wrong target. A border is the longest thing on the screen and it sits between
two things you did mean to click -- a title, a gap, the pane beyond it -- so
reaching a cell too far changed the layout, and the top row was worse than the
sides: clicking a pane's header to focus it split it upward.
So the target shrank. Each side carries a *handle* of three to seven cells in the
middle, and only the handle splits; the rest of the edge is the *rim*, which
arms the guide and does nothing when pressed. Three is the floor the pane
buttons already settled on for the reason written there -- a one-cell target is
a thing you miss with a mouse -- and seven is the ceiling, because a third of a
tall pane's border is most of that border again and brings the accidents back
with it.
Hover now answers two questions in two stages, because they are two questions.
Anywhere on an edge, the edge goes heavy and the handle thickens by a cell into
the pane: that says where the button is, which is the thing you can no longer
infer. On the handle, the dashed boundary and the arrow as well: that says what
the click will do. The hint follows the same split, so sweeping an edge neither
draws a boundary nor offers a split you cannot get from where the pointer is.
Three things this had to get right.
**The thickened handle is registered, not just painted.** The guide adds the
hit for the inner strip in the frame that draws it, and reads the outer rect
back from the hit list rather than recomputing it, so there is one opinion about
where the handle is (D6). A handle you can see and cannot click would be worse
than either mistake alone.
**The top row cannot put its handle in the middle.** The buttons own its right
and a centred title -- the default -- owns exactly the middle. The title winning
is the existing rule, and it is what keeps a double-click rename from splitting
twice on its way, so the handle is placed last, in the widest run of cells the
row has left and nearest the middle among equals. A row with nothing left offers
no upward split; its other three edges are unaffected. Each handle cell is then
painted only if the hit list still agrees it belongs to the handle, so a block
never lands on a pane's name.
**The row keeps the drag and loses the split.** `title:<id>` takes no side now
and `title:<id>:t` -- the handle -- carries one, which is why the press path
reads the side out of the action instead of assuming it. Press-and-hold skips
the dwell on a handle, where a press means something, rather than anywhere on an
edge.
Verified: `make retest`, 1801 checks, all green. Nine of them are new or
rewritten: the two-stage guide, the caption belonging to the handle, the rim
splitting nothing on any of the four sides, the top row's handle still splitting
upward on a titled pane, and the rim of the top row not. Tests read the handle's
cell out of the hit list (`Snapshot.handle`/`.rim`) rather than deriving the
geometry, so a test cannot agree with a broken formula.
Also checked by hand on a live session, which is where the placement rule earned
itself: hovering the rim draws the heavy edge and the block and no dashed line,
hovering the handle adds the line and the arrow, three clicks on pane headers
split nothing, and on a pane titled `sl0ppty` the top handle lands to the right
of the title and left of the buttons -- `drag to move · click to split up` at
x=42, `drag to move` either side of it.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/harness.py | 29 | ||||
| -rw-r--r-- | tests/test_drag.py | 6 | ||||
| -rw-r--r-- | tests/test_hints.py | 22 | ||||
| -rw-r--r-- | tests/test_layout.py | 23 | ||||
| -rw-r--r-- | tests/test_pane_title.py | 5 | ||||
| -rw-r--r-- | tests/test_split_ux.py | 128 |
6 files changed, 153 insertions, 60 deletions
diff --git a/tests/harness.py b/tests/harness.py index e75fe47..b45ef10 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -69,6 +69,35 @@ class Snapshot: return e["action"] return None + def handle(self, pane_id, side): + """The middle cell of a pane's split handle, read from the hit list. + + Read rather than recomputed on purpose: a test that derives the geometry + itself agrees with a broken formula as happily as with a correct one. + `side` is one of l r t b; the top row's handle answers to `title:` since + a press there can still become a drag. + """ + want = f"title:{pane_id}:t" if side == "t" else f"border:{pane_id}:{side}" + for e in self.hits: + if e["action"] == want: + return e["x"] + e["w"] // 2, e["y"] + e["h"] // 2 + return None + + def rim(self, pane_id, side): + """A cell on the same edge that is *not* the handle, or None.""" + h = self.handle(pane_id, side) + if not h: + return None + want = f"title:{pane_id}" if side == "t" else f"brim:{pane_id}:{side}" + for e in self.hits: + if e["action"] != want: + continue + for y in range(e["y"], e["y"] + e["h"]): + for x in range(e["x"], e["x"] + e["w"]): + if self.hit_at(x, y) == want: + return x, y + return None + def __str__(self): return self.screen() diff --git a/tests/test_drag.py b/tests/test_drag.py index e7964e4..e301fac 100644 --- a/tests/test_drag.py +++ b/tests/test_drag.py @@ -257,8 +257,10 @@ def test_drag_edge_cases(): s.settle(60) check("a keystroke ends an abandoned drag", s.api("alive")["alive"]) - # and a border click still works, which is the proof it is not wedged - s.click(left["x"], left["y"] + 2) + # and a split still works, which is the proof it is not wedged. The + # handle, since the rest of the edge is deliberately inert now. + hx, hy = s.snapshot().handle(left["id"], "l") + s.click(hx, hy) s.settle() check( "the mouse still works after a drag", diff --git a/tests/test_hints.py b/tests/test_hints.py index fd85711..61349a7 100644 --- a/tests/test_hints.py +++ b/tests/test_hints.py @@ -77,16 +77,28 @@ def test_the_zoom_hint_says_which_way_it_goes(): def test_a_border_names_the_direction_it_would_split(): + """The caption belongs to the handle, not the whole edge: the rest of an + edge does nothing, and telling you it splits would be a lie about it.""" with Session(SH, cols=96, rows=22) as s: s.settle(20) p = s.pane() - for x, y, word in ( - (p["x"], p["y"] + 2, "split left"), - (p["x"] + p["w"] - 1, p["y"] + 2, "split right"), - (p["x"] + 6, p["y"] + p["h"] - 1, "split down"), + for side, word in ( + ("l", "split left"), + ("r", "split right"), + ("b", "split down"), + ("t", "split up"), ): + x, y = s.snapshot().handle(p["id"], side) hover(s, x, y) - check(f"the border says {word}", word in bar(s), repr(bar(s))) + check(f"the {side} handle says {word}", word in bar(s), repr(bar(s))) + + rx, ry = s.snapshot().rim(p["id"], "l") + hover(s, rx, ry) + check( + "and the rest of the edge says nothing about splitting", + "split" not in bar(s), + repr(bar(s)), + ) def test_the_gap_and_the_strip_and_the_bar(): diff --git a/tests/test_layout.py b/tests/test_layout.py index c2413af..a868204 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -182,23 +182,28 @@ def test_hit_list(): str(snap.hit_at(pane["content_x"], pane["content_y"])), ) - # the border is the split target now; the top row is also the drag - # handle, which is why it reports title: rather than border: + # An edge splits from its handle only; the rest of it is the rim, which + # arms the guide and does nothing. The top row is the drag handle as + # well, which is why its handle 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)), + "the side border's handle is a split target", + snap.hit_at(*snap.handle(pane["id"], "l")) == f"border:{pane['id']}:l", + str(snap.hit_at(*snap.handle(pane["id"], "l"))), ) 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)), + "the bottom one too", + snap.hit_at(*snap.handle(pane["id"], "b")) == f"border:{pane['id']}:b", + str(snap.hit_at(*snap.handle(pane["id"], "b"))), + ) + check( + "and the rest of a side is the rim", + snap.hit_at(*snap.rim(pane["id"], "l")) == f"brim:{pane['id']}:l", + str(snap.hit_at(*snap.rim(pane["id"], "l"))), ) diff --git a/tests/test_pane_title.py b/tests/test_pane_title.py index da8e863..7f269fa 100644 --- a/tests/test_pane_title.py +++ b/tests/test_pane_title.py @@ -147,10 +147,11 @@ def test_clicking_the_title_does_not_split(): str(len(s.panes())), ) - click(s, off_title(p), p["y"]) + hx, hy = s.snapshot().handle(p["id"], "t") + click(s, hx, hy) s.settle(80) check( - "clicking the top edge still splits up", + "clicking the top row's handle still splits up", len(s.panes()) == 2, str(len(s.panes())), ) diff --git a/tests/test_split_ux.py b/tests/test_split_ux.py index e6847b8..3bf8613 100644 --- a/tests/test_split_ux.py +++ b/tests/test_split_ux.py @@ -90,16 +90,13 @@ def test_no_more_plus(): 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"), - ]: + for side, expect in [("r", "right"), ("l", "left"), ("b", "below"), ("t", "above")]: with Session(SH, cols=80, rows=26, config=FAST) as s: s.settle() before = s.pane() - x, y = cell(before) + # The handle in the middle of that edge, not just any cell on it: + # the rest of the edge deliberately does nothing. + x, y = s.snapshot().handle(before["id"], side) click(s, x, y) s.settle(80) panes = s.panes() @@ -125,18 +122,24 @@ def test_each_border_splits_toward_itself(): f"old={old['x']},{old['y']} new={new['x']},{new['y']}", ) check("and takes focus", new["focused"], str(new)) + said = { + "r": "split right", + "l": "split left", + "b": "split down", + "t": "split up", + }[side] 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(), + said in s.snapshot().screen(), repr(s.snapshot().screen()[-80:]), ) -def test_the_guide(): +def test_the_guide_escalates_from_edge_to_handle(): + """Two stages: the edge says *where the button is*, the handle says *what + the click will do*. Hovering anywhere on an edge must show the first without + the second, or brushing a border would still draw a boundary nobody asked + about.""" with Session(SH, cols=80, rows=26, config=FAST) as s: s.settle() p = s.pane() @@ -147,23 +150,39 @@ def test_the_guide(): repr(snap.screen()[:120]), ) - rest(s, p["x"], p["y"] + 2) # left border + rx, ry = snap.rim(p["id"], "l") + rest(s, rx, ry) snap = s.snapshot() check( - "hovering a side border arms it", + "hovering the rim of a side border arms it", "┃" in snap.screen(), repr(snap.screen()[:200]), ) check( - "and previews where the split would land", - "╎" in snap.screen(), + "and shows the handle you are meant to click", + "█" in snap.screen(), + repr(snap.screen()[:200]), + ) + check( + "but previews no boundary yet", + "╎" not in snap.screen(), repr(snap.screen()[:200]), ) - rest(s, p["x"] + 4, p["y"] + p["h"] - 1) # bottom border + hx, hy = snap.handle(p["id"], "l") + rest(s, hx, hy) snap = s.snapshot() check( - "hovering the bottom border arms that instead", + "on the handle it previews where the split would land", + "╎" in snap.screen() and "█" in snap.screen(), + repr(snap.screen()[:200]), + ) + + bx, by = snap.handle(p["id"], "b") + rest(s, bx, by) + snap = s.snapshot() + check( + "hovering the bottom handle arms that instead", "━" in snap.screen() and "╌" in snap.screen(), repr(snap.screen()[:200]), ) @@ -177,7 +196,9 @@ def test_the_guide(): snap = s.snapshot() check( "moving off a border puts the guide away", - "━" not in snap.screen() and "┃" not in snap.screen(), + "━" not in snap.screen() + and "┃" not in snap.screen() + and "█" not in snap.screen(), repr(snap.screen()[:200]), ) @@ -193,7 +214,7 @@ def test_guide_follows_the_new_layout(): with Session(SH, cols=120, rows=26, config=FAST) as s: s.settle() p = s.pane() - edge_x, edge_y = p["x"] + p["w"] - 1, p["y"] + 3 + edge_x, edge_y = s.snapshot().handle(p["id"], "r") rest(s, edge_x, edge_y) click(s, edge_x, edge_y) @@ -218,7 +239,7 @@ def test_guide_follows_the_new_layout(): ) check( "on the edge the pointer is on", - snap.hit_at(edge_x, edge_y) == f"border:{armed[0]['id']}:r", + (snap.hit_at(edge_x, edge_y) or "").endswith(f"{armed[0]['id']}:r"), str(snap.hit_at(edge_x, edge_y)), ) @@ -254,7 +275,11 @@ def test_the_guide_waits_for_a_rest(): check("resting on one arms it", "┃" in snap.screen(), repr(snap.screen()[:200])) check( "and it is the border being rested on", - snap.hit_at(right, row) == f"border:{p['id']}:r", + snap.hit_at(right, row) + in ( + f"border:{p['id']}:r", + f"brim:{p['id']}:r", + ), str(snap.hit_at(right, row)), ) @@ -271,7 +296,7 @@ def test_press_skips_the_wait(): with Session(SH, cols=80, rows=26) as s: s.settle() p = s.pane() - x, y = p["x"] + p["w"] - 1, p["y"] + 3 + x, y = edge_of(s, p, "r") # the handle: the rim answers no press s.send(rf"\e[<0;{x + 1};{y + 1}M") # press and hold: that is intent snap = s.snapshot() check( @@ -348,11 +373,30 @@ def test_drag_still_moves(): 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") + # click: press, release, no motion, on the handle -> split up. The rest + # of the row is a drag handle only, which is the point of the handle. + hx, hy = edge_of(s, s.panes()[0], "t") + s.send(rf"\e[<0;{hx + 1};{hy + 1}M") + s.send(rf"\e[<0;{hx + 1};{hy + 1}m") s.settle(80) - check("clicking it splits instead", len(s.panes()) == 3, str(len(s.panes()))) + check( + "clicking its handle splits instead", + len(s.panes()) == 3, + str(len(s.panes())), + ) + # and the rest of the row does not: the accident this whole shape exists + # to stop. Counted from the pane the handle click just created. + n = len(s.panes()) + top = s.panes()[0] + rx, ry = s.snapshot().rim(top["id"], "t") + s.send(rf"\e[<0;{rx + 1};{ry + 1}M") + s.send(rf"\e[<0;{rx + 1};{ry + 1}m") + s.settle(80) + check( + "while the rest of the row splits nothing", + len(s.panes()) == n, + f"{n} -> {len(s.panes())}", + ) def test_tiny_panes_have_no_targets(): @@ -390,14 +434,14 @@ def test_tiny_panes_have_no_targets(): ARROWS = {"l": "\u25c4", "r": "\u25ba", "t": "\u25b2", "b": "\u25bc"} -def edge_of(pane, side): - if side == "l": - return pane["x"], pane["y"] + 2 - if side == "r": - return pane["x"] + pane["w"] - 1, pane["y"] + 2 - if side == "t": - return pane["x"] + 4, pane["y"] - return pane["x"] + 4, pane["y"] + pane["h"] - 1 +def edge_of(s, pane, side): + """The cell on that side that actually does something: its handle. + + Read out of the hit list rather than worked out here, so these tests cannot + quietly agree with a geometry bug. The rest of an edge is the rim, which is + what `Snapshot.rim` is for. + """ + return s.snapshot().handle(pane["id"], side) def test_a_split_below_the_floor_is_not_offered(): @@ -408,7 +452,7 @@ def test_a_split_below_the_floor_is_not_offered(): s.settle() p = s.pane() for side in ("l", "r", "t", "b"): - x, y = edge_of(p, side) + x, y = edge_of(s, p, side) rest(s, x, y) snap = s.snapshot() body = snap.screen() @@ -433,7 +477,7 @@ def test_the_floor_is_configurable_in_both_axes(): with Session(SH, cols=90, rows=26, config=conf) as s: s.settle() p = s.pane() - x, y = edge_of(p, "b") + x, y = edge_of(s, p, "b") click(s, x, y) s.settle(80) check( @@ -441,7 +485,7 @@ def test_the_floor_is_configurable_in_both_axes(): len(s.panes()) == 1, str(len(s.panes())), ) - x, y = edge_of(p, "r") + x, y = edge_of(s, p, "r") click(s, x, y) s.settle(80) check( @@ -479,7 +523,7 @@ def test_the_guide_says_which_way_it_goes(): s.settle() p = s.pane() for side in ("l", "r", "t", "b"): - x, y = edge_of(p, side) + x, y = edge_of(s, p, side) rest(s, x, y) body = s.snapshot().screen() check( @@ -499,7 +543,7 @@ def test_the_arrow_sits_on_the_new_boundary(): with Session(SH, cols=120, rows=26, config=FAST) as s: s.settle() p = s.pane() - x, y = edge_of(p, "r") + x, y = edge_of(s, p, "r") rest(s, x, y) snap = s.snapshot() pos = snap.find(ARROWS["r"]) @@ -684,7 +728,7 @@ def test_it_is_on_the_sheet_and_in_the_palette(): if __name__ == "__main__": test_no_more_plus() test_each_border_splits_toward_itself() - test_the_guide() + test_the_guide_escalates_from_edge_to_handle() test_guide_follows_the_new_layout() test_the_guide_waits_for_a_rest() test_press_skips_the_wait() |
