From 3a6fdd4fd40011e2eb05c8d575bf8e195203fe1f Mon Sep 17 00:00:00 2001 From: Andrew Hamilton Date: Thu, 17 Sep 2026 13:33:31 +1000 Subject: [PATCH] fix: normalize libinput wheel scale so standalone overlay cycling works One wheel notch in standalone did nothing with the overlay up: libinput reports discrete ticks scaled by WLR_POINTER_AXIS_DISCRETE_STEP (x120), so a single notch ran cycle_tab 120 times -- an even number of wraps on two tabs, netting zero visible movement while still swallowing the event. The standalone axis handler now divides by the macro before calling overlay_handle_axis; the client forward path stays raw since the wlroots seat divides it back itself. Nested was never affected (its discrete values already arrive in wire units). Proven by test_standalone_overlay_wheel_libinput_scale, which drives one notch through the real standalone handler via a new wltest --virtual-axis mode and asserts exactly one step (it fails with 120 steps without the fix). wltest also grows the missing axis_source/stop/discrete pointer handlers, without which libwayland aborts any test client forwarded a scroll event, plus axis lines in --pointer-out. --- kick.c | 9 +++++++- tests/test_smoke.py | 52 +++++++++++++++++++++++++++++++++++++++++++++ tests/wltest.c | 49 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/kick.c b/kick.c index c39c340..5b68e7d 100644 --- a/kick.c +++ b/kick.c @@ -10694,7 +10694,14 @@ static void standalone_ptr_axis_event(struct wl_listener *listener, void *data) (void)listener; struct wlr_pointer_axis_event *ev = data; if (!server.seat) return; - if (overlay_handle_axis(ev->orientation, ev->delta, ev->delta_discrete)) + /* libinput scales discrete steps by WLR_POINTER_AXIS_DISCRETE_STEP + (±120 per notch; the seat divides it back off for clients), while + overlay_handle_axis speaks Wayland wire units (±1). Normalize only + for the overlay call -- the client forward below must stay raw. + Sub-notch fractions truncate to 0 and fall through to the smooth + accumulator inside the helper. */ + if (overlay_handle_axis(ev->orientation, ev->delta, + ev->delta_discrete / WLR_POINTER_AXIS_DISCRETE_STEP)) return; wlr_seat_pointer_notify_axis(server.seat, ev->time_msec, ev->orientation, ev->delta, ev->delta_discrete, ev->source, diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 5a2d844..31bb89e 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -5130,6 +5130,41 @@ def test_overlay_wheel_swallowed_by_client(tmp_path): s.mods(0) +def test_standalone_overlay_wheel_libinput_scale(): + """A libinput-scale discrete tick through the real standalone axis + handler steps exactly one tab -- the ×120 must be normalized, or one + notch cycles 120 times (net zero on two tabs) and the wheel looks + dead. Uses a virtual pointer, which feeds the same + standalone_ptr_axis_event libinput does (wlroots scales the wire + discrete itself, so discrete=1 arrives like one real notch).""" + with _run_standalone( + {}, "%s '#ff0000'" % WL, "%s '#0000ff'" % WL, wait="any") as s: + s.wait_surface_mapped(0) + s.wait_surface_mapped(1) + start = s.active() + other = 0 if start != 0 else 1 + s.mods(ALT) + try: + time.sleep(0.7) + n0 = len(re.findall(r"\[wheel\] cycle", s.read_log())) + _oneshot_virtual_axis(s.socket, 0, 15, 1) + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + if len(re.findall(r"\[wheel\] cycle", s.read_log())) > n0: + break + time.sleep(0.05) + else: + pytest.fail("libinput-scale wheel tick never stepped a tab") + n1 = len(re.findall(r"\[wheel\] cycle", s.read_log())) + assert n1 == n0 + 1, \ + f"one notch stepped {n1 - n0}x, want exactly 1" + s.wait_active(other, timeout=10.0) + finally: + s.mods(0) + s.wait_for(r"\[mru\] commit app=%d" % other, timeout=10.0) + assert s.active() == other + + def test_synth_feed_drives_host_listeners(tmp_path): """Synthesized host pointer/touch events go through the real host wl_pointer/wl_touch listeners instead of bypassing them for the shared @@ -5864,6 +5899,23 @@ def _oneshot_virtual_abs(sock, x, y, click=False): pass +def _oneshot_virtual_axis(sock, axis, value, discrete): + """Run a one-shot --virtual-axis (scroll) against a live socket.""" + p = subprocess.Popen( + [WL, "--virtual-axis", str(axis), str(value), str(discrete)], + env=dict(os.environ, WAYLAND_DISPLAY=sock), + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True, + preexec_fn=_preexec_death(os.getpid())) + try: + p.wait(timeout=10.0) + finally: + try: + p.kill() + except Exception: + pass + + def test_standalone_headless_virtual_pointer_motion_click(tmp_path): """A virtual pointer moves the cursor and clicks: the focused window receives pointer motion and button events through the seat.""" diff --git a/tests/wltest.c b/tests/wltest.c index b039271..1a7196e 100644 --- a/tests/wltest.c +++ b/tests/wltest.c @@ -2869,6 +2869,40 @@ static double virtual_dx, virtual_dy; static double virtual_abs_x = -1.0, virtual_abs_y = -1.0; static bool virtual_click; +/* --virtual-axis : send one scroll event through + a virtual pointer (axis 0 = vertical). Values are client wire units: + wlroots scales discrete by WLR_POINTER_AXIS_DISCRETE_STEP itself, so + discrete=1 arrives at the compositor exactly like one libinput wheel + notch. Exits after flushing. */ +static bool virtual_axis_set; +static uint32_t virtual_axis; +static double virtual_axis_value; +static int32_t virtual_axis_discrete; + +static void virtual_axis_run(void) { + display = wl_display_connect(NULL); + if (!display) { + fprintf(stderr, "wltest: virtual-axis: cannot connect\n"); + exit(1); + } + struct wl_registry *reg = wl_display_get_registry(display); + wl_registry_add_listener(reg, ®istry_listener, NULL); + wl_display_roundtrip(display); + if (!virtual_ptr_mgr || !seat) { + fprintf(stderr, "wltest: virtual-axis: manager or seat missing\n"); + exit(1); + } + struct zwlr_virtual_pointer_v1 *vptr = + zwlr_virtual_pointer_manager_v1_create_virtual_pointer( + virtual_ptr_mgr, seat); + zwlr_virtual_pointer_v1_axis(vptr, 1000, virtual_axis, + wl_fixed_from_double(virtual_axis_value)); + zwlr_virtual_pointer_v1_axis_discrete(vptr, 1000, virtual_axis, + wl_fixed_from_double(virtual_axis_value), virtual_axis_discrete); + zwlr_virtual_pointer_v1_frame(vptr); + wl_display_roundtrip(display); +} + static void virtual_motion_run(void) { display = wl_display_connect(NULL); if (!display) { @@ -4091,6 +4125,16 @@ int main(int argc, char **argv) { virtual_abs_x = atof(argv[i + 1]); virtual_abs_y = atof(argv[i + 2]); i += 3; + } else if (strcmp(argv[i], "--virtual-axis") == 0) { + if (i + 3 >= argc) { + fprintf(stderr, "wltest: --virtual-axis needs axis value discrete\n"); + return 1; + } + virtual_axis = (uint32_t)strtoul(argv[i + 1], NULL, 0); + virtual_axis_value = atof(argv[i + 2]); + virtual_axis_discrete = (int32_t)strtol(argv[i + 3], NULL, 0); + virtual_axis_set = true; + i += 4; } else if (strcmp(argv[i], "--virtual-modifiers") == 0) { if (i + 1 >= argc) { fprintf(stderr, "wltest: --virtual-modifiers needs a mask\n"); @@ -4256,6 +4300,11 @@ int main(int argc, char **argv) { virtual_motion_run(); return 0; } + /* Virtual pointer scroll: --virtual-axis sends one axis event. */ + if (virtual_axis_set) { + virtual_axis_run(); + return 0; + } /* Output-power mode: blank/unblank the output, then exit. */ if (power_arg) { power_run();