diff options
| author | user <user@clank> | 2026-08-18 16:20:37 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-18 16:20:37 +0200 |
| commit | 13967ce08d440a850665c81d407e710c1c77394d (patch) | |
| tree | 673e1fa8208474bceee1f00a6532f280335de879 | |
| parent | sl0ppty: the split arrow belongs on the button (diff) | |
| download | slosh-13967ce08d440a850665c81d407e710c1c77394d.tar.gz slosh-13967ce08d440a850665c81d407e710c1c77394d.tar.xz slosh-13967ce08d440a850665c81d407e710c1c77394d.zip | |
sl0ppty: aim the top handle at the middle, and stop ruling through the row
Two faults that fed each other, and only the second one made the first visible.
The handle went to the **widest** free run of the top row. A centred title leaves
two runs of nearly the same width, so a one-cell difference nobody can see threw
the handle out toward a corner: on a 116-column pane whose row middle is 60, it
sat at 85. Placement should be stated in terms of where the thing wants to be,
not of the leftovers it is allowed to occupy -- so it now aims at the middle of
the row and slides into the nearest run that is free. Nothing in the way: centred,
like the other three sides. A title holding the middle: hard against the title,
where the eye already is. That same pane now puts it at 64.
The armed edge also ruled the heavy line straight across the whole border, over
the pane's name, its buttons, its scroll indicator and a dead pane's epitaph. The
row went unreadable exactly while the pointer was on it -- and the title the
handle had just been placed beside was wiped, which is what turned a merely-off
position into an apparently random one. Each cell of the edge is now painted only
if the hit list says the edge still owns it: the same question the handle asks,
and the one the click will ask.
The epitaph had to start claiming its cells to take part. It answers
`panestatus:` and does nothing when clicked, because an epitaph is not a button;
what it buys is that ` exited: status 3 ` survives a hover over the border it
lives on, next to `[re-run]` and `[close]`, which already survived because they
were buttons.
Verified: `make retest`, **1877 checks measured**, all green. Four new: the handle
is centred on the row when the row is empty, sits against the title when the
title holds the middle, and the armed top edge leaves both the name and the three
buttons legible.
Live on a 116x36 pane, hovering the far left of the top row -- deliberately the
rim, so this is the first stage doing all of it:
╭━━━━━━━━━━━━━━━━━━━ sl0ppty ███▲███━━━━━━━━━━━━━━━━━▬ □ ✕ ╮
and on a dead pane, hovering the bottom handle:
╰ exited: status 3 ━━━━━━━━━━███▼███━━━━━━━━[re-run]━[close]━╯
| -rw-r--r-- | DESIGN.md | 31 | ||||
| -rw-r--r-- | docs/panes.md | 8 | ||||
| -rw-r--r-- | src/app.c | 95 | ||||
| -rw-r--r-- | tests/test_split_ux.py | 52 |
4 files changed, 157 insertions, 29 deletions
@@ -333,12 +333,31 @@ The top border keeps its other job: **the row drags the pane, its handle also splits upward**. A press that never moves is a click — the same distinction the drag machine already had to make — and the row itself now takes no side, so clicking a pane's header to focus it cannot split it. That row is also the one -place the handle cannot simply sit in the middle: the buttons own its right, -and a centred title (the default) owns exactly the middle. The title winning is -the rule — that 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, nearest the middle among equals. A row with nothing left offers no upward -split and its other three edges are unaffected. +place the handle cannot simply sit in the middle: the buttons own its right, and +a centred title (the default) owns exactly the middle. The title winning is the +rule — that is what keeps a double-click rename from splitting twice on its way +— so the handle is placed last, **as near the middle of the row as the row +allows**: aimed at the middle and slid into the nearest run of cells that is +still free. With nothing in the way that is simply centred, like the other three +sides; with a title in the way the handle ends up against it, where the eye +already is. A row with nothing left offers no upward split and its other three +edges are unaffected. + +Choosing the *widest* free run was the first rule and it was wrong in a way +worth recording: two runs either side of a centred title differ by a cell for +reasons nobody can see, so the handle jumped to whichever won and looked as +though it had been dropped at random. Placement should be stated in terms of +where the thing wants to be, not of the leftovers it is allowed to occupy. + +**The armed edge stays off the row's own furniture.** A pane's name, its +buttons, its scroll indicator and a dead pane's epitaph all live on a border, +and ruling the heavy line straight through them made the row unreadable exactly +while the pointer was on it — and hid the title the handle had just been placed +beside, which is what made a merely-off position look arbitrary. Each cell of the +edge is painted only if the hit list says the edge still owns it, which is the +same question the handle asks and the same one the click will ask. The epitaph +had to start claiming its cells to take part; it answers `panestatus:` and does +nothing when clicked, because an epitaph is not a button. **The guide arms on dwell, not on contact** (`hover_delay_ms`, 250ms). A pointer crossing a border on its way somewhere else should not make the screen diff --git a/docs/panes.md b/docs/panes.md index b341821..dd1c435 100644 --- a/docs/panes.md +++ b/docs/panes.md @@ -45,9 +45,11 @@ nothing you click can disagree with what is on screen. wall. Resting *on* the handle adds a dashed line where the new boundary will land. The rest of the edge does nothing when clicked. - **The top row is the drag handle, and its handle splits upward.** Clicking a - pane's header to focus it never splits it. On a titled pane the handle sits - wherever the title and the buttons left room, since the title keeps its own - cells for the double-click rename. + pane's header to focus it never splits it. Its handle goes as near the middle + of the row as the row allows: centred when the row is empty, and against the + title when the title is holding the middle, since the title keeps its own cells + for the double-click rename. Arming an edge leaves the name, the buttons and a + dead pane's epitaph legible — the heavy rule goes round them. - **Drag the gap between panes** to move that boundary. Where two gaps cross, drag the crossing to move both at once. - **Drag a pane by its title** onto another to swap them. Everything it could @@ -3316,9 +3316,18 @@ static void draw_pane_status(app_t *a, screen_t *s, node_t *leaf, color_t fg, len = room; buf[len] = 0; } - screen_text(s, left, y, buf, - dead ? DEAD_C : (focused ? TITLE_FOCUS : TITLE_IDLE), NO_COLOR, - dead ? ATTR_BOLD : 0); + uint16_t drawn = screen_text( + s, left, y, buf, dead ? DEAD_C : (focused ? TITLE_FOCUS : TITLE_IDLE), + NO_COLOR, dead ? ATTR_BOLD : 0); + /* Claims its cells, the way the name on the top row does. Nothing clicks it + * -- an epitaph is not a button -- but owning the cells is what keeps the + * armed split guide from ruling a line through the one sentence saying how + * this pane died. */ + if (drawn) { + char action[48]; + snprintf(action, sizeof action, "panestatus:%u", leaf->id); + hit_add(&s->hits, left, y, drawn, 1, action); + } } } @@ -3379,6 +3388,17 @@ static void split_handle_action(const node_t *leaf, char side, char *buf, snprintf(buf, cap, "border:%u:%c", leaf->id, side); } +/* And what the rest of that side is called: the part that arms the guide and + * does nothing when pressed. The top row's rim is the drag handle, so it keeps + * the plain `title:` name it always had. */ +static void split_rim_action(const node_t *leaf, char side, char *buf, + size_t cap) { + if (side == 't') + snprintf(buf, cap, "title:%u", leaf->id); + else + snprintf(buf, cap, "brim:%u:%c", leaf->id, side); +} + /* The split guide, in two stages, because a hover answers two questions. * * Anywhere on the side: the edge goes heavy and the handle thickens. That says @@ -3445,15 +3465,35 @@ static void draw_split_guide(app_t *a, screen_t *s, node_t *leaf) { uint16_t x1 = (uint16_t)(r.x + r.w - 1), y1 = (uint16_t)(r.y + r.h - 1); color_t hi = GUIDE; - /* Stage one: the armed edge, and the handle on it. */ + /* Stage one: the armed edge, on the cells the edge actually owns. + * + * A pane's name, its buttons and its scroll indicator all live on the top + * border, and the first version ruled straight through them: the row went + * unreadable exactly while the pointer was on it, and the handle looked as + * though it had been dropped at random, because the title it sits beside had + * been painted over. Asking the hit list which cells belong to this edge is + * the same question the handle already asks two lines down, and it costs one + * lookup per cell of one edge. + * + * The dead row's own buttons sit on the bottom border for the same reason, so + * this is not a top-row special case. */ + char rim[48], act[48]; + split_rim_action(leaf, side, rim, sizeof rim); + split_handle_action(leaf, side, act, sizeof act); if (side == 'l' || side == 'r') { uint16_t bx = side == 'l' ? r.x : x1; - for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) - screen_text(s, bx, y, "\u2503", hi, NO_COLOR, ATTR_BOLD); + for (uint16_t y = (uint16_t)(r.y + 1); y < y1; y++) { + const char *own = hit_test(&s->hits, bx, y); + if (own && (strcmp(own, rim) == 0 || strcmp(own, act) == 0)) + screen_text(s, bx, y, "\u2503", hi, NO_COLOR, ATTR_BOLD); + } } else { uint16_t by = side == 't' ? r.y : y1; - for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) - screen_text(s, x, by, "\u2501", hi, NO_COLOR, ATTR_BOLD); + for (uint16_t x = (uint16_t)(r.x + 1); x < x1; x++) { + const char *own = hit_test(&s->hits, x, by); + if (own && (strcmp(own, rim) == 0 || strcmp(own, act) == 0)) + screen_text(s, x, by, "\u2501", hi, NO_COLOR, ATTR_BOLD); + } } /* The handle, made heavier, and — where that means claiming a cell the pane @@ -3474,8 +3514,6 @@ static void draw_split_guide(app_t *a, screen_t *s, node_t *leaf) { * where it is. Each cell is then painted only if the list still says it * belongs to the handle, which is what keeps a block off the pane's name. * Drawing asks the question the click is going to ask. */ - char act[48]; - split_handle_action(leaf, side, act, sizeof act); const hit_t *core = hit_find(&s->hits, act); if (core) { rect_t h = {core->x, core->y, core->w, core->h}; @@ -3815,27 +3853,44 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { runs[nruns++] = (struct run){lo, (uint16_t)(right_lo - 1)}; } + /* As near the middle of the row as the row allows. + * + * Centred in the widest *run* was the first rule and it looked arbitrary, + * because it is: with a centred title the row leaves two runs of nearly the + * same size, one of them a cell longer for reasons no one can see, and the + * handle floated off into the middle of whichever won. Aiming at the row's + * middle and sliding into the run instead means the handle hugs whatever is + * in the way -- so it sits against the title, where the eye already is, + * rather than halfway to the corner. With nothing in the way it is simply + * centred, which is the same rule the other three sides follow. */ uint16_t mid = (uint16_t)(r.x + r.w / 2); - uint16_t best_lo = 0, best_len = 0, best_dist = 0; + uint16_t cap = split_handle_len((uint16_t)(r.w - 2)); + bool found = false; + uint16_t best_x = 0, best_w = 0, best_dist = 0; for (size_t i = 0; i < nruns; i++) { if (runs[i].hi < runs[i].lo) continue; uint16_t len = (uint16_t)(runs[i].hi - runs[i].lo + 1); if (len < 3) continue; - uint16_t c = (uint16_t)(runs[i].lo + len / 2); + uint16_t want = cap > len ? len : cap; + uint16_t last = (uint16_t)(runs[i].hi - want + 1); + uint16_t start = mid > want / 2 ? (uint16_t)(mid - want / 2) : runs[i].lo; + if (start < runs[i].lo) start = runs[i].lo; + if (start > last) start = last; + uint16_t c = (uint16_t)(start + want / 2); uint16_t dist = (uint16_t)(c > mid ? c - mid : mid - c); - if (len > best_len || (len == best_len && dist < best_dist)) { - best_lo = runs[i].lo; - best_len = len; + /* Nearest wins; a tie goes to the longer handle, which can only happen + * when one run is too short to hold a full one. */ + if (!found || dist < best_dist || (dist == best_dist && want > best_w)) { + found = true; + best_x = start; + best_w = want; best_dist = dist; } } - if (best_len >= 3) { - uint16_t want = split_handle_len((uint16_t)(r.w - 2)); - if (want > best_len) want = best_len; + if (found) { char action[48]; split_handle_action(leaf, 't', action, sizeof action); - hit_add(&s->hits, (uint16_t)(best_lo + (best_len - want) / 2), r.y, want, - 1, action); + hit_add(&s->hits, best_x, r.y, best_w, 1, action); } } } diff --git a/tests/test_split_ux.py b/tests/test_split_ux.py index 333ce07..e677ea1 100644 --- a/tests/test_split_ux.py +++ b/tests/test_split_ux.py @@ -627,6 +627,57 @@ def test_the_arrow_rides_the_handle(): ) +def test_the_top_handle_aims_at_the_middle_and_keeps_the_row_readable(): + """Two faults that fed each other. + + The handle went to the *widest* free run of the top row, and a centred title + leaves two runs of nearly the same width — so a one-cell difference nobody + can see threw it out toward a corner. And the armed edge ruled straight + through the title, so the thing it sits beside was invisible, which is what + made the position look arbitrary rather than merely off. + """ + plain = ["/bin/sh", "-c", "stty raw -echo; cat"] + with Session(plain, cols=110, rows=28, config=FAST) as s: + s.settle() + p = s.pane() + e = [h for h in s.snapshot().hits if h["action"] == f"title:{p['id']}:t"][0] + mid = p["x"] + p["w"] // 2 + centre = e["x"] + e["w"] // 2 + check( + "with nothing in the way the handle is centred on the row", + abs(centre - mid) <= 1, + f"handle centre {centre}, row middle {mid}", + ) + + with Session(SH, cols=110, rows=28, config=FAST) as s: # SH sets a title + s.settle() + p = s.pane() + snap = s.snapshot() + e = [h for h in snap.hits if h["action"] == f"title:{p['id']}:t"][0] + name = [h for h in snap.hits if h["action"] == f"panetitle:{p['id']}"][0] + check( + "and when the title holds the middle it sits against it", + e["x"] == name["x"] + name["w"] or e["x"] + e["w"] == name["x"], + f"handle {e['x']}..{e['x'] + e['w'] - 1}, " + f"title {name['x']}..{name['x'] + name['w'] - 1}", + ) + + # ...which only reads as deliberate if the armed edge leaves the title be. + hx, hy = snap.handle(p["id"], "t") + rest(s, hx, hy) + row = s.snapshot().text[p["y"]] + check( + "the armed top edge does not rule through the name", + "shell" in row, + repr(row), + ) + check( + "nor through the buttons", + all(m in row for m in ("▬", "□", "✕")), + repr(row), + ) + + # ---- `C-a Enter`: split whichever way there is room for ----------------------- @@ -815,6 +866,7 @@ if __name__ == "__main__": test_the_keyboard_obeys_the_same_floor() test_the_guide_says_which_way_it_goes() test_the_arrow_rides_the_handle() + test_the_top_handle_aims_at_the_middle_and_keeps_the_row_readable() test_it_splits_across_the_longer_side() test_the_aspect_is_the_configured_one() test_it_falls_back_to_the_axis_that_fits() |
