diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.cpp index 8b257a40053..5516a1f8cf2 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.cpp @@ -7,28 +7,153 @@ #include // standard includes +#include +#include #include #include +#include #include #include // local includes #include "src/config.h" +#include "src/logging.h" +#include "src/platform/macos/mouse_utils.h" #include "src/platform/virtualhid_input.h" namespace platf { + namespace { + constexpr uint32_t max_displays = 16; + + /** + * @brief Read the pointer position in global CoreGraphics point space. + * @return The current pointer location, or an empty optional when events are unavailable. + */ + std::optional cursor_point() { + const auto event = CGEventCreate(nullptr); + if (!event) { + return std::nullopt; + } + + const auto point = CGEventGetLocation(event); + CFRelease(event); + return point; + } + + /** + * @brief Collect the bounds of every active display. + * @return The union of the display layout, or an empty optional if it is unavailable. + */ + std::optional active_layout_bounds() { + uint32_t count = 0; + if (CGGetActiveDisplayList(0, nullptr, &count) != kCGErrorSuccess || count == 0) { + return std::nullopt; + } + + CGDirectDisplayID displays[max_displays]; + count = std::min(count, max_displays); + if (CGGetActiveDisplayList(count, displays, &count) != kCGErrorSuccess) { + return std::nullopt; + } + + std::vector rects; + rects.reserve(count); + for (uint32_t i = 0; i < count; ++i) { + const auto bounds = CGDisplayBounds(displays[i]); + rects.push_back({bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height}); + } + + return macos::mouse::layout_bounds(rects); + } + } // namespace std::optional get_mouse_loc(input_t & /*input*/) { - const auto event = CGEventCreate(nullptr); - if (!event) { + const auto current = cursor_point(); + if (!current) { return std::nullopt; } - const auto current = CGEventGetLocation(event); - CFRelease(event); - return util::point_t {current.x, current.y}; + return util::point_t {current->x, current->y}; } + namespace macos { + bool move_mouse_relative(int delta_x, int delta_y) { + // CoreGraphics discards posted events without Accessibility. Report that so the caller + // can use the virtual HID path instead of silently dropping the movement. + if (!AXIsProcessTrusted()) { + return false; + } + + static const bool logged = [] { + BOOST_LOG(info) << "macOS relative mouse backend selected: CoreGraphics"; + return true; + }(); + (void) logged; + + static std::optional layout; // Only reached from the input thread. + static std::optional predicted; + static std::chrono::steady_clock::time_point last_packet; + + // CGEventGetLocation() reports only what the window server has committed so far. Re-basing + // every packet on it makes a burst at 500-1000 Hz read the same stale position repeatedly, + // so each packet overwrites the previous one's movement and the pointer under-travels. + // Accumulate the chain locally instead, and trust the server again on the first packet or + // after a gap long enough for the pointer to have been moved by something else. + const auto now = std::chrono::steady_clock::now(); + auto base = predicted; + if (!base || now - last_packet > std::chrono::milliseconds {100}) { + const auto live = cursor_point(); + if (!live) { + return false; + } + + base = live; + } + + auto target = CGPoint {base->x + delta_x, base->y + delta_y}; + if (!layout || target.x < layout->min_x || target.y < layout->min_y || target.x >= layout->max_x || target.y >= layout->max_y) { + const auto refreshed = active_layout_bounds(); + if (!refreshed) { + return false; + } + + layout = refreshed; + target = mouse::clamp_to_layout(target, *layout); + } + + const auto type = mouse::move_event_type( + CGEventSourceButtonState(kCGEventSourceStateHIDSystemState, kCGMouseButtonLeft), + CGEventSourceButtonState(kCGEventSourceStateHIDSystemState, kCGMouseButtonRight), + CGEventSourceButtonState(kCGEventSourceStateHIDSystemState, kCGMouseButtonCenter) + ); + + const auto event = CGEventCreateMouseEvent(nullptr, type, target, mouse::move_event_button(type)); + if (!event) { + return false; + } + + // Describe the movement once, with the location and the device deltas agreeing: a fresh + // event leaves the deltas at zero, so apps reading them instead of the cursor location + // would see no movement at all. + CGEventSetDoubleValueField(event, kCGMouseEventDeltaX, target.x - base->x); + CGEventSetDoubleValueField(event, kCGMouseEventDeltaY, target.y - base->y); + + // Modifiers are reported per event, so a Shift/Option/Ctrl held during a drag has to travel + // with the motion. The button press comes from the virtual HID backend, which stamps the + // same live state onto its mouse events; without this the press carries the modifier and + // every drag after it loses it. + CGEventSetFlags(event, CGEventSourceFlagsState(kCGEventSourceStateHIDSystemState)); + + // Exactly one pointer update: no CGWarpMouseCursorPosition() on top of this event. + CGEventPost(kCGHIDEventTap, event); + CFRelease(event); + + predicted = target; + last_packet = now; + return true; + } + } // namespace macos + platform_caps::caps_t get_capabilities() { platform_caps::caps_t caps = 0; const auto runtime = virtualhid::create_runtime(); diff --git a/src/platform/macos/mouse_utils.h b/src/platform/macos/mouse_utils.h new file mode 100644 index 00000000000..43d866204a0 --- /dev/null +++ b/src/platform/macos/mouse_utils.h @@ -0,0 +1,139 @@ +/** + * @file src/platform/macos/mouse_utils.h + * @brief Declarations for macOS relative pointer math helpers. + */ +#pragma once + +// system includes +#include + +// standard includes +#include +#include +#include + +namespace platf { + namespace macos { + namespace mouse { + + /** + * @brief A display rectangle in global CoreGraphics point space. + */ + struct rect_t { + double x; + double y; + double width; + double height; + }; + + /** + * @brief The outer bounds of the entire display layout. + */ + struct bounds_t { + double min_x; + double min_y; + double max_x; + double max_y; + }; + + /** + * @brief Compute the union of every active display. + * @param rects The active display rectangles. + * @return The layout bounds, or an empty optional when there are no displays. + */ + inline std::optional + layout_bounds(const std::vector &rects) { + if (rects.empty()) { + return std::nullopt; + } + + bounds_t bounds {rects.front().x, rects.front().y, rects.front().x + rects.front().width, rects.front().y + rects.front().height}; + for (const auto &rect : rects) { + bounds.min_x = std::min(bounds.min_x, rect.x); + bounds.min_y = std::min(bounds.min_y, rect.y); + bounds.max_x = std::max(bounds.max_x, rect.x + rect.width); + bounds.max_y = std::max(bounds.max_y, rect.y + rect.height); + } + + return bounds; + } + + /** + * @brief Clamp a point into the display layout. + * + * Secondary displays commonly have negative origins, so the whole layout is clamped + * instead of the main display rectangle. + * + * @param point The point to clamp. + * @param bounds The bounds to clamp to. + * @return The clamped point. + */ + inline CGPoint + clamp_to_layout(CGPoint point, const bounds_t &bounds) { + // Keep one point inside the layout so a drag near the edge still has a hit target. + const double max_x = bounds.max_x - 1.0; + const double max_y = bounds.max_y - 1.0; + + return { + std::min(std::max(point.x, bounds.min_x), max_x), + std::min(std::max(point.y, bounds.min_y), max_y) + }; + } + + /** + * @brief Resolve the move event type for the currently held mouse buttons. + * @param left True when the left button is held. + * @param right True when the right button is held. + * @param center True when the center button is held. + * @return The CoreGraphics event type describing the move. + */ + inline CGEventType + move_event_type(bool left, bool right, bool center) { + if (left) { + return kCGEventLeftMouseDragged; + } + if (right) { + return kCGEventRightMouseDragged; + } + if (center) { + return kCGEventOtherMouseDragged; + } + + return kCGEventMouseMoved; + } + + /** + * @brief Resolve the button reported alongside a move event. + * @param type The event type returned by `move_event_type()`. + * @return The mouse button for `CGEventCreateMouseEvent()`. + */ + inline CGMouseButton + move_event_button(CGEventType type) { + switch (type) { + case kCGEventRightMouseDragged: + return kCGMouseButtonRight; + case kCGEventOtherMouseDragged: + return kCGMouseButtonCenter; + default: + return kCGMouseButtonLeft; + } + } + + } // namespace mouse + } // namespace macos + + namespace macos { + /** + * @brief Inject a relative pointer movement through CoreGraphics. + * + * A single pointer update is emitted per movement so that the delta cannot be applied + * twice, or fought over by two mechanisms. + * + * @param delta_x The horizontal movement in pointer units. + * @param delta_y The vertical movement in pointer units. + * @return True when the movement was injected, false when CoreGraphics cannot be used. + */ + bool move_mouse_relative(int delta_x, int delta_y); + } // namespace macos + +} // namespace platf diff --git a/src/platform/virtualhid_input.cpp b/src/platform/virtualhid_input.cpp index 362f50af5e0..070862a5749 100644 --- a/src/platform/virtualhid_input.cpp +++ b/src/platform/virtualhid_input.cpp @@ -21,6 +21,9 @@ // local includes #include "src/config.h" #include "src/logging.h" +#ifdef __APPLE__ + #include "src/platform/macos/mouse_utils.h" +#endif #include "virtualhid_input.h" using namespace std::literals; @@ -794,6 +797,23 @@ namespace platf::virtualhid { } void move_mouse(input_context_t &context, int delta_x, int delta_y) { +#if defined(__APPLE__) && !defined(SUNSHINE_TESTS) + // libvirtualhid updates the pointer twice per relative packet: it posts a kCGHIDEventTap + // event carrying an absolute location *and* kCGMouseEventDeltaX/Y, then warps the cursor to + // the clamped location. Those two updates disagree - the delta is fed through the system + // pointer-acceleration curve and is computed from the unclamped position - so the cursor is + // nudged forward and snapped back on every packet. Relative motion therefore reads as the + // pointer barely moving or jittering in place, while button and scroll input, which never + // warps, keep working. Emit a single CoreGraphics update instead, and only fall back to the + // virtual HID report when CoreGraphics cannot inject events at all. + // + // Excluded from the test build: these tests assert the submitted virtual HID report, and + // must not move the pointer of whoever is running the suite. + if (platf::macos::move_mouse_relative(delta_x, delta_y)) { + return; + } +#endif // __APPLE__ && !SUNSHINE_TESTS + if (context.mouse) { log_failure("submit libvirtualhid mouse movement"sv, context.mouse->move_relative(delta_x, delta_y)); } diff --git a/tests/unit/platform/macos/test_mouse_utils.cpp b/tests/unit/platform/macos/test_mouse_utils.cpp new file mode 100644 index 00000000000..2397a3f5117 --- /dev/null +++ b/tests/unit/platform/macos/test_mouse_utils.cpp @@ -0,0 +1,117 @@ +/** + * @file tests/unit/platform/macos/test_mouse_utils.cpp + * @brief Tests for macOS relative pointer math helpers. + */ + +#ifdef __APPLE__ + + // system includes + #include + + // standard includes + #include + #include + #include + + // local includes + #include "src/platform/macos/mouse_utils.h" + +using platf::macos::mouse::bounds_t; +using platf::macos::mouse::clamp_to_layout; +using platf::macos::mouse::layout_bounds; +using platf::macos::mouse::move_event_button; +using platf::macos::mouse::move_event_type; +using platf::macos::mouse::rect_t; + +namespace { + std::optional bounds_of(std::vector rects) { + return layout_bounds(rects); + } +} // namespace + +TEST(MacosMouseUtils, LayoutBoundsSpansTheWholeLayout) { + const auto bounds = bounds_of({{0, 0, 1920, 1080}, {-2560, 40, 2560, 1440}}); + ASSERT_TRUE(bounds.has_value()); + EXPECT_DOUBLE_EQ(-2560.0, bounds->min_x); + EXPECT_DOUBLE_EQ(0.0, bounds->min_y); + EXPECT_DOUBLE_EQ(1920.0, bounds->max_x); + EXPECT_DOUBLE_EQ(1480.0, bounds->max_y); +} + +TEST(MacosMouseUtils, LayoutBoundsIsEmptyWithoutDisplays) { + EXPECT_FALSE(bounds_of({}).has_value()); +} + +TEST(MacosMouseUtils, ClampKeepsSecondaryDisplaysReachable) { + // A secondary display to the left has a negative origin: clamping to the main display only + // would make it unreachable. + const bounds_t bounds {-2560, 0, 1920, 1480}; + + const auto left = clamp_to_layout({-9000, 500}, bounds); + EXPECT_DOUBLE_EQ(-2560.0, left.x); + EXPECT_DOUBLE_EQ(500.0, left.y); + + const auto above = clamp_to_layout({100, -700}, bounds); + EXPECT_DOUBLE_EQ(100.0, above.x); + EXPECT_DOUBLE_EQ(0.0, above.y); +} + +TEST(MacosMouseUtils, ClampStopsJustInsideTheOuterEdge) { + const bounds_t bounds {0, 0, 1920, 1080}; + + const auto right = clamp_to_layout({5000, 500}, bounds); + EXPECT_DOUBLE_EQ(1919.0, right.x); + + const auto below = clamp_to_layout({500, 5000}, bounds); + EXPECT_DOUBLE_EQ(1079.0, below.y); +} + +TEST(MacosMouseUtils, ClampPreservesFractionalPoints) { + const bounds_t bounds {0, 0, 1920, 1080}; + + const auto point = clamp_to_layout({640.5, 360.25}, bounds); + EXPECT_DOUBLE_EQ(640.5, point.x); + EXPECT_DOUBLE_EQ(360.25, point.y); +} + +TEST(MacosMouseUtils, RepeatedDeltaAccumulatesExactlyOnce) { + const bounds_t bounds {0, 0, 1920, 1080}; + CGPoint point {100, 100}; + for (int i = 0; i < 100; ++i) { + point = clamp_to_layout({point.x + 3, point.y + 2}, bounds); + } + + EXPECT_DOUBLE_EQ(400.0, point.x); + EXPECT_DOUBLE_EQ(300.0, point.y); +} + +TEST(MacosMouseUtils, AlternatingDeltaNetsToZero) { + const bounds_t bounds {0, 0, 1920, 1080}; + CGPoint point {900, 500}; + for (int i = 0; i < 200; ++i) { + const double delta = (i % 2 == 0) ? 7 : -7; + point = clamp_to_layout({point.x + delta, point.y}, bounds); + } + + EXPECT_DOUBLE_EQ(900.0, point.x); + EXPECT_DOUBLE_EQ(500.0, point.y); +} + +TEST(MacosMouseUtils, MoveTypeFollowsTheHeldButton) { + EXPECT_EQ(kCGEventMouseMoved, move_event_type(false, false, false)); + EXPECT_EQ(kCGEventLeftMouseDragged, move_event_type(true, false, false)); + EXPECT_EQ(kCGEventRightMouseDragged, move_event_type(false, true, false)); + EXPECT_EQ(kCGEventOtherMouseDragged, move_event_type(false, false, true)); + + // Only one drag type can describe a move, and macOS reports the left button first. + EXPECT_EQ(kCGEventLeftMouseDragged, move_event_type(true, true, true)); +} + +TEST(MacosMouseUtils, MoveButtonMatchesTheMoveType) { + EXPECT_EQ(kCGMouseButtonLeft, move_event_button(kCGEventMouseMoved)); + EXPECT_EQ(kCGMouseButtonLeft, move_event_button(kCGEventLeftMouseDragged)); + EXPECT_EQ(kCGMouseButtonRight, move_event_button(kCGEventRightMouseDragged)); + EXPECT_EQ(kCGMouseButtonCenter, move_event_button(kCGEventOtherMouseDragged)); +} + +#endif // __APPLE__