From 8dd25ece1073653c95ed26450be2739d3e0e30fd Mon Sep 17 00:00:00 2001 From: Ilya Kamenko Date: Mon, 2 Mar 2026 18:49:53 +0100 Subject: [PATCH] Fold hold-to-scroll into existing scroll button lock mode Instead of adding a new ENABLED_HOLD enum value, modify the existing ENABLED lock mode so that hold+scroll+release doesn't engage the lock. Add a 500ms grace period: if the button was held and used to scroll for longer than 500ms, releasing the button does not engage the lock (temporary scroll). If released within 500ms (e.g. shaky hands triggering accidental motion), the lock still engages as before. This fixes the unintuitive behavior where the lock engages even after actively scrolling, without requiring new API surface. Closes: https://gitlab.freedesktop.org/libinput/libinput/-/issues/1259 Co-Authored-By: Claude Opus 4.6 Part-of: --- doc/user/scrolling.rst | 6 ++ src/evdev.c | 10 +++ src/libinput.h | 4 +- test/litest.h | 1 + test/test-pointer.c | 144 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 1 deletion(-) diff --git a/doc/user/scrolling.rst b/doc/user/scrolling.rst index 2adec76f..ddc47b4a 100644 --- a/doc/user/scrolling.rst +++ b/doc/user/scrolling.rst @@ -124,6 +124,12 @@ button lock, the button is now considered logically held down. Pressing and releasing the button a second time logically releases the button. While the button is logically held down, motion events are converted to scroll events. +If the button is held and used to scroll for longer than a short grace +period, releasing the button does not engage the lock. This allows +hold-to-scroll for short, precise adjustments without accidentally toggling +the lock. A quick click or a brief scroll within the grace period still +engages the lock as normal. + .. _scroll_sources: ------------------------------------------------------------------------------ diff --git a/src/evdev.c b/src/evdev.c index c42c559c..6c5ec1e5 100644 --- a/src/evdev.c +++ b/src/evdev.c @@ -54,6 +54,7 @@ #define DEFAULT_WHEEL_CLICK_ANGLE 15 #define DEFAULT_BUTTON_SCROLL_TIMEOUT usec_from_millis(200) +#define SCROLL_BUTTON_LOCK_GRACE_TIMEOUT usec_from_millis(500) enum evdev_device_udev_tags { EVDEV_UDEV_TAG_NONE = 0, @@ -228,6 +229,15 @@ evdev_button_scroll_button(struct evdev_device *device, usec_t time, int is_pres break; /* handle event */ case BUTTONSCROLL_LOCK_FIRSTDOWN: assert(!is_press); + if (device->scroll.button_scroll_state == BUTTONSCROLL_SCROLLING && + usec_cmp(usec_delta(time, device->scroll.button_down_time), + SCROLL_BUTTON_LOCK_GRACE_TIMEOUT) >= 0) { + /* held + scrolled past grace period: temporary scroll, + * no lock engaged */ + device->scroll.lock_state = BUTTONSCROLL_LOCK_IDLE; + evdev_log_debug(device, "scroll lock: temp scroll done\n"); + break; /* pass release through */ + } device->scroll.lock_state = BUTTONSCROLL_LOCK_FIRSTUP; evdev_log_debug(device, "scroll lock: first up\n"); return; /* filter release event */ diff --git a/src/libinput.h b/src/libinput.h index 70a87e1b..481cbd69 100644 --- a/src/libinput.h +++ b/src/libinput.h @@ -6624,7 +6624,9 @@ enum libinput_config_scroll_button_lock_state { * If the state is * @ref LIBINPUT_CONFIG_SCROLL_BUTTON_LOCK_ENABLED, the button is considered * logically down after the first press and release sequence, and logically - * up after the second press and release sequence. + * up after the second press and release sequence. If the button is held + * and used to scroll for longer than a short grace period, releasing the + * button does not engage the lock. * * @param device The device to configure * @param state The state to set the scroll button lock to diff --git a/test/litest.h b/test/litest.h index bac7c399..d2d00a8e 100644 --- a/test/litest.h +++ b/test/litest.h @@ -1359,6 +1359,7 @@ _litest_timeout(struct libinput *li, const char *func, int lineno, int millis); #define litest_timeout_debounce(li_) litest_timeout(li_, 30) #define litest_timeout_softbuttons(li_) litest_timeout(li_, 300) #define litest_timeout_buttonscroll(li_) litest_timeout(li_, 300) +#define litest_timeout_scroll_button_lock_grace(li_) litest_timeout(li_, 600) #define litest_timeout_wheel_scroll(li_) litest_timeout(li_, 600) #define litest_timeout_edgescroll(li_) litest_timeout(li_, 300) #define litest_timeout_finger_switch(li_) litest_timeout(li_, 140) diff --git a/test/test-pointer.c b/test/test-pointer.c index e0bef987..56540551 100644 --- a/test/test-pointer.c +++ b/test/test-pointer.c @@ -2238,6 +2238,146 @@ START_TEST(pointer_scroll_button_lock_doubleclick_nomove) } END_TEST +START_TEST(pointer_scroll_button_lock_scroll_releases) +{ + struct litest_device *dev = litest_current_device(); + struct libinput *li = dev->libinput; + + litest_enable_scroll_button_lock(dev, BTN_LEFT); + litest_disable_middleemu(dev); + litest_drain_events(li); + + /* Press, wait for buttonscroll timeout, scroll, wait past grace + period, release. The lock should NOT engage. */ + litest_button_click_debounced(dev, li, BTN_LEFT, true); + litest_timeout_buttonscroll(li); + litest_dispatch(li); + + for (int i = 0; i < 10; i++) { + litest_event(dev, EV_REL, REL_X, 1); + litest_event(dev, EV_REL, REL_Y, 6); + litest_event(dev, EV_SYN, SYN_REPORT, 0); + } + litest_dispatch(li); + litest_assert_only_axis_events(li, LIBINPUT_EVENT_POINTER_SCROLL_CONTINUOUS); + + litest_timeout_scroll_button_lock_grace(li); + + litest_button_click_debounced(dev, li, BTN_LEFT, false); + litest_dispatch(li); + + /* Expect scroll stop, no lock */ + litest_assert_scroll(li, LIBINPUT_EVENT_POINTER_SCROLL_CONTINUOUS, 0, 0); + + /* Subsequent motion should be pointer motion, not scroll */ + for (int i = 0; i < 10; i++) { + litest_event(dev, EV_REL, REL_X, 1); + litest_event(dev, EV_REL, REL_Y, 6); + litest_event(dev, EV_SYN, SYN_REPORT, 0); + } + litest_assert_only_typed_events(li, LIBINPUT_EVENT_POINTER_MOTION); +} +END_TEST + +START_TEST(pointer_scroll_button_lock_scroll_within_grace) +{ + struct litest_device *dev = litest_current_device(); + struct libinput *li = dev->libinput; + + litest_enable_scroll_button_lock(dev, BTN_LEFT); + litest_disable_middleemu(dev); + litest_drain_events(li); + + /* Press, wait for buttonscroll timeout, scroll briefly, release + immediately (total < 500ms from press). The lock SHOULD engage + since we're within the grace period. */ + litest_button_click_debounced(dev, li, BTN_LEFT, true); + litest_timeout_buttonscroll(li); + litest_dispatch(li); + + for (int i = 0; i < 3; i++) { + litest_event(dev, EV_REL, REL_X, 1); + litest_event(dev, EV_REL, REL_Y, 6); + litest_event(dev, EV_SYN, SYN_REPORT, 0); + } + litest_dispatch(li); + + /* Release immediately (still within grace period) */ + litest_button_click_debounced(dev, li, BTN_LEFT, false); + litest_dispatch(li); + + /* Lock should be engaged: drain scroll events from the hold */ + litest_assert_only_axis_events(li, LIBINPUT_EVENT_POINTER_SCROLL_CONTINUOUS); + + /* Motion while locked should produce scroll events */ + for (int i = 0; i < 10; i++) { + litest_event(dev, EV_REL, REL_X, 1); + litest_event(dev, EV_REL, REL_Y, 6); + litest_event(dev, EV_SYN, SYN_REPORT, 0); + } + litest_dispatch(li); + litest_assert_only_axis_events(li, LIBINPUT_EVENT_POINTER_SCROLL_CONTINUOUS); + + /* Click again to unlock */ + litest_button_click_debounced(dev, li, BTN_LEFT, true); + litest_button_click_debounced(dev, li, BTN_LEFT, false); + litest_dispatch(li); + + litest_assert_scroll(li, LIBINPUT_EVENT_POINTER_SCROLL_CONTINUOUS, 0, 0); + + /* Back to normal motion */ + for (int i = 0; i < 10; i++) { + litest_event(dev, EV_REL, REL_X, 1); + litest_event(dev, EV_REL, REL_Y, 6); + litest_event(dev, EV_SYN, SYN_REPORT, 0); + } + litest_assert_only_typed_events(li, LIBINPUT_EVENT_POINTER_MOTION); +} +END_TEST + +START_TEST(pointer_scroll_button_lock_hold_no_move_still_locks) +{ + struct litest_device *dev = litest_current_device(); + struct libinput *li = dev->libinput; + + litest_enable_scroll_button_lock(dev, BTN_LEFT); + litest_disable_middleemu(dev); + litest_drain_events(li); + + /* Press, wait past 500ms, release without moving. + button_scroll_state == READY (not SCROLLING), so lock + engages as normal click. */ + litest_button_click_debounced(dev, li, BTN_LEFT, true); + litest_timeout_buttonscroll(li); + litest_timeout_scroll_button_lock_grace(li); + litest_dispatch(li); + + litest_button_click_debounced(dev, li, BTN_LEFT, false); + litest_dispatch(li); + + /* Lock should be engaged (release was filtered). + The scroll SM was in READY state, so it emits button press/release + (since no scrolling occurred), but the lock SM filtered the release. + Subsequent motion should produce scroll events. */ + litest_assert_empty_queue(li); + + for (int i = 0; i < 10; i++) { + litest_event(dev, EV_REL, REL_X, 1); + litest_event(dev, EV_REL, REL_Y, 6); + litest_event(dev, EV_SYN, SYN_REPORT, 0); + } + litest_dispatch(li); + litest_assert_only_axis_events(li, LIBINPUT_EVENT_POINTER_SCROLL_CONTINUOUS); + + /* Click to unlock */ + litest_button_click_debounced(dev, li, BTN_LEFT, true); + litest_button_click_debounced(dev, li, BTN_LEFT, false); + litest_dispatch(li); + + litest_assert_scroll(li, LIBINPUT_EVENT_POINTER_SCROLL_CONTINUOUS, 0, 0); +} +END_TEST + START_TEST(pointer_scroll_nowheel_defaults) { struct litest_device *dev = litest_current_device(); @@ -3816,6 +3956,10 @@ TEST_COLLECTION(pointer) } litest_add(pointer_scroll_button_lock_doubleclick_nomove, LITEST_RELATIVE|LITEST_BUTTON, LITEST_ANY); + litest_add(pointer_scroll_button_lock_scroll_releases, LITEST_RELATIVE|LITEST_BUTTON, LITEST_ANY); + litest_add(pointer_scroll_button_lock_scroll_within_grace, LITEST_RELATIVE|LITEST_BUTTON, LITEST_ANY); + litest_add(pointer_scroll_button_lock_hold_no_move_still_locks, LITEST_RELATIVE|LITEST_BUTTON, LITEST_ANY); + litest_add(pointer_scroll_nowheel_defaults, LITEST_RELATIVE|LITEST_BUTTON, LITEST_WHEEL); litest_add_for_device(pointer_scroll_defaults_logitech_marble , LITEST_LOGITECH_TRACKBALL); litest_add(pointer_scroll_natural_defaults, LITEST_WHEEL, LITEST_TABLET);