aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-14 00:44:19 +0200
committersl0p <sl0p@sl0p.foo>2026-08-14 00:44:19 +0200
commita7d3d42ecbf5d143bd1f98c379051736f97dc0fa (patch)
treefed88c198ccb2780b1a63e6136d5612e8a51f7b8
parentsl0ptty: fix the split guide describing the layout from before the split (diff)
downloadslosh-a7d3d42ecbf5d143bd1f98c379051736f97dc0fa.tar.gz
slosh-a7d3d42ecbf5d143bd1f98c379051736f97dc0fa.tar.xz
slosh-a7d3d42ecbf5d143bd1f98c379051736f97dc0fa.zip
sl0ptty: the split guide arms on dwell, not on contact
A pointer crossing a border on its way somewhere else made the screen flash. The guide now waits for the pointer to rest (hover_delay_ms, 250ms by default, 0 to disable). The timer is on pointer stillness rather than on the hovered target, which is what makes a sweep across three borders arm none of them: every motion event that changes the position restarts the clock, so only stopping counts. Holding the button on a border skips the wait, because that is intent rather than passage. This needs the loop to wake when nothing has happened — a pointer that is deliberately not moving generates no events to repaint on — which is the same self-scheduled repaint toasts already needed, so app_next_deadline_ms grew a second reason rather than the loop growing a second mechanism. Four older guide tests waited 60ms and now wait for the dwell; they were asserting on contact because contact was all there was. 398 checks green.
-rw-r--r--DESIGN.md10
-rw-r--r--config/config.kdl5
-rw-r--r--src/app.c26
-rw-r--r--src/config.c3
-rw-r--r--src/config.h3
-rw-r--r--src/server.c9
-rw-r--r--tests/test_split_ux.py86
7 files changed, 126 insertions, 16 deletions
diff --git a/DESIGN.md b/DESIGN.md
index 6e47dab..74cc7aa 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -267,6 +267,16 @@ 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.
+**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
+flash, and the timer is on *pointer stillness* rather than on the target, so
+sweeping across three borders arms none of them. Holding the button on a
+border is intent, and skips the wait.
+
+That needs the event loop to wake when nothing has happened — the same
+mechanism toasts already needed, since a pointer that is deliberately not
+moving generates no events to repaint on.
+
**The guide is derived, not remembered.** Which border the pointer is over is
worked out *during the paint*, by asking the hit list that same paint just
filled. The first version remembered the answer from the last motion event,
diff --git a/config/config.kdl b/config/config.kdl
index 4df8de6..7928d11 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -41,6 +41,11 @@ min_pane cols=24 rows=6
// Rows per wheel notch, and per scroll-up/scroll-down.
scroll_lines 3
+// How long the pointer must rest on a pane border before the split guide
+// arms. Contact alone is not enough: a pointer crossing a border on its way
+// somewhere else should not make the screen flash. 0 arms immediately.
+hover_delay_ms 250
+
// How long an announcement ("copied 13 chars", a pane's OSC 9 notification,
// "config reloaded") stays in the bottom-right corner.
toast_ms 2500
diff --git a/src/app.c b/src/app.c
index c913029..b1e5978 100644
--- a/src/app.c
+++ b/src/app.c
@@ -136,6 +136,7 @@ struct app {
* the mouse moved and corrected it. */
uint16_t ptr_x, ptr_y;
bool ptr_valid;
+ int64_t ptr_still_since; /* when the pointer last stopped moving */
/* Transient announcements: "copied 13 chars", a notification from a pane,
* a config reload. They stack upward from the bottom right and expire on
@@ -213,11 +214,25 @@ static void toasts_expire(app_t *a) {
a->ntoasts = keep;
}
-/* When the next toast expires, so a caller's poll can wake for it. */
+/* When something needs repainting on its own: a toast expiring, or a hover
+ * guide arming. Without the second one the guide would appear on the next
+ * event rather than when the dwell is up, which for a resting pointer means
+ * "never". */
int app_next_deadline_ms(app_t *a) {
int64_t soonest = -1;
for (size_t i = 0; i < a->ntoasts; i++)
if (soonest < 0 || a->toasts[i].until < soonest) soonest = a->toasts[i].until;
+
+ if (a->ptr_valid && a->painted) {
+ int64_t due = a->ptr_still_since + CFG.hover_delay_ms;
+ if (due > now_ms_()) {
+ const char *action = hit_test(&a->painted->hits, a->ptr_x, a->ptr_y);
+ bool on_border = action && (strncmp(action, "border:", 7) == 0 ||
+ strncmp(action, "title:", 6) == 0);
+ if (on_border && (soonest < 0 || due < soonest)) soonest = due;
+ }
+ }
+
if (soonest < 0) return -1;
int64_t in = soonest - now_ms_();
return in <= 0 ? 0 : (int)in;
@@ -1187,6 +1202,13 @@ static void draw_split_guide(app_t *a, screen_t *s, node_t *leaf) {
a->drag.kind == DRAG_EDGE)
return; /* a drag in progress means the pointer is busy */
+ /* Arm on dwell, not on contact: a pointer merely crossing a border on its
+ * way somewhere else should not make the screen flash. Holding the button
+ * on a border is intent, so that skips the wait. */
+ if (a->drag.kind != DRAG_BORDER &&
+ now_ms_() - a->ptr_still_since < CFG.hover_delay_ms)
+ return;
+
/* Ask the hit list where the pointer is, using the entries this very pass
* registered for this pane. Drawing and hit-testing cannot disagree, and
* neither can drawing and *the layout*. */
@@ -1916,6 +1938,8 @@ 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);
+ if (!a->ptr_valid || ev->mx != a->ptr_x || ev->my != a->ptr_y)
+ a->ptr_still_since = now_ms_();
a->ptr_x = ev->mx;
a->ptr_y = ev->my;
a->ptr_valid = true;
diff --git a/src/config.c b/src/config.c
index 14e11ba..f173b2d 100644
--- a/src/config.c
+++ b/src/config.c
@@ -127,6 +127,7 @@ void config_defaults(config_t *c) {
c->min_pane_rows = 6;
c->scroll_lines = 3;
c->toast_ms = 2500;
+ c->hover_delay_ms = 250;
c->status_bar = true;
c->focus_follows_mouse = true;
@@ -245,6 +246,8 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
c->scroll_lines =
(uint16_t)kdl_arg_int(kdl_child(root, "scroll_lines"), 0, c->scroll_lines);
c->toast_ms = (uint16_t)kdl_arg_int(kdl_child(root, "toast_ms"), 0, c->toast_ms);
+ c->hover_delay_ms = (uint16_t)kdl_arg_int(kdl_child(root, "hover_delay_ms"), 0,
+ c->hover_delay_ms);
const char *sh = kdl_arg(kdl_child(root, "shell"), 0, NULL);
if (sh) {
diff --git a/src/config.h b/src/config.h
index c7a234e..ec89299 100644
--- a/src/config.h
+++ b/src/config.h
@@ -58,7 +58,8 @@ typedef struct {
align_t title_align;
uint16_t min_pane_cols, min_pane_rows;
uint16_t scroll_lines;
- uint16_t toast_ms; /* how long an announcement stays up */ /* rows per wheel notch */
+ uint16_t toast_ms;
+ uint16_t hover_delay_ms; /* how long the pointer must rest to arm a guide */ /* how long an announcement stays up */ /* rows per wheel notch */
bool status_bar;
bool focus_follows_mouse;
diff --git a/src/server.c b/src/server.c
index 94f55ed..1579281 100644
--- a/src/server.c
+++ b/src/server.c
@@ -217,10 +217,11 @@ int server_run(const char *name, const char *const argv[], uint16_t cols,
int t = due <= 0 ? 0 : (int)due;
if (timeout < 0 || t < timeout) timeout = t;
}
- /* a toast expires on its own, so the loop has to wake for it */
- int toast_due = app_next_deadline_ms(s.app);
- if (toast_due >= 0 && (timeout < 0 || toast_due < timeout)) {
- timeout = toast_due;
+ /* some things happen without an event: a toast expiring, a hover guide
+ * arming under a pointer that is deliberately not moving */
+ int self_due = app_next_deadline_ms(s.app);
+ if (self_due >= 0 && (timeout < 0 || self_due < timeout)) {
+ timeout = self_due;
pending_paint = true;
}
diff --git a/tests/test_split_ux.py b/tests/test_split_ux.py
index 8009ee6..17f368a 100644
--- a/tests/test_split_ux.py
+++ b/tests/test_split_ux.py
@@ -6,6 +6,7 @@ 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
+import time
from harness import Session, check, report
@@ -16,6 +17,12 @@ def hover(s, x, y):
s.send(rf"\e[<35;{x + 1};{y + 1}M")
+def rest(s, x, y):
+ """Hover and stay there: the guide arms on dwell, not on contact."""
+ hover(s, x, y)
+ time.sleep(0.35)
+
+
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")
@@ -82,24 +89,21 @@ def test_the_guide():
"┃" 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)
+ rest(s, p["x"], p["y"] + 2) # left border
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)
+ rest(s, p["x"] + 4, p["y"] + p["h"] - 1) # bottom border
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)
+ rest(s, p["content_x"] + 3, p["content_y"] + 2) # into the content
snap = s.snapshot()
check("moving off a border puts the guide away",
"━" not in snap.screen() and "┃" not in snap.screen(),
@@ -119,8 +123,7 @@ def test_guide_follows_the_new_layout():
p = s.pane()
edge_x, edge_y = p["x"] + p["w"] - 1, p["y"] + 3
- hover(s, edge_x, edge_y)
- s.settle(60)
+ rest(s, edge_x, edge_y)
click(s, edge_x, edge_y)
s.settle(100) # and deliberately no further mouse movement
@@ -141,14 +144,74 @@ def test_guide_follows_the_new_layout():
str(snap.hit_at(edge_x, edge_y)))
+def test_the_guide_waits_for_a_rest():
+ """A pointer crossing a border on its way elsewhere must not flash it."""
+ with Session(SH, cols=54, rows=12) as s:
+ s.settle()
+ p = s.pane()
+ left, right = p["x"], p["x"] + p["w"] - 1
+ row = p["y"] + 3
+
+ hover(s, left, row)
+ snap = s.snapshot() # no dwell: this is the moment of contact
+ check("touching a border does not arm it immediately",
+ "┃" not in snap.screen(), repr(snap.screen()[:160]))
+
+ # keep moving: across the pane and onto the other border
+ hover(s, left + 6, row)
+ hover(s, right, row)
+ snap = s.snapshot()
+ check("passing over borders arms nothing", "┃" not in snap.screen(),
+ repr(snap.screen()[:160]))
+
+ time.sleep(0.4) # now rest
+ snap = s.snapshot()
+ 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",
+ str(snap.hit_at(right, row)))
+
+ hover(s, p["content_x"] + 2, row)
+ snap = s.snapshot()
+ check("moving away disarms it at once", "┃" not in snap.screen(),
+ repr(snap.screen()[:160]))
+
+
+def test_press_skips_the_wait():
+ with Session(SH, cols=54, rows=12) as s:
+ s.settle()
+ p = s.pane()
+ x, y = p["x"] + p["w"] - 1, p["y"] + 3
+ s.send(rf"\e[<0;{x + 1};{y + 1}M") # press and hold: that is intent
+ snap = s.snapshot()
+ check("holding the button on a border arms it immediately",
+ "┃" in snap.screen(), repr(snap.screen()[:200]))
+ s.send(rf"\e[<0;{x + 1};{y + 1}m")
+
+
+def test_delay_is_configurable():
+ import os, tempfile
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write("hover_delay_ms 0\n")
+ f.close()
+ with Session(SH, cols=54, rows=12, config=f.name) as s:
+ s.settle()
+ p = s.pane()
+ hover(s, p["x"], p["y"] + 3)
+ snap = s.snapshot()
+ check("with no delay configured it arms on contact",
+ "┃" in snap.screen(), repr(snap.screen()[:160]))
+ os.unlink(f.name)
+
+
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)
+ rest(s, left["x"], left["y"] + 2)
snap = s.snapshot()
row = snap.line(left["y"] + 2)
check("the hovered pane is armed", "┃" in row[left["x"]:left["x"] + left["w"]],
@@ -204,6 +267,9 @@ if __name__ == "__main__":
test_each_border_splits_toward_itself()
test_the_guide()
test_guide_follows_the_new_layout()
+ test_the_guide_waits_for_a_rest()
+ test_press_skips_the_wait()
+ test_delay_is_configurable()
test_guide_is_per_pane()
test_drag_still_moves()
test_tiny_panes_have_no_targets()