From a9189e01ea8aa1a6a07f9f920a0a66e5ce93ce1d Mon Sep 17 00:00:00 2001 From: Gabriel Duarte Date: Thu, 15 Oct 2026 23:12:07 +0200 Subject: [PATCH 1/5] test(linux): cover scrolling after absolute mouse motion Scroll should come out of the uinput device that moved last, the same way buttons already do. These tests fail until that's fixed, since scroll always goes to the relative device and the absolute device has no wheel axes. --- tests/unit/test_linux_backend.cpp | 63 ++++++++++++++++++++++------- tests/unit/test_linux_consumers.cpp | 7 ++++ 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index e442006..113b9e7 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -46,6 +46,19 @@ namespace { } return pressed_codes; } + + std::vector relative_axis_values( + const std::vector &events, + std::uint16_t code + ) { + std::vector values; + for (const auto &event : events) { + if (event.type == EV_REL && event.code == code) { + values.push_back(event.value); + } + } + return values; + } } // namespace TEST_F(LinuxBackendTest, TranslatesKeyboardKeys) { @@ -748,6 +761,26 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseRoutesMotionAndButtonsAcrossSplitD })); } +TEST_F(LinuxBackendTest, PipeBackedUinputMouseScrollsOnLastMotionDevice) { + const std::vector events { + {.kind = lvh::MouseEventKind::absolute_motion, .x = 50, .y = 25, .width = 100, .height = 100}, + {.kind = lvh::MouseEventKind::vertical_scroll, .high_resolution_scroll = 120}, + {.kind = lvh::MouseEventKind::horizontal_scroll, .high_resolution_scroll = -120}, + {.kind = lvh::MouseEventKind::relative_motion, .x = 5, .y = -2}, + {.kind = lvh::MouseEventKind::vertical_scroll, .high_resolution_scroll = -120}, + }; + const auto result = lvh::detail::test::linux_uinput_mouse_submit_split_pipe_sequence(events); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + + EXPECT_EQ(relative_axis_values(result.absolute_events, REL_WHEEL), (std::vector {1})); + EXPECT_EQ(relative_axis_values(result.absolute_events, REL_HWHEEL), (std::vector {-1})); + EXPECT_EQ(relative_axis_values(result.relative_events, REL_WHEEL), (std::vector {-1})); +#if defined(REL_WHEEL_HI_RES) + EXPECT_EQ(relative_axis_values(result.absolute_events, REL_WHEEL_HI_RES), (std::vector {120})); + EXPECT_EQ(relative_axis_values(result.relative_events, REL_WHEEL_HI_RES), (std::vector {-120})); +#endif +} + TEST_F(LinuxBackendTest, PipeBackedUinputMouseAccumulatesLegacyScrollDetentsPerAxis) { const std::vector events { {.kind = lvh::MouseEventKind::vertical_scroll, .high_resolution_scroll = 60}, @@ -758,23 +791,13 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseAccumulatesLegacyScrollDetentsPerA const auto result = lvh::detail::test::linux_uinput_mouse_submit_pipe_sequence(events); ASSERT_TRUE(result.status.ok()) << result.status.message(); - const auto event_values = [&result](std::uint16_t code) { - std::vector values; - for (const auto &event : result.events) { - if (event.type == EV_REL && event.code == code) { - values.push_back(event.value); - } - } - return values; - }; - - EXPECT_EQ(event_values(REL_WHEEL), (std::vector {1})); - EXPECT_EQ(event_values(REL_HWHEEL), (std::vector {-1})); + EXPECT_EQ(relative_axis_values(result.events, REL_WHEEL), (std::vector {1})); + EXPECT_EQ(relative_axis_values(result.events, REL_HWHEEL), (std::vector {-1})); #if defined(REL_WHEEL_HI_RES) - EXPECT_EQ(event_values(REL_WHEEL_HI_RES), (std::vector {60, 60})); + EXPECT_EQ(relative_axis_values(result.events, REL_WHEEL_HI_RES), (std::vector {60, 60})); #endif #if defined(REL_HWHEEL_HI_RES) - EXPECT_EQ(event_values(REL_HWHEEL_HI_RES), (std::vector {-60, -60})); + EXPECT_EQ(relative_axis_values(result.events, REL_HWHEEL_HI_RES), (std::vector {-60, -60})); #endif EXPECT_EQ( std::ranges::count_if(result.events, [](const auto &event) { @@ -1352,10 +1375,20 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc const auto absolute_mouse = lvh::detail::test::linux_uinput_create_fake_absolute_mouse_device(); ASSERT_TRUE(absolute_mouse.status.ok()) << absolute_mouse.status.message(); EXPECT_TRUE(has_type(absolute_mouse, EV_KEY)); - EXPECT_FALSE(has_type(absolute_mouse, EV_REL)); + EXPECT_TRUE(has_type(absolute_mouse, EV_REL)); EXPECT_TRUE(has_type(absolute_mouse, EV_ABS)); EXPECT_TRUE(has_property(absolute_mouse, INPUT_PROP_DIRECT)); EXPECT_NE(find_code(absolute_mouse, EV_KEY, BTN_LEFT), nullptr); + EXPECT_EQ(find_code(absolute_mouse, EV_REL, REL_X), nullptr); + EXPECT_EQ(find_code(absolute_mouse, EV_REL, REL_Y), nullptr); + EXPECT_NE(find_code(absolute_mouse, EV_REL, REL_WHEEL), nullptr); + EXPECT_NE(find_code(absolute_mouse, EV_REL, REL_HWHEEL), nullptr); +#if defined(REL_WHEEL_HI_RES) + EXPECT_NE(find_code(absolute_mouse, EV_REL, REL_WHEEL_HI_RES), nullptr); +#endif +#if defined(REL_HWHEEL_HI_RES) + EXPECT_NE(find_code(absolute_mouse, EV_REL, REL_HWHEEL_HI_RES), nullptr); +#endif const auto *mouse_x = find_code(absolute_mouse, EV_ABS, ABS_X); ASSERT_NE(mouse_x, nullptr); EXPECT_TRUE(mouse_x->has_absinfo); diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index e4083a7..7d70836 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -1341,6 +1341,13 @@ TEST_F(LinuxConsumerTest, LibinputSeesSplitUinputMouseMotionAndButtons) { EXPECT_NEAR(libinput_event_pointer_get_absolute_x_transformed(pointer_event, 100), 75.0, 0.1); EXPECT_NEAR(libinput_event_pointer_get_absolute_y_transformed(pointer_event, 100), 75.0, 0.1); + ASSERT_TRUE(created.mouse->vertical_scroll(120).ok()); + event = wait_for_libinput_event(absolute_context.get(), {LIBINPUT_EVENT_POINTER_SCROLL_WHEEL}); + ASSERT_NE(event.get(), nullptr); + pointer_event = libinput_event_get_pointer_event(event.get()); + ASSERT_NE(pointer_event, nullptr); + EXPECT_DOUBLE_EQ(libinput_event_pointer_get_scroll_value_v120(pointer_event, LIBINPUT_POINTER_AXIS_SCROLL_VERTICAL), -120.0); + ASSERT_TRUE(created.mouse->button(lvh::MouseButton::middle, true).ok()); event = wait_for_libinput_event(absolute_context.get(), {LIBINPUT_EVENT_POINTER_BUTTON}); ASSERT_NE(event.get(), nullptr); From 22e5a515cc2de48814e838ea87d7be6f770e7e22 Mon Sep 17 00:00:00 2001 From: Gabriel Duarte Date: Thu, 15 Oct 2026 23:12:07 +0200 Subject: [PATCH 2/5] fix(linux): send scroll to the mouse device that last moved In Moonlight's remote desktop mouse mode the pointer moves through the absolute uinput device, but scroll always went to the relative one, so scrolling didn't work while the mouse was moving. The absolute device now has the wheel axes too, still without REL_X and REL_Y so it stays an absolute pointer, and scroll goes to whichever device moved last, like buttons. inputtino fixed this the same way in games-on-whales/inputtino@65227c7. --- src/platform/linux/uhid_backend.cpp | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index adb110d..3b4d331 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -1204,6 +1204,10 @@ namespace lvh::detail { } } + if (const auto status = enable_evdev_type(device, EV_REL, "relative mouse events"); !status.ok()) { + return status; + } + if (kind == UinputMouseDeviceKind::absolute) { if (const auto status = enable_evdev_property(device, INPUT_PROP_DIRECT, "direct input"); !status.ok()) { return status; @@ -1217,17 +1221,15 @@ namespace lvh::detail { return status; } auto y = make_absinfo(0, absolute_axis_max); - return enable_evdev_code(device, EV_ABS, ABS_Y, "absolute mouse axis", &y); - } - - if (const auto status = enable_evdev_type(device, EV_REL, "relative mouse events"); !status.ok()) { - return status; - } - - for (const auto code : {REL_X, REL_Y}) { - if (const auto status = enable_evdev_code(device, EV_REL, code, "relative mouse axis"); !status.ok()) { + if (const auto status = enable_evdev_code(device, EV_ABS, ABS_Y, "absolute mouse axis", &y); !status.ok()) { return status; } + } else { + for (const auto code : {REL_X, REL_Y}) { + if (const auto status = enable_evdev_code(device, EV_REL, code, "relative mouse axis"); !status.ok()) { + return status; + } + } } if (const auto status = enable_evdev_code(device, EV_REL, REL_WHEEL, "vertical scroll"); !status.ok()) { @@ -1935,19 +1937,19 @@ namespace lvh::detail { std::uint16_t legacy_code, std::optional high_resolution_code ) { - auto &relative = device(UinputMouseDeviceKind::relative); + auto &target = device(last_motion_device_); const auto converted = accumulated_legacy_scroll(remainder, distance); if (converted.detents != 0) { - if (const auto status = relative.emit(EV_REL, legacy_code, converted.detents); !status.ok()) { + if (const auto status = target.emit(EV_REL, legacy_code, converted.detents); !status.ok()) { return status; } } if (high_resolution_code.has_value()) { - if (const auto status = relative.emit(EV_REL, *high_resolution_code, distance); !status.ok()) { + if (const auto status = target.emit(EV_REL, *high_resolution_code, distance); !status.ok()) { return status; } } - if (const auto status = relative.synchronize(); !status.ok()) { + if (const auto status = target.synchronize(); !status.ok()) { return status; } remainder = converted.remainder; From 91ba7e7be8cbe5139c32b7b78074ad3609c55add Mon Sep 17 00:00:00 2001 From: Gabriel Duarte Date: Fri, 16 Oct 2026 00:53:31 +0200 Subject: [PATCH 3/5] test(linux): wrap the libinput scroll assertion Keeps the new line under the 120-character guideline. --- tests/unit/test_linux_consumers.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 7d70836..2dd083d 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -1346,7 +1346,10 @@ TEST_F(LinuxConsumerTest, LibinputSeesSplitUinputMouseMotionAndButtons) { ASSERT_NE(event.get(), nullptr); pointer_event = libinput_event_get_pointer_event(event.get()); ASSERT_NE(pointer_event, nullptr); - EXPECT_DOUBLE_EQ(libinput_event_pointer_get_scroll_value_v120(pointer_event, LIBINPUT_POINTER_AXIS_SCROLL_VERTICAL), -120.0); + EXPECT_DOUBLE_EQ( + libinput_event_pointer_get_scroll_value_v120(pointer_event, LIBINPUT_POINTER_AXIS_SCROLL_VERTICAL), + -120.0 + ); ASSERT_TRUE(created.mouse->button(lvh::MouseButton::middle, true).ok()); event = wait_for_libinput_event(absolute_context.get(), {LIBINPUT_EVENT_POINTER_BUTTON}); From b316f9f366ffae94f1d1d87e8f2df65afb34d7f0 Mon Sep 17 00:00:00 2001 From: Gabriel Duarte Date: Fri, 16 Oct 2026 01:01:42 +0200 Subject: [PATCH 4/5] test(linux): use using enum in the scroll routing test Clears SonarCloud's cpp:S6177 finding on the new test. --- tests/unit/test_linux_backend.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 113b9e7..beac95d 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -762,12 +762,14 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseRoutesMotionAndButtonsAcrossSplitD } TEST_F(LinuxBackendTest, PipeBackedUinputMouseScrollsOnLastMotionDevice) { + using enum lvh::MouseEventKind; + const std::vector events { - {.kind = lvh::MouseEventKind::absolute_motion, .x = 50, .y = 25, .width = 100, .height = 100}, - {.kind = lvh::MouseEventKind::vertical_scroll, .high_resolution_scroll = 120}, - {.kind = lvh::MouseEventKind::horizontal_scroll, .high_resolution_scroll = -120}, - {.kind = lvh::MouseEventKind::relative_motion, .x = 5, .y = -2}, - {.kind = lvh::MouseEventKind::vertical_scroll, .high_resolution_scroll = -120}, + {.kind = absolute_motion, .x = 50, .y = 25, .width = 100, .height = 100}, + {.kind = vertical_scroll, .high_resolution_scroll = 120}, + {.kind = horizontal_scroll, .high_resolution_scroll = -120}, + {.kind = relative_motion, .x = 5, .y = -2}, + {.kind = vertical_scroll, .high_resolution_scroll = -120}, }; const auto result = lvh::detail::test::linux_uinput_mouse_submit_split_pipe_sequence(events); ASSERT_TRUE(result.status.ok()) << result.status.message(); From 621287103b29b5ef2f888f089ef3d5af818913e6 Mon Sep 17 00:00:00 2001 From: Gabriel Duarte Date: Fri, 16 Oct 2026 02:17:44 +0200 Subject: [PATCH 5/5] docs(linux): document mouse wheel routing on UinputMouse --- src/platform/linux/uhid_backend.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 3b4d331..ef20d84 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -1742,6 +1742,11 @@ namespace lvh::detail { /** * @brief Backend mouse backed by separate relative and absolute Linux uinput devices. + * + * Both devices expose legacy and available high-resolution wheel axes. + * Relative motion axes are exposed only by the relative device. + * Wheel events follow the last successful motion submission, defaulting + * to the relative device before any successful motion submission. */ class UinputMouse final: public BackendMouse { public: