aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-14 00:21:56 +0200
committersl0p <sl0p@sl0p.foo>2026-08-14 00:21:56 +0200
commitf2f3d678495e37d2eee2d26f0f704f2accf451e1 (patch)
tree4ee05f2c77cfe1d2d921e05cbd478cf9a1346236
parentsl0ptty: select-to-copy, middle-click paste, and toasts (diff)
downloadslosh-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).
-rw-r--r--DESIGN.md17
-rw-r--r--Makefile2
-rw-r--r--README.md6
-rw-r--r--src/app.c137
-rw-r--r--tests/test_drag.py21
-rw-r--r--tests/test_layout.py30
-rw-r--r--tests/test_split_ux.py174
7 files changed, 337 insertions, 50 deletions
diff --git a/DESIGN.md b/DESIGN.md
index 14d7d00..6ba45a8 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -250,6 +250,23 @@ Frame pacing: pty reads are coalesced and painted on a timerfd at a cap
- **M8** — pane sizes as weights: keyboard resize, drag the gap to move a
boundary, drag a title to swap two panes ✅
+### The border is the button
+
+The per-pane `+` is gone. It was one glyph for a verb with four directions, so
+it always split into columns; it cost every frame three columns of title,
+forever, for something pressed rarely; and there were N of them on screen for
+one verb.
+
+**Clicking a pane's border splits toward it.** The side you click is the side
+the new pane appears on, which is the direction information a single glyph
+could never carry. Hovering a border arms it (the edge goes heavy) and draws a
+dashed line where the new boundary would land, so the gesture explains itself
+and costs nothing when the pointer is elsewhere.
+
+The top border keeps its other job: **click splits upward, drag moves the
+pane**. A press that never moves is a click — the same distinction the drag
+machine already had to make.
+
### One drag machine, two verbs (M8)
Pane sizes are **weights**, so an even split is simply equal weights and
diff --git a/Makefile b/Makefile
index 55aa4a3..0e88171 100644
--- a/Makefile
+++ b/Makefile
@@ -58,7 +58,7 @@ test: $(BIN) $(TEST_BIN) $(KDL_TEST) ## unit + headless checks (fast)
@echo
cd tests && python3 test_screen.py && python3 test_layout.py && python3 test_tabs.py && python3 test_osc5577.py && \
python3 test_responsive.py && python3 test_finder.py && \
- python3 test_config.py && python3 test_layout_files.py && python3 test_drag.py && python3 test_hover.py && python3 test_scroll.py && python3 test_clipboard.py
+ python3 test_config.py && python3 test_layout_files.py && python3 test_drag.py && python3 test_hover.py && python3 test_scroll.py && python3 test_clipboard.py && python3 test_split_ux.py
test-live: $(BIN) ## the checks that need a real tty (slow)
python3 tests/live_m0.py
diff --git a/README.md b/README.md
index b51b7a0..3bda04b 100644
--- a/README.md
+++ b/README.md
@@ -52,8 +52,10 @@ sl0ptty cmd '{"cmd":"panes"}'
| `C-a x` / `C-a d` / `C-a q` | close pane / detach / quit |
| `C-a C-a` | send a literal `C-a` |
-The mouse works throughout: click a pane to focus it, a tab to switch, `+` on a
-frame to split, `+tab` to open one, a collapsed header to expand it, and a
+The mouse works throughout. **Click a pane's border to split toward it** — the
+side you click is the side the new pane appears on, and hovering a border arms
+it and shows a dashed line where the split would land. Click a pane to focus
+it, a tab to switch, `+tab` to open one, a collapsed header to expand it, and a
pane's own buttons to talk to the program inside it. **Drag a pane's title bar
onto another pane to swap them, or drag the gap between two panes to move the
boundary.** Focus follows the pointer (`focus_follows_mouse`, on by default,
diff --git a/src/app.c b/src/app.c
index 0b40a11..e5a9e57 100644
--- a/src/app.c
+++ b/src/app.c
@@ -121,13 +121,21 @@ 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 } kind;
+ enum { DRAG_NONE, DRAG_TITLE, DRAG_EDGE, DRAG_SELECT, DRAG_BORDER } 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 */
uint16_t x, y; /* where the pointer was at the last event */
+ bool moved; /* a press that never moves is a click */
+ char side; /* border press: 'l' 'r' 't' 'b' */
} drag;
+ /* Which pane border the pointer is over, for the split guide. Recomputed
+ * from the hit list on every motion, so it cannot describe a border that is
+ * not there any more. */
+ uint32_t hover_pane;
+ char hover_side;
+
/* Transient announcements: "copied 13 chars", a notification from a pane,
* a config reload. They stack upward from the bottom right and expire on
* their own, so nothing has to be dismissed. */
@@ -633,8 +641,7 @@ static void replace_child(node_t *parent, node_t *old, node_t *new_) {
}
}
-static void split_focus(app_t *a, split_dir_t dir) {
- node_t *leaf = cur(a)->focus;
+static void split_node(app_t *a, node_t *leaf, split_dir_t dir, bool before) {
if (!leaf) return;
node_t *fresh = leaf_new(a);
if (!fresh) return;
@@ -646,9 +653,10 @@ static void split_focus(app_t *a, split_dir_t dir) {
p->kids = realloc(p->kids, (p->nkids + 1) * sizeof *p->kids);
size_t at = 0;
while (at < p->nkids && p->kids[at] != leaf) at++;
- memmove(&p->kids[at + 2], &p->kids[at + 1],
- (p->nkids - at - 1) * sizeof *p->kids);
- p->kids[at + 1] = fresh;
+ 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->nkids++;
} else {
@@ -660,8 +668,8 @@ static void split_focus(app_t *a, split_dir_t dir) {
sp->dir = dir;
sp->nkids = 2;
sp->kids = malloc(2 * sizeof *sp->kids);
- sp->kids[0] = leaf;
- sp->kids[1] = fresh;
+ sp->kids[before ? 1 : 0] = leaf;
+ sp->kids[before ? 0 : 1] = fresh;
sp->parent = leaf->parent;
if (leaf->parent) replace_child(leaf->parent, leaf, sp);
else cur(a)->root = sp;
@@ -673,6 +681,10 @@ static void split_focus(app_t *a, split_dir_t dir) {
layout(a);
}
+static void split_focus(app_t *a, split_dir_t dir) {
+ split_node(a, cur(a)->focus, dir, false);
+}
+
static node_t *first_leaf(node_t *n) {
while (n && n->kind == NODE_SPLIT) n = n->kids[0];
return n;
@@ -1164,6 +1176,35 @@ static void draw_pane_status(screen_t *s, node_t *leaf, color_t fg,
}
}
+/* The split guide: the armed edge goes heavy, and a dashed line shows where
+ * the new boundary would land. Drawn *after* the pane's content, because the
+ * dashed line crosses it — the first version was painted under the terminal
+ * and was invisible. Hover only, so an idle frame stays quiet. */
+static void draw_split_guide(app_t *a, screen_t *s, node_t *leaf) {
+ if (a->hover_pane != leaf->id || !a->hover_side) return;
+ rect_t r = leaf->rect;
+ if (r.w < 4 || r.h < 4) return;
+ uint16_t x1 = (uint16_t)(r.x + r.w - 1), y1 = (uint16_t)(r.y + r.h - 1);
+ color_t hi = FRAME_FOCUS;
+ char side = a->hover_side;
+
+ if (side == 'l' || side == 'r') {
+ uint16_t bx = side == 'l' ? r.x : x1;
+ uint16_t mid = (uint16_t)(r.x + r.w / 2);
+ for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) {
+ screen_text(s, bx, y, "\u2503", hi, NO_COLOR, ATTR_BOLD);
+ screen_text(s, mid, y, "\u254e", hi, NO_COLOR, 0);
+ }
+ } else {
+ uint16_t by = side == 't' ? r.y : y1;
+ uint16_t mid = (uint16_t)(r.y + r.h / 2);
+ for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) {
+ screen_text(s, x, by, "\u2501", hi, NO_COLOR, ATTR_BOLD);
+ screen_text(s, x, mid, "\u254c", hi, NO_COLOR, 0);
+ }
+ }
+}
+
static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
rect_t r = leaf->rect;
if (r.w < 3 || r.h < 3) return;
@@ -1198,20 +1239,21 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
hit_add(&s->hits, r.x, r.y, r.w, 1, action);
}
- /* The split button is budgeted BEFORE the title, and hit-tested from the
- * same rect it is drawn in. Both halves of that sentence are scar tissue
- * from the sl0ppi fork, where a title long enough to fill the frame ate the
- * button's columns and the click landed two cells away from the glyph. */
+ /* No split button. The border *is* the button: clicking an edge splits
+ * toward it, which encodes the direction a single glyph never could, and
+ * gives every frame its columns back. */
uint16_t avail = (uint16_t)(r.w - 2);
- uint16_t btn_x = 0;
- bool has_btn = avail >= 6;
- if (has_btn) {
- btn_x = (uint16_t)(x1 - 2);
- screen_text(s, btn_x, r.y, "+", fg, NO_COLOR, ATTR_BOLD);
+ bool has_btn = false;
+ uint16_t btn_x = x1;
+
+ {
char action[48];
- snprintf(action, sizeof action, "split:%u", leaf->id);
- hit_add(&s->hits, btn_x, r.y, 1, 1, action);
- avail = (uint16_t)(avail - 3);
+ snprintf(action, sizeof action, "border:%u:l", leaf->id);
+ hit_add(&s->hits, r.x, (uint16_t)(r.y + 1), 1, (uint16_t)(r.h - 2), action);
+ snprintf(action, sizeof action, "border:%u:r", leaf->id);
+ hit_add(&s->hits, x1, (uint16_t)(r.y + 1), 1, (uint16_t)(r.h - 2), action);
+ snprintf(action, sizeof action, "border:%u:b", leaf->id);
+ hit_add(&s->hits, r.x, y1, r.w, 1, action);
}
draw_pane_status(s, leaf, fg, focused);
@@ -1325,6 +1367,7 @@ static void draw_cb(node_t *n, void *ud) {
return;
}
pane_compose(n->pane, d->s, n->content.x, n->content.y, n == cur(d->a)->focus);
+ draw_split_guide(d->a, d->s, n);
}
static void draw_tab_strip(app_t *a, screen_t *s) {
@@ -1610,15 +1653,33 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
return;
}
if (ev->maction == MOUSE_PRESS) {
+ /* 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. */
a->drag.kind = DRAG_TITLE;
a->drag.src = id;
a->drag.target = id;
a->drag.x = ev->mx;
a->drag.y = ev->my;
+ a->drag.moved = false;
+ a->drag.side = 't';
app_focus_pane(a, id);
}
return;
}
+ if (strncmp(action, "border:", 7) == 0) {
+ uint32_t id = (uint32_t)strtoul(action + 7, NULL, 10);
+ const char *colon = strchr(action + 7, ':');
+ char side = colon && colon[1] ? colon[1] : 'r';
+ if (ev->maction == MOUSE_PRESS) {
+ a->drag.kind = DRAG_BORDER;
+ a->drag.src = id;
+ a->drag.side = side;
+ a->drag.moved = false;
+ a->drag.x = ev->mx;
+ a->drag.y = ev->my;
+ }
+ return;
+ }
if (strncmp(action, "edge:", 5) == 0) {
if (ev->maction != MOUSE_PRESS) return;
a->drag.kind = DRAG_EDGE;
@@ -1835,8 +1896,25 @@ void app_event(app_t *a, const input_event_t *ev) {
if (!a->painted) break;
const char *action = hit_test(&a->painted->hits, ev->mx, ev->my);
+ /* Which border the pointer is over, for the guide. */
+ if (ev->maction == MOUSE_MOTION && a->drag.kind == DRAG_NONE) {
+ uint32_t hp = 0;
+ char hs = 0;
+ if (action && strncmp(action, "border:", 7) == 0) {
+ hp = (uint32_t)strtoul(action + 7, NULL, 10);
+ const char *colon = strchr(action + 7, ':');
+ hs = colon && colon[1] ? colon[1] : 0;
+ } else if (action && strncmp(action, "title:", 6) == 0) {
+ hp = (uint32_t)strtoul(action + 6, NULL, 10);
+ hs = 't';
+ }
+ a->hover_pane = hp;
+ a->hover_side = hs;
+ }
+
if (a->drag.kind != DRAG_NONE) {
if (ev->maction == MOUSE_MOTION) {
+ a->drag.moved = true;
if (a->drag.kind == DRAG_SELECT) {
node_t *n = pane_by_id(a, a->drag.src);
if (n)
@@ -1856,6 +1934,25 @@ void app_event(app_t *a, const input_event_t *ev) {
a->drag.x = ev->mx;
a->drag.y = ev->my;
} else if (ev->maction == MOUSE_RELEASE) {
+ /* A press that never moved is a click, and a click on an edge
+ * splits toward it. */
+ if (!a->drag.moved &&
+ (a->drag.kind == DRAG_BORDER || a->drag.kind == DRAG_TITLE)) {
+ node_t *n = pane_by_id(a, a->drag.src);
+ if (n) {
+ char side = a->drag.side;
+ bool rows = side == 't' || side == 'b';
+ bool before = side == 'l' || side == 't';
+ split_node(a, n, rows ? SPLIT_ROWS : SPLIT_COLS, before);
+ app_toast(a, side == 'l' ? "split left"
+ : side == 'r' ? "split right"
+ : side == 't' ? "split up"
+ : "split down");
+ }
+ a->drag.kind = DRAG_NONE;
+ a->drag.src = a->drag.target = 0;
+ break;
+ }
if (a->drag.kind == DRAG_SELECT) {
/* Releasing copies, which is the whole point: no menu, no chord,
* the selection *is* the copy. */
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())