diff --git a/kick.c b/kick.c index 5b68e7d..7447c05 100644 --- a/kick.c +++ b/kick.c @@ -9312,6 +9312,10 @@ static void handle_window_meta(struct wl_listener *l, void *data) { Carries the remainder across events; cleared whenever the overlay hides so no stale fraction leaks into the next session. */ static double overlay_wheel_acc = 0.0; +/* Notched-wheel accumulation (ticks) towards one overlay tab step. A + jittery wheel emits a burst of ticks per physical notch; counting several + per step stills the selection. Same reset contract as overlay_wheel_acc. */ +static int overlay_wheel_ticks = 0; static void update_overlay_visible(bool alt) { if (alt == host.overlay_visible) return; @@ -9336,6 +9340,7 @@ static void update_overlay_visible(bool alt) { server.mru_alt_anchor = NULL; server.mru_alt_navigated = false; overlay_wheel_acc = 0.0; + overlay_wheel_ticks = 0; } host.overlay_visible = alt; /* The overlay draws as a second pass on top of the composited frame, so @@ -11181,10 +11186,25 @@ static bool overlay_eats_pointer(void) { } /* Smooth-scroll pixels per overlay tab step. Notched wheels send discrete - ticks (one step each) alongside their pixel value; smooth-only devices + ticks (accumulated below) alongside their pixel value; smooth-only devices send value alone, which accumulates here. */ #define OVERLAY_WHEEL_STEP 15.0 +/* Wheel ticks (notches) per overlay tab step. Honours KICK_WHEEL_TICKS so a + jittery wheel can be steadied (e.g. =3); unset or invalid means 1, today's + one-notch-per-step. Read per event like KICK_REPEAT_DELAY_MS. */ +#define KICK_WHEEL_TICKS 1 + +static long kick_wheel_ticks(void) { + const char *e = getenv("KICK_WHEEL_TICKS"); + if (e && *e) { + long v = atol(e); + if (v > 0) + return v; + } + return KICK_WHEEL_TICKS; +} + /* Whether the wheel has anywhere to go: two or more live, non-closing tabs (the same predicate cycle_tab steps over). With fewer, wheel input is still swallowed but steps and logs nothing. */ @@ -11200,8 +11220,9 @@ static bool overlay_can_cycle(void) { /* Vertical wheel while the overlay eats pointer input: cycle tabs exactly like Alt+Up/Down (preview now, Alt release commits via the usual overlay path). discrete > 0 (wheel down) steps to the next tab, < 0 to the - previous one; a zero discrete accumulates value towards one step. - Returns true when eaten (the axis must not reach the client). */ + previous one, accumulated KICK_WHEEL_TICKS per step; a zero discrete + accumulates value towards one step. Returns true when eaten (the axis + must not reach the client). */ static bool overlay_handle_axis(uint32_t axis, double value, int32_t discrete) { if (axis != 0 || !overlay_eats_pointer()) return false; @@ -11210,11 +11231,13 @@ static bool overlay_handle_axis(uint32_t axis, double value, int32_t discrete) { if (!overlay_can_cycle()) return true; if (discrete != 0) { - int dir = discrete > 0 ? 1 : -1; - int32_t n = discrete > 0 ? discrete : -discrete; - for (int32_t i = 0; i < n; i++) { + overlay_wheel_ticks += discrete; + long per = kick_wheel_ticks(); + while (overlay_wheel_ticks >= per || overlay_wheel_ticks <= -per) { + int dir = overlay_wheel_ticks > 0 ? 1 : -1; cycle_tab(dir); kick_log(KICK_LOG_INFO, "[wheel] cycle dir=%+d\n", dir); + overlay_wheel_ticks -= dir * (int)per; } return true; } diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 31bb89e..afadcf2 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -3265,7 +3265,7 @@ def test_tab_death_activates_left_neighbor(session): _assert_alive(session, "compositor exited after first tab died") -def _nested_session(*tabs, extra_args=(), repeat_off=True): +def _nested_session(*tabs, extra_args=(), repeat_off=True, extra_env=()): """Launch a nested compositor with an arbitrary tab list (each tab is a shell command string, as on the CLI). extra_args are passed on the CLI before the tabs (e.g. --modifier super). The `session` fixture exists for @@ -3273,6 +3273,8 @@ def _nested_session(*tabs, extra_args=(), repeat_off=True): repeat_off stretches the keybind repeat delay past any load-stalled tap so discrete chords never catch a wall-clock repeat (see KICK_REPEAT_OFF); the hold-to-repeat test opts out with repeat_off=False. + extra_env appends VAR=value strings to the spawn environment (e.g. + tuning knobs like KICK_WHEEL_TICKS). A spawn whose client dies before opening any window (load-starved client startup: "command exited without opening a window" + "no tabs left") is retried up to 3 times -- no test asserts boot failure, so this masks no @@ -3290,7 +3292,8 @@ def _nested_session(*tabs, extra_args=(), repeat_off=True): "KICK_LOG=2", "KICK_SYNTH_INPUT=1", "KICK_DUMP_DIR=" + dump_dir, "KICK_REPEAT_DELAY_MS=" + (KICK_REPEAT_TEST_DELAY if repeat_off - else KICK_REPEAT_DEFAULT)] + else KICK_REPEAT_DEFAULT), + *extra_env] if os.environ.get("KICK_LIBDECOR"): cmd.append("KICK_LIBDECOR=" + os.environ["KICK_LIBDECOR"]) proc = subprocess.Popen( @@ -5099,6 +5102,70 @@ def test_overlay_wheel_cycle(session): assert session.active() == other +def test_overlay_wheel_ticks_env(): + """KICK_WHEEL_TICKS=N steadies a jittery wheel: with =3, two ticks step + nowhere, the third steps exactly one tab, and six ticks step two tabs. + Unset keeps today's one-notch-per-step (see test_overlay_wheel_cycle). + Spawns its own session since the shared fixture has a fixed env.""" + s = _nested_session(f"{WL} '#ff0000'", f"{WL} '#0000ff'", + extra_env=("KICK_WHEEL_TICKS=3",)) + try: + s.wait_any_active() + s.wait_surface_mapped(0) + s.wait_surface_mapped(1) + start = s.active() + other = 0 if start != 0 else 1 + + def tick(n=1, sign=1): + for _ in range(n): + s.host_ptr(HP_DISCRETE, 0, 1 if sign > 0 else 0xFFFFFFFF) + s.host_ptr(HP_AXIS, 0, 15 if sign > 0 else 0x100000000 - 15) + time.sleep(0.08) + + def wheel_count(): + return len(re.findall(r"\[wheel\] cycle", s.read_log())) + + s.mods(ALT) + try: + time.sleep(0.5) + # Two ticks of three: no step, still on the start tab. + n0 = wheel_count() + tick(2) + time.sleep(0.5) + assert wheel_count() == n0, "sub-threshold ticks stepped a tab" + assert s.active() == start + # Third tick: exactly one step. + tick(1) + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + if wheel_count() > n0: + break + time.sleep(0.05) + else: + pytest.fail("three ticks never stepped a tab") + assert wheel_count() == n0 + 1, "three ticks stepped more than once" + s.wait_active(other, timeout=10.0) + # Three more ticks: exactly one more step (back to start). + n1 = wheel_count() + tick(3) + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + if wheel_count() > n1: + break + time.sleep(0.05) + else: + pytest.fail("three more ticks never stepped again") + time.sleep(0.5) + assert wheel_count() == n1 + 1, "three ticks stepped more than once" + s.wait_active(start, timeout=10.0) + finally: + s.mods(0) + s.wait_for(r"\[mru\] commit app=%d" % start, timeout=10.0) + assert s.active() == start + finally: + s.close() + + def test_overlay_wheel_swallowed_by_client(tmp_path): """While the overlay is up, vertical scroll never reaches clients but horizontal scroll still does: a --pointer-out client sees no new axis