diff --git a/docs/platform-support.md b/docs/platform-support.md index 0b5b42a..078bed7 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -118,6 +118,13 @@ The Linux backend uses standard user-space kernel interfaces: - X11/XTest only as a keyboard and mouse fallback when `uinput` cannot be used and an X11 session is available. +The uinput mouse advertises the legacy `REL_WHEEL` and `REL_HWHEEL` axes together +with their high-resolution counterparts when the platform provides them. It +accumulates high-resolution input independently for each axis and emits a legacy +detent for every 120 accumulated units. This follows the Linux input protocol, +lets libinput recognize the device as wheel-capable, and prevents libinput from +reserving the physical middle button for button scrolling. + Gamepad support normally prefers `uhid` because descriptors, raw HID identity, feature reports, and output reports matter for controller compatibility. Xbox One and Xbox Series use backend-only Bluetooth identities with a 283-byte BLE diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 83f39cf..1192447 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -91,6 +91,17 @@ namespace lvh::detail { constexpr auto tablet_pressure_max = 4096; constexpr auto tablet_distance_max = 1024; constexpr auto tablet_resolution = 28; + constexpr std::int32_t mouse_scroll_units_per_detent = 120; +#if defined(REL_WHEEL_HI_RES) + constexpr std::optional vertical_high_resolution_scroll_code = REL_WHEEL_HI_RES; +#else + constexpr std::optional vertical_high_resolution_scroll_code = std::nullopt; +#endif +#if defined(REL_HWHEEL_HI_RES) + constexpr std::optional horizontal_high_resolution_scroll_code = REL_HWHEEL_HI_RES; +#else + constexpr std::optional horizontal_high_resolution_scroll_code = std::nullopt; +#endif constexpr auto poll_timeout_ms = 100; constexpr auto uinput_feedback_startup_delay = std::chrono::milliseconds {100}; constexpr auto xbox_trigger_max = 255; @@ -1236,12 +1247,25 @@ namespace lvh::detail { return 0; } - if (const auto steps = distance / 120; steps != 0) { + if (const auto steps = distance / mouse_scroll_units_per_detent; steps != 0) { return steps; } return distance > 0 ? 1 : -1; } + struct LegacyScrollConversion { + std::int32_t detents = 0; + std::int32_t remainder = 0; + }; + + LegacyScrollConversion accumulated_legacy_scroll(std::int32_t remainder, std::int32_t distance) { + const auto total = static_cast(remainder) + distance; + return { + .detents = static_cast(total / mouse_scroll_units_per_detent), + .remainder = static_cast(total % mouse_scroll_units_per_detent), + }; + } + /** * @brief Shared Linux uinput device wrapper. */ @@ -1429,22 +1453,20 @@ namespace lvh::detail { } } -#if defined(REL_WHEEL_HI_RES) - if (const auto status = enable_evdev_code(device, EV_REL, REL_WHEEL_HI_RES, "high-resolution vertical scroll"); !status.ok()) { + if (const auto status = enable_evdev_code(device, EV_REL, REL_WHEEL, "vertical scroll"); !status.ok()) { return status; } -#else - if (const auto status = enable_evdev_code(device, EV_REL, REL_WHEEL, "vertical scroll"); !status.ok()) { +#if defined(REL_WHEEL_HI_RES) + if (const auto status = enable_evdev_code(device, EV_REL, REL_WHEEL_HI_RES, "high-resolution vertical scroll"); !status.ok()) { return status; } #endif -#if defined(REL_HWHEEL_HI_RES) - if (const auto status = enable_evdev_code(device, EV_REL, REL_HWHEEL_HI_RES, "high-resolution horizontal scroll"); !status.ok()) { + if (const auto status = enable_evdev_code(device, EV_REL, REL_HWHEEL, "horizontal scroll"); !status.ok()) { return status; } -#else - if (const auto status = enable_evdev_code(device, EV_REL, REL_HWHEEL, "horizontal scroll"); !status.ok()) { +#if defined(REL_HWHEEL_HI_RES) + if (const auto status = enable_evdev_code(device, EV_REL, REL_HWHEEL_HI_RES, "high-resolution horizontal scroll"); !status.ok()) { return status; } #endif @@ -1944,6 +1966,8 @@ namespace lvh::detail { private: std::string device_name_; + std::int32_t vertical_scroll_remainder_ = 0; + std::int32_t horizontal_scroll_remainder_ = 0; OperationStatus submit_relative_motion(const MouseEvent &event) { if (event.x != 0) { @@ -1977,29 +2001,35 @@ namespace lvh::detail { } OperationStatus submit_vertical_scroll(std::int32_t distance) { -#if defined(REL_WHEEL_HI_RES) - if (const auto status = emit_event(EV_REL, REL_WHEEL_HI_RES, distance); !status.ok()) { - return status; - } -#else - if (const auto status = emit_event(EV_REL, REL_WHEEL, legacy_scroll_steps(distance)); !status.ok()) { - return status; - } -#endif - return sync(); + return submit_scroll(distance, vertical_scroll_remainder_, REL_WHEEL, vertical_high_resolution_scroll_code); } OperationStatus submit_horizontal_scroll(std::int32_t distance) { -#if defined(REL_HWHEEL_HI_RES) - if (const auto status = emit_event(EV_REL, REL_HWHEEL_HI_RES, distance); !status.ok()) { - return status; + return submit_scroll(distance, horizontal_scroll_remainder_, REL_HWHEEL, horizontal_high_resolution_scroll_code); + } + + OperationStatus submit_scroll( + std::int32_t distance, + std::int32_t &remainder, + std::uint16_t legacy_code, + std::optional high_resolution_code + ) { + const auto converted = accumulated_legacy_scroll(remainder, distance); + if (converted.detents != 0) { + if (const auto status = emit_event(EV_REL, legacy_code, converted.detents); !status.ok()) { + return status; + } } -#else - if (const auto status = emit_event(EV_REL, REL_HWHEEL, legacy_scroll_steps(distance)); !status.ok()) { + if (high_resolution_code.has_value()) { + if (const auto status = emit_event(EV_REL, *high_resolution_code, distance); !status.ok()) { + return status; + } + } + if (const auto status = sync(); !status.ok()) { return status; } -#endif - return sync(); + remainder = converted.remainder; + return OperationStatus::success(); } }; diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 5249147..179fbaf 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -897,6 +897,14 @@ namespace lvh::detail::test { */ LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event); + /** + * @brief Submit mouse events to one pipe-backed uinput mouse. + * + * @param events Mouse events to submit in order. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe_sequence(const std::vector &events); + /** * @brief Place and release a contact through a pipe-backed uinput touchscreen. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index e02c767..7686553 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -1333,13 +1333,23 @@ namespace lvh::detail::test { } LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event) { + return linux_uinput_mouse_submit_pipe_sequence(std::vector {event}); + } + + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe_sequence(const std::vector &events) { std::array descriptors {-1, -1}; if (::pipe(descriptors.data()) != 0) { return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; } UinputMouse mouse {descriptors[1]}; - auto status = mouse.submit(event); + auto status = OperationStatus::success(); + for (const auto &event : events) { + status = mouse.submit(event); + if (!status.ok()) { + break; + } + } static_cast(mouse.close()); auto records = read_input_events_until_eof(descriptors[0]); static_cast(::close(descriptors[0])); diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index f5018b2..ac00bd0 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -609,32 +609,78 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { event.high_resolution_scroll = 120; result = lvh::detail::test::linux_uinput_mouse_submit_pipe(event); ASSERT_TRUE(result.status.ok()) << result.status.message(); - ASSERT_EQ(result.events.size(), 2U); - EXPECT_EQ(result.events[0].type, EV_REL); #if defined(REL_WHEEL_HI_RES) - EXPECT_EQ(result.events[0].code, REL_WHEEL_HI_RES); - EXPECT_EQ(result.events[0].value, 120); + constexpr auto expected_vertical_scroll_event_count = 3U; #else + constexpr auto expected_vertical_scroll_event_count = 2U; +#endif + ASSERT_EQ(result.events.size(), expected_vertical_scroll_event_count); + EXPECT_EQ(result.events[0].type, EV_REL); EXPECT_EQ(result.events[0].code, REL_WHEEL); EXPECT_EQ(result.events[0].value, 1); +#if defined(REL_WHEEL_HI_RES) + EXPECT_EQ(result.events[1].type, EV_REL); + EXPECT_EQ(result.events[1].code, REL_WHEEL_HI_RES); + EXPECT_EQ(result.events[1].value, 120); #endif - EXPECT_EQ(result.events[1].type, EV_SYN); + EXPECT_EQ(result.events.back().type, EV_SYN); event = {}; event.kind = lvh::MouseEventKind::horizontal_scroll; event.high_resolution_scroll = -120; result = lvh::detail::test::linux_uinput_mouse_submit_pipe(event); ASSERT_TRUE(result.status.ok()) << result.status.message(); - ASSERT_EQ(result.events.size(), 2U); - EXPECT_EQ(result.events[0].type, EV_REL); #if defined(REL_HWHEEL_HI_RES) - EXPECT_EQ(result.events[0].code, REL_HWHEEL_HI_RES); - EXPECT_EQ(result.events[0].value, -120); + constexpr auto expected_horizontal_scroll_event_count = 3U; #else + constexpr auto expected_horizontal_scroll_event_count = 2U; +#endif + ASSERT_EQ(result.events.size(), expected_horizontal_scroll_event_count); + EXPECT_EQ(result.events[0].type, EV_REL); EXPECT_EQ(result.events[0].code, REL_HWHEEL); EXPECT_EQ(result.events[0].value, -1); +#if defined(REL_HWHEEL_HI_RES) + EXPECT_EQ(result.events[1].type, EV_REL); + EXPECT_EQ(result.events[1].code, REL_HWHEEL_HI_RES); + EXPECT_EQ(result.events[1].value, -120); #endif - EXPECT_EQ(result.events[1].type, EV_SYN); + EXPECT_EQ(result.events.back().type, EV_SYN); +} + +TEST_F(LinuxBackendTest, PipeBackedUinputMouseAccumulatesLegacyScrollDetentsPerAxis) { + const std::vector events { + {.kind = lvh::MouseEventKind::vertical_scroll, .high_resolution_scroll = 60}, + {.kind = lvh::MouseEventKind::horizontal_scroll, .high_resolution_scroll = -60}, + {.kind = lvh::MouseEventKind::vertical_scroll, .high_resolution_scroll = 60}, + {.kind = lvh::MouseEventKind::horizontal_scroll, .high_resolution_scroll = -60}, + }; + 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})); +#if defined(REL_WHEEL_HI_RES) + EXPECT_EQ(event_values(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})); +#endif + EXPECT_EQ( + std::ranges::count_if(result.events, [](const auto &event) { + return event.type == EV_SYN && event.code == SYN_REPORT; + }), + 4 + ); } TEST_F(LinuxBackendTest, PipeBackedUinputTouchDevicesEmitEvents) { @@ -1129,6 +1175,14 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc EXPECT_TRUE(has_type(mouse, EV_ABS)); EXPECT_NE(find_code(mouse, EV_KEY, BTN_LEFT), nullptr); EXPECT_NE(find_code(mouse, EV_REL, REL_X), nullptr); + EXPECT_NE(find_code(mouse, EV_REL, REL_WHEEL), nullptr); + EXPECT_NE(find_code(mouse, EV_REL, REL_HWHEEL), nullptr); +#if defined(REL_WHEEL_HI_RES) + EXPECT_NE(find_code(mouse, EV_REL, REL_WHEEL_HI_RES), nullptr); +#endif +#if defined(REL_HWHEEL_HI_RES) + EXPECT_NE(find_code(mouse, EV_REL, REL_HWHEEL_HI_RES), nullptr); +#endif const auto *mouse_x = find_code(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 80c1b1b..461e518 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -1147,8 +1147,10 @@ TEST_F(LinuxConsumerTest, LibinputSeesUinputMouseMotionAndButtons) { auto event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_DEVICE_ADDED}); ASSERT_NE(event.get(), nullptr); - ASSERT_NE(libinput_event_get_device(event.get()), nullptr); - EXPECT_TRUE(libinput_device_has_capability(libinput_event_get_device(event.get()), LIBINPUT_DEVICE_CAP_POINTER)); + auto *device = libinput_event_get_device(event.get()); + ASSERT_NE(device, nullptr); + EXPECT_TRUE(libinput_device_has_capability(device, LIBINPUT_DEVICE_CAP_POINTER)); + EXPECT_EQ(libinput_device_config_scroll_get_method(device), LIBINPUT_CONFIG_SCROLL_NO_SCROLL); ASSERT_TRUE(created.mouse->move_relative(25, -10).ok()); event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_POINTER_MOTION}); @@ -1158,20 +1160,20 @@ TEST_F(LinuxConsumerTest, LibinputSeesUinputMouseMotionAndButtons) { EXPECT_DOUBLE_EQ(libinput_event_pointer_get_dx_unaccelerated(pointer_event), 25.0); EXPECT_DOUBLE_EQ(libinput_event_pointer_get_dy_unaccelerated(pointer_event), -10.0); - ASSERT_TRUE(created.mouse->button(lvh::MouseButton::left, true).ok()); + ASSERT_TRUE(created.mouse->button(lvh::MouseButton::middle, true).ok()); event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_POINTER_BUTTON}); ASSERT_NE(event.get(), nullptr); pointer_event = libinput_event_get_pointer_event(event.get()); ASSERT_NE(pointer_event, nullptr); - EXPECT_EQ(libinput_event_pointer_get_button(pointer_event), BTN_LEFT); + EXPECT_EQ(libinput_event_pointer_get_button(pointer_event), BTN_MIDDLE); EXPECT_EQ(libinput_event_pointer_get_button_state(pointer_event), LIBINPUT_BUTTON_STATE_PRESSED); - ASSERT_TRUE(created.mouse->button(lvh::MouseButton::left, false).ok()); + ASSERT_TRUE(created.mouse->button(lvh::MouseButton::middle, false).ok()); event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_POINTER_BUTTON}); ASSERT_NE(event.get(), nullptr); pointer_event = libinput_event_get_pointer_event(event.get()); ASSERT_NE(pointer_event, nullptr); - EXPECT_EQ(libinput_event_pointer_get_button(pointer_event), BTN_LEFT); + EXPECT_EQ(libinput_event_pointer_get_button(pointer_event), BTN_MIDDLE); EXPECT_EQ(libinput_event_pointer_get_button_state(pointer_event), LIBINPUT_BUTTON_STATE_RELEASED); }