feat: steady jittery wheels with KICK_WHEEL_TICKS

A bouncy old wheel emits a burst of discrete ticks per physical notch,
and each tick stepped one overlay tab, so the selection jumped around.
Discrete ticks now accumulate toward a per-step threshold with net
remainders (a +2/-1 burst leaves +1 instead of jumping), mirroring the
smooth-scroll accumulator: deliberate flicks still skip multiple tabs.

The threshold is KICK_WHEEL_TICKS, read per event like
KICK_REPEAT_DELAY_MS; unset or invalid means 1, today's
one-notch-per-step, so good hardware sees no change. It only affects
overlay cycling, never client scrolling, and the leftover fraction
resets when the overlay hides. The smooth 15px threshold is untouched.

Covered by test_overlay_wheel_ticks_env, which spawns with
KICK_WHEEL_TICKS=3 (via a new _nested_session extra_env knob) and
proves two ticks step nowhere while three step exactly once; it fails
on unpatched code.
This commit is contained in:
Andrew Hamilton 2026-09-17 13:48:53 +10:00
parent 3a6fdd4fd4
commit 01fd76e1ce
2 changed files with 98 additions and 8 deletions

35
kick.c
View file

@ -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;
}

View file

@ -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