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);