diff --git a/include/mocktail/platform/platform_runtime.h b/include/mocktail/platform/platform_runtime.h index 33c9d87..48724bf 100644 --- a/include/mocktail/platform/platform_runtime.h +++ b/include/mocktail/platform/platform_runtime.h @@ -152,6 +152,7 @@ struct MouseMotionEvent { float delta_x = 0.0f; float delta_y = 0.0f; std::uint32_t buttons = 0; + bool relative_mode = false; }; struct MouseButtonEvent { @@ -160,6 +161,7 @@ struct MouseButtonEvent { std::uint8_t clicks = 0; float x = 0.0f; float y = 0.0f; + bool relative_mode = false; }; struct MouseWheelEvent { @@ -167,6 +169,7 @@ struct MouseWheelEvent { float delta_y = 0.0f; float mouse_x = 0.0f; float mouse_y = 0.0f; + bool relative_mode = false; }; struct TouchEvent { diff --git a/include/mocktail/platform/sdl_event_converter.h b/include/mocktail/platform/sdl_event_converter.h index 01b1e53..8497a13 100644 --- a/include/mocktail/platform/sdl_event_converter.h +++ b/include/mocktail/platform/sdl_event_converter.h @@ -14,7 +14,8 @@ namespace platform { // Returns false for invalid arguments and SDL events outside the platform // contract. bool ConvertSdlEvent(SDL_Window* window, const SDL_Event& source, - PlatformEvent* destination); + PlatformEvent* destination, + bool relative_mouse_mode = false); } // namespace platform } // namespace mocktail diff --git a/src/platform/sdl_event_converter.cc b/src/platform/sdl_event_converter.cc index a29b7ea..26cd1b0 100644 --- a/src/platform/sdl_event_converter.cc +++ b/src/platform/sdl_event_converter.cc @@ -6,7 +6,7 @@ namespace mocktail { namespace platform { bool ConvertSdlEvent(SDL_Window* window, const SDL_Event& source, - PlatformEvent* destination) { + PlatformEvent* destination, bool relative_mouse_mode) { if (window == nullptr || destination == nullptr) { return false; } @@ -60,20 +60,21 @@ bool ConvertSdlEvent(SDL_Window* window, const SDL_Event& source, case SDL_EVENT_MOUSE_MOTION: destination->payload = MouseMotionEvent{ source.motion.x, source.motion.y, source.motion.xrel, - source.motion.yrel, static_cast(source.motion.state)}; + source.motion.yrel, static_cast(source.motion.state), + relative_mouse_mode}; return true; case SDL_EVENT_MOUSE_BUTTON_DOWN: case SDL_EVENT_MOUSE_BUTTON_UP: destination->payload = MouseButtonEvent{ source.button.down, source.button.button, source.button.clicks, - source.button.x, source.button.y}; + source.button.x, source.button.y, relative_mouse_mode}; return true; case SDL_EVENT_MOUSE_WHEEL: { // SDL already applies the host's natural-scrolling preference to x/y. // Undoing SDL_MOUSEWHEEL_FLIPPED here would ignore the OS setting. destination->payload = MouseWheelEvent{ source.wheel.x, source.wheel.y, source.wheel.mouse_x, - source.wheel.mouse_y}; + source.wheel.mouse_y, relative_mouse_mode}; return true; } case SDL_EVENT_FINGER_DOWN: diff --git a/src/platform/sdl_platform_runtime.cc b/src/platform/sdl_platform_runtime.cc index 3ba2242..7149b4d 100644 --- a/src/platform/sdl_platform_runtime.cc +++ b/src/platform/sdl_platform_runtime.cc @@ -119,7 +119,8 @@ class SdlPlatformRuntime final : public PlatformRuntime { *has_event = false; SDL_Event sdl_event; while (SDL_PollEvent(&sdl_event)) { - if (ConvertSdlEvent(window_, sdl_event, event)) { + if (ConvertSdlEvent(window_, sdl_event, event, + SDL_GetWindowRelativeMouseMode(window_))) { *has_event = true; return Status::Ok(); } diff --git a/src/runtime/roblox_input_router.cc b/src/runtime/roblox_input_router.cc index 763e7c1..1b0eb8f 100644 --- a/src/runtime/roblox_input_router.cc +++ b/src/runtime/roblox_input_router.cc @@ -545,8 +545,8 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked( const float delta_x = transform.HostLogicalToGuestX(event.delta_x); const float delta_y = transform.HostLogicalToGuestY(event.delta_y); - if (event.x == 0.0f && event.y == 0.0f && - (delta_x != 0.0f || delta_y != 0.0f)) { + if (event.relative_mode) { + // Android's captured-pointer path maintains an unclipped virtual position. mouse_x_ += delta_x; mouse_y_ += delta_y; } else { @@ -559,7 +559,10 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked( const float clamped_y = std::clamp(mouse_y_, 0.0f, max_y); return NativeResultLocked( - sink_.mouse_move(sink_.context, clamped_x, clamped_y, delta_x, delta_y), + sink_.mouse_move(sink_.context, + event.relative_mode ? mouse_x_ : clamped_x, + event.relative_mode ? mouse_y_ : clamped_y, + delta_x, delta_y), RobloxInputEventKind::kMouseMotion); } @@ -576,15 +579,12 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked( RobloxInputEventKind::kMouseButton, Unsupported("SDL mouse button has no Roblox mapping")); } - if (event.x > 0.0f || event.y > 0.0f || - (mouse_x_ == 0.0f && mouse_y_ == 0.0f)) { - const platform::SurfaceCoordinateTransform transform = - CoordinateTransform(snapshot_.viewport); + const platform::SurfaceCoordinateTransform transform = + CoordinateTransform(snapshot_.viewport); + if (!event.relative_mode) { mouse_x_ = transform.HostLogicalToGuestX(event.x); mouse_y_ = transform.HostLogicalToGuestY(event.y); } - const platform::SurfaceCoordinateTransform transform = - CoordinateTransform(snapshot_.viewport); const float max_x = std::max(0.0F, transform.guest_width() - 1.0F); const float max_y = std::max(0.0F, transform.guest_height() - 1.0F); const float clamped_x = std::clamp(mouse_x_, 0.0f, max_x); @@ -615,10 +615,13 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseWheelLocked( } const platform::SurfaceCoordinateTransform transform = CoordinateTransform(snapshot_.viewport); - mouse_x_ = transform.HostLogicalToGuestX(std::max(0.0f, event.mouse_x)); - mouse_y_ = transform.HostLogicalToGuestY(std::max(0.0f, event.mouse_y)); + if (!event.relative_mode) { + mouse_x_ = transform.HostLogicalToGuestX(std::max(0.0f, event.mouse_x)); + mouse_y_ = transform.HostLogicalToGuestY(std::max(0.0f, event.mouse_y)); + } return NativeResultLocked( - sink_.mouse_wheel(sink_.context, mouse_x_, mouse_y_, event.delta_y), + sink_.mouse_wheel(sink_.context, std::max(0.0f, mouse_x_), + std::max(0.0f, mouse_y_), event.delta_y), RobloxInputEventKind::kMouseWheel); } diff --git a/src/window/window.cc b/src/window/window.cc index 9eea7af..5d4a543 100644 --- a/src/window/window.cc +++ b/src/window/window.cc @@ -2172,7 +2172,9 @@ bool PumpEvents() { const bool fullscreen_shortcut = HandleFullscreenShortcut(event); platform::PlatformEvent platform_event; const bool converted = - platform::ConvertSdlEvent(g_state.sdl_window, event, &platform_event); + platform::ConvertSdlEvent( + g_state.sdl_window, event, &platform_event, + SDL_GetWindowRelativeMouseMode(g_state.sdl_window)); if (is_window_event && g_window_surface_lifecycle.active()) { int pixel_width = 0; int pixel_height = 0; @@ -2238,7 +2240,8 @@ bool PumpEvents() { std::get_if(&pending_motion.payload); const auto* nxt = std::get_if(&platform_event.payload); - if (cur != nullptr && nxt != nullptr) { + if (cur != nullptr && nxt != nullptr && + cur->relative_mode == nxt->relative_mode) { cur->delta_x += nxt->delta_x; cur->delta_y += nxt->delta_y; cur->x = nxt->x; diff --git a/tests/platform_graphics_foundation_test.cc b/tests/platform_graphics_foundation_test.cc index a5e57f2..fe497eb 100644 --- a/tests/platform_graphics_foundation_test.cc +++ b/tests/platform_graphics_foundation_test.cc @@ -167,6 +167,26 @@ TEST(SdlEventConverterTest, PreservesHostWheelDirectionAndPrecision) { } } +TEST(SdlEventConverterTest, PreservesRelativeModeForAllMouseEvents) { + auto* window = reinterpret_cast(0x1); + platform::PlatformEvent event; + for (const bool relative_mode : {false, true}) { + SDL_Event source{}; + source.type = SDL_EVENT_MOUSE_MOTION; + ASSERT_TRUE(platform::ConvertSdlEvent(window, source, &event, relative_mode)); + EXPECT_EQ(std::get(event.payload).relative_mode, + relative_mode); + source.type = SDL_EVENT_MOUSE_BUTTON_DOWN; + ASSERT_TRUE(platform::ConvertSdlEvent(window, source, &event, relative_mode)); + EXPECT_EQ(std::get(event.payload).relative_mode, + relative_mode); + source.type = SDL_EVENT_MOUSE_WHEEL; + ASSERT_TRUE(platform::ConvertSdlEvent(window, source, &event, relative_mode)); + EXPECT_EQ(std::get(event.payload).relative_mode, + relative_mode); + } +} + TEST(SdlEventConverterTest, RejectsInvalidArgumentsAndUnsupportedEvents) { SDL_Event source{}; source.type = SDL_EVENT_USER; diff --git a/tests/roblox_input_router_test.cc b/tests/roblox_input_router_test.cc index 94fb7a5..8e33684 100644 --- a/tests/roblox_input_router_test.cc +++ b/tests/roblox_input_router_test.cc @@ -257,6 +257,85 @@ TEST_F(RobloxInputRouterTest, MiddleClickUsesMouseButton3OnPressAndRelease) { EXPECT_EQ(router_.Snapshot().active_mouse_buttons, 0U); } +TEST_F(RobloxInputRouterTest, MouseOriginIsAnAbsolutePosition) { + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + 100.0F, 80.0F, 0.0F, 0.0F, 0})).dispatched()); + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + 0.0F, 0.0F, -4.0F, -7.0F, 0})).dispatched()); + + const auto& move = probe_.mouse_moves.back(); + EXPECT_FLOAT_EQ(move.x, 0.0F); + EXPECT_FLOAT_EQ(move.y, 0.0F); + EXPECT_FLOAT_EQ(move.delta_x, -4.0F); + EXPECT_FLOAT_EQ(move.delta_y, -7.0F); +} + +TEST_F(RobloxInputRouterTest, ClickAtOriginDoesNotReusePreviousPosition) { + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + 100.0F, 80.0F, 0.0F, 0.0F, 0})).dispatched()); + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseButtonEvent{ + true, SDL_BUTTON_RIGHT, 1, 0.0F, 0.0F})).dispatched()); + + const auto& button = probe_.mouse_buttons.back(); + EXPECT_FLOAT_EQ(button.x, 0.0F); + EXPECT_FLOAT_EQ(button.y, 0.0F); +} + +TEST_F(RobloxInputRouterTest, CapturedPointerKeepsMovingBeyondWindowEdges) { + float x = 640.0F; + float y = 360.0F; + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + x, y, 0.0F, 0.0F, 0})).dispatched()); + const std::pair deltas[] = { + {4000.0F, 0.0F}, {-8000.0F, 0.0F}, {4000.0F, 0.0F}, + {0.0F, 4000.0F}, {0.0F, -8000.0F}, {0.0F, 4000.0F}}; + for (const auto& delta : deltas) { + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + 640.0F, 360.0F, delta.first, delta.second, SDL_BUTTON_RMASK, true})) + .dispatched()); + x += delta.first; + y += delta.second; + const auto& move = probe_.mouse_moves.back(); + EXPECT_FLOAT_EQ(move.x, x); + EXPECT_FLOAT_EQ(move.y, y); + EXPECT_FLOAT_EQ(move.delta_x, delta.first); + EXPECT_FLOAT_EQ(move.delta_y, delta.second); + } +} + +TEST_F(RobloxInputRouterTest, CapturedButtonsAndWheelPreserveVirtualPosition) { + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + 100.0F, 80.0F, 0.0F, 0.0F, 0})).dispatched()); + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + 1279.0F, 0.0F, 1500.0F, -200.0F, 0, true})).dispatched()); + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseButtonEvent{ + true, SDL_BUTTON_LEFT, 1, 640.0F, 360.0F, true})).dispatched()); + EXPECT_FLOAT_EQ(probe_.mouse_buttons.back().x, 1279.0F); + EXPECT_FLOAT_EQ(probe_.mouse_buttons.back().y, 0.0F); + + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseWheelEvent{ + 0.0F, -1.0F, 640.0F, 360.0F, true})).dispatched()); + EXPECT_FLOAT_EQ(probe_.mouse_wheels.back().x, 1600.0F); + EXPECT_FLOAT_EQ(probe_.mouse_wheels.back().y, 0.0F); + EXPECT_FLOAT_EQ(probe_.mouse_wheels.back().delta_y, -1.0F); + + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + 1279.0F, 0.0F, 1.0F, 2.0F, 0, true})).dispatched()); + EXPECT_FLOAT_EQ(probe_.mouse_moves.back().x, 1601.0F); + EXPECT_FLOAT_EQ(probe_.mouse_moves.back().y, -118.0F); +} + +TEST_F(RobloxInputRouterTest, AbsoluteMotionResetsPositionAfterCapture) { + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + 640.0F, 360.0F, 4000.0F, -4000.0F, 0, true})).dispatched()); + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + 20.0F, 30.0F, 0.0F, 0.0F, 0})).dispatched()); + ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseMotionEvent{ + 640.0F, 360.0F, 1.0F, 2.0F, 0, true})).dispatched()); + EXPECT_FLOAT_EQ(probe_.mouse_moves.back().x, 21.0F); + EXPECT_FLOAT_EQ(probe_.mouse_moves.back().y, 32.0F); +} + TEST_F(RobloxInputRouterTest, ScalesMouseCoordinatesAfterDisplayScaleChange) { const RobloxInputDispatchResult resize = router_.HandleEvent(