aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 16:18:04 +0200
committeruser <user@clank>2026-08-14 16:18:04 +0200
commitb0e42c5ff20098fc2fa5f44cbb28cc4f7b1e11e6 (patch)
treea972d8446a1f812d19ccb2fa12e6d1a12cc3f099
parentsl0ppty: a tab is laid out or it is a list, never half of each (diff)
downloadslosh-b0e42c5ff20098fc2fa5f44cbb28cc4f7b1e11e6.tar.gz
slosh-b0e42c5ff20098fc2fa5f44cbb28cc4f7b1e11e6.tar.xz
slosh-b0e42c5ff20098fc2fa5f44cbb28cc4f7b1e11e6.zip
sl0ppty: a row in the stack lights up under the pointer
Once a tab is a list, its rows are what you are picking from, and until now they gave no sign of being pickable at all — a flat rule that looked the same whether the mouse was on it or on the other side of the screen. Lit immediately rather than on dwell. The dwell that keeps a split guide from flashing as the pointer crosses a border is exactly wrong here: this is feedback about where the pointer *is*, not an action being armed, and a list you are scanning has to track the mouse continuously or it feels broken. The finder's language for "this is the row", a solid bar, rather than a brighter rule. FRAME_FOCUS would have read as "this pane is focused", which is a different claim and one a collapsed row cannot make. Hovering deliberately does not open the row. focus: already ignored everything but a press, so this needed no guard — but it is the property worth keeping and now has a test: a list that opened panes as the mouse crossed it would shuffle under you while you read it. Clicking is still what opens one. The lit row is decided from the same rect that is registered as the row's hit, in the same function, so what lights up and what would be clicked cannot drift apart.
-rw-r--r--src/app.c27
-rw-r--r--tests/test_responsive.py95
2 files changed, 119 insertions, 3 deletions
diff --git a/src/app.c b/src/app.c
index bf463c9..ace5119 100644
--- a/src/app.c
+++ b/src/app.c
@@ -1584,9 +1584,30 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) {
status && *status ? " · " : "", status && *status ? status : "");
line[r.w < sizeof line ? r.w : sizeof line - 1] = 0;
- for (uint16_t x = r.x; x < r.x + r.w; x++)
- screen_text(s, x, r.y, "─", FRAME_IDLE, NO_COLOR, 0);
- screen_text(s, r.x, r.y, line, FRAME_IDLE, NO_COLOR, 0);
+ /* Once a tab is a list, its rows are what you are picking from, and a row
+ * under the pointer should say so. Immediate rather than on dwell: this is
+ * feedback about where the pointer *is*, not an action being armed, and a
+ * list you are scanning should track the mouse continuously — the dwell that
+ * stops a guide from flashing would only make this feel broken.
+ *
+ * Tested against the rect registered as the hit two lines below, so the row
+ * that lights up and the row that would be clicked are the same row by
+ * construction. A drag already owns the pointer and says nothing here. */
+ bool hot = a->ptr_valid && a->drag.kind == DRAG_NONE && a->ptr_y == r.y &&
+ a->ptr_x >= r.x && a->ptr_x < r.x + r.w;
+
+ /* The finder's language for "this is the row": a solid bar, not a brighter
+ * rule. FRAME_FOCUS would have read as "this pane is focused", which is a
+ * different claim and one this row cannot make. */
+ if (hot)
+ for (uint16_t x = r.x; x < r.x + r.w; x++)
+ screen_text(s, x, r.y, " ", BTN_FG, BTN_BG, 0);
+ else
+ for (uint16_t x = r.x; x < r.x + r.w; x++)
+ screen_text(s, x, r.y, "─", FRAME_IDLE, NO_COLOR, 0);
+
+ screen_text(s, r.x, r.y, line, hot ? BTN_FG : FRAME_IDLE,
+ hot ? BTN_BG : NO_COLOR, hot ? ATTR_BOLD : 0);
char action[48];
snprintf(action, sizeof action, "focus:%u", leaf->id);
diff --git a/tests/test_responsive.py b/tests/test_responsive.py
index 985191b..a45d58f 100644
--- a/tests/test_responsive.py
+++ b/tests/test_responsive.py
@@ -293,6 +293,98 @@ def test_a_tab_is_laid_out_or_it_is_a_list_never_both():
os.unlink(lay)
+# ---- hovering a row in the stack -------------------------------------------
+
+BTN_BG = "#ff5fd7"
+
+
+def stacked_session():
+ """A tab small enough to be a list, with two headers and one body."""
+ lay = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ lay.write('layout {\n tab name="t" {\n pane\n'
+ ' pane split="rows" { pane\n pane }\n }\n}\n')
+ lay.close()
+ conf = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ conf.write("min_pane cols=24 rows=17\n")
+ conf.close()
+ return Session(SH, cols=100, rows=20, config=conf.name, layout=lay.name)
+
+
+def hover(s, x, y):
+ s.send(rf"\e[<35;{x + 1};{y + 1}M")
+
+
+def row_bg(snap, pane):
+ return (snap.style_at(pane["x"] + 4, pane["y"]) or {}).get("bg")
+
+
+def test_hovering_a_row_lights_it_up():
+ with stacked_session() as s:
+ s.settle(20)
+ headers = [p for p in s.panes() if p["hidden"]]
+ check("the tab is a list with rows to hover", len(headers) >= 2,
+ str(s.panes()))
+ if len(headers) < 2:
+ return
+
+ snap = s.snapshot()
+ check("nothing is lit before the pointer arrives",
+ all(row_bg(snap, p) != BTN_BG for p in headers), "")
+
+ hover(s, headers[0]["x"] + 4, headers[0]["y"])
+ snap = s.snapshot()
+ check("the row under the pointer is lit",
+ row_bg(snap, headers[0]) == BTN_BG, str(row_bg(snap, headers[0])))
+ check("and only that row",
+ all(row_bg(snap, p) != BTN_BG for p in headers[1:]), "")
+
+ hover(s, headers[1]["x"] + 4, headers[1]["y"])
+ snap = s.snapshot()
+ check("the light follows the pointer down the list",
+ row_bg(snap, headers[1]) == BTN_BG
+ and row_bg(snap, headers[0]) != BTN_BG, "")
+
+
+def test_leaving_the_stack_puts_the_light_out():
+ with stacked_session() as s:
+ s.settle(20)
+ headers = [p for p in s.panes() if p["hidden"]]
+ body = [p for p in s.panes() if not p["hidden"]][0]
+ if not headers:
+ return
+ hover(s, headers[0]["x"] + 4, headers[0]["y"])
+ hover(s, body["content_x"] + 2, body["content_y"] + 2)
+ snap = s.snapshot()
+ check("no row is lit once the pointer is off the list",
+ all(row_bg(snap, p) != BTN_BG for p in headers), "")
+
+
+def test_hovering_a_row_does_not_open_it():
+ """Feedback about where the pointer is, not an action. Opening on hover
+ would make the list shuffle under the mouse as you read it."""
+ with stacked_session() as s:
+ s.settle(20)
+ before = [p["id"] for p in s.panes() if p["focused"]]
+ headers = [p for p in s.panes() if p["hidden"]]
+ if not headers:
+ return
+ for h in headers:
+ hover(s, h["x"] + 4, h["y"])
+ s.settle(20)
+ after = [p["id"] for p in s.panes() if p["focused"]]
+ check("hovering every row changes nothing about which is open",
+ before == after, f"{before} -> {after}")
+ check("and they are all still headers",
+ len([p for p in s.panes() if p["hidden"]]) == len(headers), "")
+
+ # ...but clicking one still does open it.
+ s.click(headers[-1]["x"] + 4, headers[-1]["y"])
+ s.settle(20)
+ now = {p["id"]: p for p in s.panes()}
+ check("clicking the row it lit is what opens that pane",
+ not now[headers[-1]["id"]]["hidden"], str(s.panes()))
+
+
if __name__ == "__main__":
test_collapse()
test_collapse_expand_cycle()
@@ -304,4 +396,7 @@ if __name__ == "__main__":
test_every_collapsed_pane_is_clickable()
test_a_flattened_stack_offers_no_resize_handles()
test_a_tab_is_laid_out_or_it_is_a_list_never_both()
+ test_hovering_a_row_lights_it_up()
+ test_leaving_the_stack_puts_the_light_out()
+ test_hovering_a_row_does_not_open_it()
sys.exit(report())