Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 56 additions & 26 deletions src/platform/linux/uhid_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::uint16_t> vertical_high_resolution_scroll_code = REL_WHEEL_HI_RES;
#else
constexpr std::optional<std::uint16_t> vertical_high_resolution_scroll_code = std::nullopt;
#endif
#if defined(REL_HWHEEL_HI_RES)
constexpr std::optional<std::uint16_t> horizontal_high_resolution_scroll_code = REL_HWHEEL_HI_RES;
#else
constexpr std::optional<std::uint16_t> 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;
Expand Down Expand Up @@ -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<std::int64_t>(remainder) + distance;
return {
.detents = static_cast<std::int32_t>(total / mouse_scroll_units_per_detent),
.remainder = static_cast<std::int32_t>(total % mouse_scroll_units_per_detent),
};
}

/**
* @brief Shared Linux uinput device wrapper.
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<std::uint16_t> 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();
}
};

Expand Down
8 changes: 8 additions & 0 deletions tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<MouseEvent> &events);

/**
* @brief Place and release a contact through a pipe-backed uinput touchscreen.
*
Expand Down
12 changes: 11 additions & 1 deletion tests/fixtures/linux_backend_test_hooks.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<MouseEvent> {event});
}

LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe_sequence(const std::vector<MouseEvent> &events) {
std::array<int, 2> 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<void>(mouse.close());
auto records = read_input_events_until_eof(descriptors[0]);
static_cast<void>(::close(descriptors[0]));
Expand Down
74 changes: 64 additions & 10 deletions tests/unit/test_linux_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<lvh::MouseEvent> 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<std::int32_t> 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<std::int32_t> {1}));
EXPECT_EQ(event_values(REL_HWHEEL), (std::vector<std::int32_t> {-1}));
#if defined(REL_WHEEL_HI_RES)
EXPECT_EQ(event_values(REL_WHEEL_HI_RES), (std::vector<std::int32_t> {60, 60}));
#endif
#if defined(REL_HWHEEL_HI_RES)
EXPECT_EQ(event_values(REL_HWHEEL_HI_RES), (std::vector<std::int32_t> {-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) {
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 8 additions & 6 deletions tests/unit/test_linux_consumers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand All @@ -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);
}

Expand Down
Loading