diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-14 00:44:19 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-14 00:44:19 +0200 |
| commit | a7d3d42ecbf5d143bd1f98c379051736f97dc0fa (patch) | |
| tree | fed88c198ccb2780b1a63e6136d5612e8a51f7b8 | |
| parent | sl0ptty: fix the split guide describing the layout from before the split (diff) | |
| download | slosh-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.md | 10 | ||||
| -rw-r--r-- | config/config.kdl | 5 | ||||
| -rw-r--r-- | src/app.c | 26 | ||||
| -rw-r--r-- | src/config.c | 3 | ||||
| -rw-r--r-- | src/config.h | 3 | ||||
| -rw-r--r-- | src/server.c | 9 | ||||
| -rw-r--r-- | tests/test_split_ux.py | 86 |
7 files changed, 126 insertions, 16 deletions
@@ -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 @@ -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() |
