Merge branch 'scroll-button-lock-hold' into 'main'

Fold hold-to-scroll into existing scroll button lock mode

Closes #1259

See merge request libinput/libinput!1435
This commit is contained in:
Ilya Kamenko 2026-03-10 23:57:41 +00:00
commit 0136c6bb94
5 changed files with 164 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

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