diff --git a/docs/streaming-host-integration.md b/docs/streaming-host-integration.md index d7767d31..1bdd963a 100644 --- a/docs/streaming-host-integration.md +++ b/docs/streaming-host-integration.md @@ -22,3 +22,12 @@ features. `DeviceNode` paths can help diagnose host-side enumeration; client feature support still depends on the physical controller, connection, client, and game. See the [end-user compatibility matrix](end-user-gamepad-guide.md#compatibility-matrix) for observed streaming behavior. + +Route `RuntimeOptions::log_callback` into the host logger for lifecycle, +failure, and debug-level input coordinate diagnostics. When streaming one +monitor from a multi-monitor desktop, supply the full desktop bounds and the +selected viewport in `CreateMouseOptions`. See the +[mouse viewport setup](usage.md#absolute-mouse-viewports). +Use native screen units for that geometry: screen points on macOS and desktop +pixels on Windows, Linux, and FreeBSD. The captured image dimensions supplied +to `Mouse::move_absolute()` can differ from the viewport dimensions. diff --git a/docs/usage.md b/docs/usage.md index c92e3d94..7f98c859 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -64,6 +64,61 @@ The API also exposes `Keyboard`, `Mouse`, `Touchscreen`, `Trackpad`, and `PenTablet` where supported. `DeviceNode` provides paths for diagnostics or handoff to another local input consumer. +## Diagnostics + +Hosts can route libvirtualhid diagnostics into their own logging system by +installing `RuntimeOptions::log_callback` before creating the runtime: + +```cpp +lvh::RuntimeOptions runtime_options; +runtime_options.backend = lvh::BackendKind::platform_default; +runtime_options.log_callback = [](lvh::LogLevel level, const std::string &message) { + host_log(level, message); +}; +auto runtime = lvh::Runtime::create(runtime_options); +``` + +The callback receives runtime and device lifecycle messages, operation failures, +and debug-level mouse coordinate diagnostics. It runs synchronously on the +calling thread. If a consumer callback throws, libvirtualhid disables it for +subsequent messages so it cannot interrupt input delivery. + +## Absolute Mouse Viewports + +Absolute mouse coordinates can target one monitor inside a larger virtual +desktop. Supply both the desktop bounds and the selected viewport in native +screen coordinates when creating the mouse: screen points on macOS, and desktop +pixels on Windows, Linux, and FreeBSD. Offsets and dimensions must use the same +units. + +```cpp +lvh::CreateMouseOptions mouse_options; +mouse_options.profile = lvh::profiles::mouse(); +mouse_options.desktop = {.offset_x = -1920, .offset_y = 0, .width = 3840, .height = 1080}; +mouse_options.viewport = {.offset_x = 0, .offset_y = 0, .width = 1920, .height = 1080}; +auto created = runtime->create_mouse(mouse_options); +``` + +On macOS, use the global screen-point geometry reported by `CGDisplayBounds` +for the desktop and viewport. A Retina display with 3840×2160 backing pixels +and 1920×1080 screen points needs a 1920×1080 viewport. Absolute source +coordinates may still use the 3840×2160 captured image dimensions; the backend +maps that image's center to (960, 540) screen points. See Apple's +[coordinate guidance](https://developer.apple.com/library/archive/documentation/GraphicsAnimation/Conceptual/HighResolutionOSX/APIs/APIs.html). + +`Mouse::move_absolute()` coordinates are scaled from their supplied source +dimensions into the target viewport, then normalized against the virtual +desktop where the platform input API requires it. This contract covers +CoreGraphics on macOS, `SendInput` on Windows, and the XTest or `uinput` path on +Linux and FreeBSD, including virtual desktops whose origin is negative. Leave +both viewport dimensions at zero to retain the platform-default pointer area +(the main display on macOS and the virtual desktop on other current backends). +The macOS default refreshes on every mouse submission that uses a location, +including after display resolution or main-display changes. If its bounds are +temporarily unavailable, the submission fails and a later submission retries. +A configured target viewport must be fully contained by its desktop and remains +fixed for the mouse's lifetime; recreate the mouse when that geometry changes. + ## License and diagnostic tool On Windows and macOS, `get_license_status`, `activate_license`, diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 0f39df13..3bf362f3 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -5,9 +5,12 @@ // standard includes #include +#include #include +#include #include #include +#include #include // local includes @@ -19,6 +22,51 @@ namespace lvh::detail { + /** + * @brief Exception-safe dispatcher for a consumer diagnostic callback. + */ + class Logger { + public: + /** + * @brief Construct a dispatcher for an optional callback. + * + * @param callback Consumer diagnostic callback. + */ + explicit Logger(LogCallback callback): + callback_ {std::move(callback)} {} + + /** + * @brief Report whether a callback is installed. + * + * @return `true` when messages have a destination. + */ + bool enabled() const noexcept { + return static_cast(callback_) && !callback_failed_.load(std::memory_order_relaxed); + } + + /** + * @brief Emit one diagnostic message without allowing consumer exceptions to escape. + * + * @param level Message severity. + * @param message Diagnostic text. + */ + void emit(LogLevel level, const std::string &message) const noexcept { + if (!enabled()) { + return; + } + + try { + callback_(level, message); + } catch (...) { + callback_failed_.store(true, std::memory_order_relaxed); + } + } + + private: + LogCallback callback_; + mutable std::atomic_bool callback_failed_ = false; ///< Whether the consumer callback threw an exception. + }; + class SynchronizedState { public: template @@ -71,14 +119,21 @@ namespace lvh::detail { }; struct MouseDevice: SynchronizedState { - explicit MouseDevice(DeviceId device_id, CreateMouseOptions create_options, std::unique_ptr backend_mouse): + explicit MouseDevice( + DeviceId device_id, + CreateMouseOptions create_options, + std::unique_ptr backend_mouse, + std::shared_ptr device_logger + ): id {device_id}, options {std::move(create_options)}, - backend {std::move(backend_mouse)} {} + backend {std::move(backend_mouse)}, + logger {std::move(device_logger)} {} DeviceId id; CreateMouseOptions options; std::unique_ptr backend; + std::shared_ptr logger; bool open = true; MouseEvent last_event; std::size_t submitted_events = 0; @@ -141,11 +196,15 @@ namespace lvh::detail { class RuntimeState: public SynchronizedState { public: explicit RuntimeState(RuntimeOptions runtime_options): - options {runtime_options}, - backend {create_backend(runtime_options.backend)}, - caps {backend->capabilities()} {} + options {std::move(runtime_options)}, + logger {std::make_shared(options.log_callback)}, + backend {create_backend(options.backend)}, + caps {backend->capabilities()} { + logger->emit(LogLevel::info, "initialized " + caps.backend_name + " backend"); + } RuntimeOptions options; + std::shared_ptr logger; std::unique_ptr backend; BackendCapabilities caps; DeviceId next_device_id = 1; @@ -199,10 +258,91 @@ namespace lvh { if (options.profile.name.empty()) { return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); } + if (const auto valid_dimensions = [](const PointerViewport &viewport) { + return viewport.width >= 0 && viewport.height >= 0 && + ((viewport.width == 0 && viewport.height == 0) || (viewport.width > 0 && viewport.height > 0)); + }; + !valid_dimensions(options.desktop) || !valid_dimensions(options.viewport)) { + return OperationStatus::failure( + ErrorCode::invalid_argument, + "mouse viewport dimensions must both be positive or both be zero" + ); + } + const auto has_desktop = options.desktop.width > 0; + if (const auto has_viewport = options.viewport.width > 0; has_desktop != has_viewport) { + return OperationStatus::failure( + ErrorCode::invalid_argument, + "mouse desktop and target viewport must be configured together" + ); + } + if (has_desktop) { + const auto desktop_right = static_cast(options.desktop.offset_x) + options.desktop.width; + const auto desktop_bottom = static_cast(options.desktop.offset_y) + options.desktop.height; + const auto viewport_right = static_cast(options.viewport.offset_x) + options.viewport.width; + const auto viewport_bottom = static_cast(options.viewport.offset_y) + options.viewport.height; + if (options.viewport.offset_x < options.desktop.offset_x || options.viewport.offset_y < options.desktop.offset_y || viewport_right > desktop_right || viewport_bottom > desktop_bottom) { + return OperationStatus::failure( + ErrorCode::invalid_argument, + "mouse target viewport must be contained by the virtual desktop" + ); + } + } return OperationStatus::success(); } + /** + * @brief Format a mouse event for the consumer diagnostic callback. + * + * @param id Runtime mouse identifier. + * @param event Mouse event being submitted. + * @param desktop Configured virtual desktop bounds. + * @param viewport Configured target viewport. + * @return Human-readable diagnostic message. + */ + std::string mouse_event_description( + DeviceId id, + const MouseEvent &event, + const PointerViewport &desktop, + const PointerViewport &viewport + ) { + std::ostringstream message; + message << "mouse " << id << ' '; + switch (event.kind) { + using enum MouseEventKind; + + case relative_motion: + message << "relative motion x=" << event.x << " y=" << event.y; + break; + case absolute_motion: + message << "absolute motion x="; + if (event.has_fractional_absolute_coordinates) { + message << event.absolute_x << " y=" << event.absolute_y; + } else { + message << event.x << " y=" << event.y; + } + message << " source=" << event.width << 'x' << event.height; + if (viewport.width > 0 && viewport.height > 0) { + message << " viewport=" << viewport.offset_x << ',' << viewport.offset_y << ' ' + << viewport.width << 'x' << viewport.height << " desktop=" << desktop.offset_x << ',' + << desktop.offset_y << ' ' << desktop.width << 'x' << desktop.height; + } else { + message << " viewport=platform-default"; + } + break; + case button: + message << "button " << static_cast(std::to_underlying(event.button)) << (event.pressed ? " pressed" : " released"); + break; + case vertical_scroll: + message << "vertical scroll distance=" << event.high_resolution_scroll; + break; + case horizontal_scroll: + message << "horizontal scroll distance=" << event.high_resolution_scroll; + break; + } + return message.str(); + } + OperationStatus validate_touchscreen_options(const CreateTouchscreenOptions &options) { if (options.profile.device_type != DeviceType::touchscreen) { return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a touchscreen"); @@ -612,10 +752,18 @@ namespace lvh { OperationStatus Mouse::submit(const MouseEvent &event) { if (const auto validation = validate_mouse_event(event); !validation.ok()) { + device_->logger->emit(LogLevel::warning, "rejected mouse event: " + validation.message()); return validation; } - return with_device(device_, [&event](auto &device) { + if (device_->logger->enabled()) { + device_->logger->emit( + LogLevel::debug, + mouse_event_description(device_->id, event, device_->options.desktop, device_->options.viewport) + ); + } + + const auto status = with_device(device_, [&event](auto &device) { if (!device.open) { return OperationStatus::failure(ErrorCode::device_closed, "mouse is closed"); } @@ -630,6 +778,10 @@ namespace lvh { ++device.submitted_events; return OperationStatus::success(); }); + if (!status.ok()) { + device_->logger->emit(LogLevel::error, "mouse input failed: " + status.message()); + } + return status; } OperationStatus Mouse::move_relative(std::int32_t delta_x, std::int32_t delta_y) { @@ -1129,6 +1281,7 @@ namespace lvh { MouseCreationResult Runtime::create_mouse(const CreateMouseOptions &options) { if (const auto validation = validate_mouse_options(options); !validation.ok()) { + state_->logger->emit(LogLevel::warning, "rejected mouse creation: " + validation.message()); return {validation, nullptr}; } @@ -1138,15 +1291,17 @@ namespace lvh { auto backend_result = state_->backend->create_mouse(id, options); if (!backend_result) { + state_->logger->emit(LogLevel::error, "mouse creation failed: " + backend_result.status.message()); return {std::move(backend_result.status), nullptr}; } - auto device = std::make_shared(id, options, std::move(backend_result.mouse)); + auto device = std::make_shared(id, options, std::move(backend_result.mouse), state_->logger); state_->with_lock([this, &device]() { state_->mice.emplace_back(device); }); auto mouse = std::make_unique(detail::RuntimeConstructionToken {}, std::move(device)); + state_->logger->emit(LogLevel::info, std::format("created mouse {}", id)); return {OperationStatus::success(), std::move(mouse)}; } diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp index 9bd2e57b..097d62c1 100644 --- a/src/include/libvirtualhid/types.hpp +++ b/src/include/libvirtualhid/types.hpp @@ -107,6 +107,24 @@ namespace lvh { platform_default, ///< Native backend for the current platform. }; + /** + * @brief Diagnostic message severity. + */ + enum class LogLevel : std::uint8_t { + debug, ///< Detailed diagnostic information. + info, ///< Normal runtime lifecycle information. + warning, ///< Recoverable problem or fallback. + error, ///< Operation failure. + }; + + /** + * @brief Consumer callback that receives libvirtualhid diagnostic messages. + * + * The callback is invoked synchronously from the thread performing the + * operation. If the callback throws, it is disabled for subsequent messages. + */ + using LogCallback = std::function; + /** * @brief Runtime creation options. */ @@ -115,6 +133,11 @@ namespace lvh { * @brief Backend implementation requested by the caller. */ BackendKind backend = BackendKind::fake; + + /** + * @brief Optional callback for diagnostic messages. + */ + LogCallback log_callback; }; /** @@ -442,6 +465,36 @@ namespace lvh { std::string stable_id; }; + /** + * @brief Viewport in the platform's native screen coordinate space. + * + * Mouse desktop and viewport geometry use screen points on macOS and desktop + * pixels on Windows, Linux, and FreeBSD. Use the same units for offsets and + * dimensions; source coordinates supplied to Mouse::move_absolute() remain + * in the caller's source coordinate space and are scaled into this viewport. + */ + struct PointerViewport { + /** + * @brief Horizontal viewport offset in native screen coordinates. + */ + std::int32_t offset_x = 0; + + /** + * @brief Vertical viewport offset in native screen coordinates. + */ + std::int32_t offset_y = 0; + + /** + * @brief Viewport width in native screen units, or `0` to use the platform default. + */ + std::int32_t width = 0; + + /** + * @brief Viewport height in native screen units, or `0` to use the platform default. + */ + std::int32_t height = 0; + }; + /** * @brief Full mouse creation request. */ @@ -455,6 +508,25 @@ namespace lvh { * @brief Consumer-defined stable identity string. */ std::string stable_id; + + /** + * @brief Native virtual-desktop bounds used to normalize the target viewport. + * + * Set this together with `viewport`; leave both dimensions at zero to use + * the platform-default pointer area. Use screen points on macOS and desktop + * pixels on Windows, Linux, and FreeBSD. + */ + PointerViewport desktop; + + /** + * @brief Native desktop viewport that receives absolute mouse input. + * + * Set this together with `desktop`; leave both dimensions at zero to use + * the platform-default pointer area. On macOS the default follows the current + * main display bounds on each submission; configured geometry remains fixed + * for the mouse's lifetime. + */ + PointerViewport viewport; }; /** @@ -768,31 +840,6 @@ namespace lvh { std::string text; }; - /** - * @brief Pixel viewport used by backends that need screen-local pointer coordinates. - */ - struct PointerViewport { - /** - * @brief Horizontal viewport offset in pixels. - */ - std::int32_t offset_x = 0; - - /** - * @brief Vertical viewport offset in pixels. - */ - std::int32_t offset_y = 0; - - /** - * @brief Viewport width in pixels, or `0` to use the platform default. - */ - std::int32_t width = 0; - - /** - * @brief Viewport height in pixels, or `0` to use the platform default. - */ - std::int32_t height = 0; - }; - /** * @brief Pointer state transition requested for contact-capable devices. */ diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index edf6b534..7c24f416 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -808,6 +808,37 @@ namespace lvh::detail { return static_cast(numerator / limit); } + /** + * @brief Scale a source coordinate through a target viewport into an absolute input axis. + * + * @param value Source coordinate. + * @param source_dimension Source coordinate-space dimension. + * @param viewport_offset Native target viewport offset. + * @param viewport_dimension Native target viewport dimension. + * @param desktop_offset Native virtual-desktop origin. + * @param desktop_dimension Native virtual-desktop dimension. + * @return Normalized evdev absolute-axis value. + */ + int scale_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ) { + if (source_dimension <= 0 || desktop_dimension <= 0 || viewport_dimension <= 0) { + return 0; + } + + const auto clamped = std::clamp(value, 0.0F, static_cast(source_dimension)); + const auto viewport_span = static_cast(std::max(viewport_dimension - 1, 0)); + const auto target = static_cast(viewport_offset - desktop_offset) + + clamped * viewport_span / static_cast(source_dimension); + const auto normalized = std::clamp(target / static_cast(desktop_dimension), 0.0F, 1.0F); + return static_cast(std::lround(normalized * static_cast(absolute_axis_max))); + } + int scale_normalized_axis(float value, int maximum) { return static_cast(std::lround(std::clamp(value, 0.0F, 1.0F) * static_cast(maximum))); } @@ -1692,15 +1723,24 @@ namespace lvh::detail { */ class UinputMouse final: public BackendMouse { public: - UinputMouse(int relative_file_descriptor, int absolute_file_descriptor): + UinputMouse( + int relative_file_descriptor, + int absolute_file_descriptor, + PointerViewport desktop = {}, + PointerViewport viewport = {} + ): relative_device_ {relative_file_descriptor}, - absolute_device_ {absolute_file_descriptor} {} + absolute_device_ {absolute_file_descriptor}, + desktop_ {desktop}, + viewport_ {viewport} {} ~UinputMouse() override { static_cast(close()); } OperationStatus create(DeviceId id, const CreateMouseOptions &options) { + desktop_ = options.desktop; + viewport_ = options.viewport; if (const auto status = relative_device_.create(id, options.profile, UinputMouseDeviceKind::relative); !status.ok()) { return status; } @@ -1749,6 +1789,8 @@ namespace lvh::detail { private: UinputMouseDevice relative_device_; UinputMouseDevice absolute_device_; + PointerViewport desktop_; ///< Native virtual-desktop bounds used for absolute input. + PointerViewport viewport_; ///< Native target viewport used for absolute input. UinputMouseDeviceKind last_motion_device_ = UinputMouseDeviceKind::relative; std::byte relative_buttons_down_ {}; std::byte absolute_buttons_down_ {}; @@ -1805,10 +1847,32 @@ namespace lvh::detail { OperationStatus submit_absolute_motion(const MouseEvent &event) { auto &absolute = device(UinputMouseDeviceKind::absolute); - if (const auto status = absolute.emit(EV_ABS, ABS_X, scale_absolute_axis(event.x, event.width)); !status.ok()) { - return status; - } - if (const auto status = absolute.emit(EV_ABS, ABS_Y, scale_absolute_axis(event.y, event.height)); !status.ok()) { + const auto x = event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x); + const auto y = event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y); + const auto absolute_x = viewport_.width > 0 ? + scale_absolute_axis_to_viewport( + x, + event.width, + viewport_.offset_x, + viewport_.width, + desktop_.offset_x, + desktop_.width + ) : + scale_absolute_axis(event.x, event.width); + const auto absolute_y = viewport_.height > 0 ? + scale_absolute_axis_to_viewport( + y, + event.height, + viewport_.offset_y, + viewport_.height, + desktop_.offset_y, + desktop_.height + ) : + scale_absolute_axis(event.y, event.height); + if (const auto status = absolute.emit(EV_ABS, ABS_X, absolute_x); !status.ok()) { + return status; + } + if (const auto status = absolute.emit(EV_ABS, ABS_Y, absolute_y); !status.ok()) { return status; } if (const auto status = absolute.synchronize(); !status.ok()) { @@ -2478,7 +2542,9 @@ namespace lvh::detail { static_cast(close()); } - OperationStatus create() { + OperationStatus create(const CreateMouseOptions &options = {}) { + desktop_ = options.desktop; + viewport_ = options.viewport; display_ = XOpenDisplay(nullptr); if (display_ == nullptr) { return OperationStatus::failure(ErrorCode::backend_unavailable, "failed to open X display for XTest mouse fallback"); @@ -2530,11 +2596,23 @@ namespace lvh::detail { private: void submit_absolute_motion(const MouseEvent &event) { const auto screen = DefaultScreen(display_); - const auto screen_width = DisplayWidth(display_, screen); - const auto screen_height = DisplayHeight(display_, screen); - const auto x = scale_absolute_axis(event.x, event.width) * std::max(screen_width - 1, 0) / absolute_axis_max; - const auto y = scale_absolute_axis(event.y, event.height) * std::max(screen_height - 1, 0) / absolute_axis_max; - XTestFakeMotionEvent(display_, screen, x, y, CurrentTime); + const auto screen_width = std::max(DisplayWidth(display_, screen) - 1, 0); + const auto screen_height = std::max(DisplayHeight(display_, screen) - 1, 0); + const auto event_x = event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x); + const auto event_y = event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y); + const auto x = viewport_.width > 0 ? + viewport_.offset_x - desktop_.offset_x + static_cast(std::lround(std::clamp(event_x, 0.0F, static_cast(event.width)) * static_cast(std::max(viewport_.width - 1, 0)) / static_cast(event.width))) : + scale_absolute_axis(event.x, event.width) * screen_width / absolute_axis_max; + const auto y = viewport_.height > 0 ? + viewport_.offset_y - desktop_.offset_y + static_cast(std::lround(std::clamp(event_y, 0.0F, static_cast(event.height)) * static_cast(std::max(viewport_.height - 1, 0)) / static_cast(event.height))) : + scale_absolute_axis(event.y, event.height) * screen_height / absolute_axis_max; + XTestFakeMotionEvent( + display_, + screen, + std::clamp(x, 0, screen_width), + std::clamp(y, 0, screen_height), + CurrentTime + ); } void submit_scroll(std::int32_t distance, int positive_button, int negative_button) { @@ -2547,6 +2625,8 @@ namespace lvh::detail { } Display *display_ = nullptr; + PointerViewport desktop_; ///< Native virtual-desktop bounds represented by the X root window. + PointerViewport viewport_; ///< Native target viewport used for absolute input. }; #else bool can_use_xtest() { @@ -3611,19 +3691,19 @@ namespace lvh::detail { BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) override { const auto relative_fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (relative_fd < 0) { - return create_xtest_mouse(); + return create_xtest_mouse(options); } const auto absolute_fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (absolute_fd < 0) { static_cast(system_close(relative_fd)); - return create_xtest_mouse(); + return create_xtest_mouse(options); } auto mouse = std::make_unique(relative_fd, absolute_fd); if (const auto status = mouse->create(id, options); !status.ok()) { static_cast(mouse->close()); - auto fallback = create_xtest_mouse(); + auto fallback = create_xtest_mouse(options); if (fallback) { return fallback; } @@ -3694,10 +3774,10 @@ namespace lvh::detail { #endif } - BackendMouseCreationResult create_xtest_mouse() { + BackendMouseCreationResult create_xtest_mouse(const CreateMouseOptions &options) { #if defined(LIBVIRTUALHID_HAVE_XTEST) auto mouse = std::make_unique(); - if (const auto status = mouse->create(); !status.ok()) { + if (const auto status = mouse->create(options); !status.ok()) { return {status, nullptr}; } return {OperationStatus::success(), std::move(mouse)}; diff --git a/src/platform/macos/macos_backend.cpp b/src/platform/macos/macos_backend.cpp index 2f1c5dc8..8c027c97 100644 --- a/src/platform/macos/macos_backend.cpp +++ b/src/platform/macos/macos_backend.cpp @@ -459,14 +459,35 @@ namespace lvh::detail { }; } + /** + * @brief Resolve a portable pointer viewport to CoreGraphics desktop bounds. + * + * @param viewport Consumer-selected viewport in CoreGraphics screen points, or zero dimensions for the main display. + * @return Current CoreGraphics bounds used for mouse mapping and confinement. + */ + inline CGRect mouse_viewport_bounds(const PointerViewport &viewport) { + if (viewport.width > 0 && viewport.height > 0) { + return CGRect { + .origin = CGPoint { + static_cast(viewport.offset_x), + static_cast(viewport.offset_y), + }, + .size = CGSize { + static_cast(viewport.width), + static_cast(viewport.height), + }, + }; + } + + return CGDisplayBounds(CGMainDisplayID()); + } + /** * @brief Shared macOS backend state. */ class MacosInputState { public: MacosInputState(): - display {CGMainDisplayID()}, - display_scaling {display_scaling_for(display)}, source {CGEventSourceCreate(kCGEventSourceStateHIDSystemState)}, keyboard_source {CGEventSourceCreate(kCGEventSourceStatePrivate)}, mouse_event {source ? CGEventCreate(source) : nullptr}, @@ -489,31 +510,6 @@ namespace lvh::detail { } } - /** - * @brief Compute the coordinate scaling factor for a display. - * - * @param display_id CoreGraphics display identifier. - * @return Coordinate scaling factor. - */ - static CGFloat display_scaling_for(CGDirectDisplayID display_id) { - const auto mode = CGDisplayCopyDisplayMode(display_id); - if (!mode) { - return 1.0; - } - - const auto logical_width = CGDisplayModeGetPixelWidth(mode); - if (logical_width == 0) { - CFRelease(mode); - return 1.0; - } - - const auto scaling = static_cast(CGDisplayPixelsWide(display_id)) / static_cast(logical_width); - CFRelease(mode); - return scaling; - } - - CGDirectDisplayID display {}; ///< CoreGraphics identifier for the target display. - CGFloat display_scaling = 1.0; ///< Scaling factor from logical to physical display pixels. CGEventSourceRef source {}; ///< CoreGraphics event source for mouse and scroll events. CGEventSourceRef keyboard_source {}; ///< CoreGraphics event source for keyboard events. CGEventRef mouse_event {}; ///< Reusable CoreGraphics mouse event. @@ -728,8 +724,9 @@ namespace lvh::detail { */ class MacosMouse final: public BackendMouse { public: - explicit MacosMouse(std::shared_ptr state): - state_ {std::move(state)} {} + MacosMouse(std::shared_ptr state, const PointerViewport &viewport): + state_ {std::move(state)}, + viewport_ {viewport} {} ~MacosMouse() override { static_cast(close()); @@ -746,6 +743,13 @@ namespace lvh::detail { return OperationStatus::failure(ErrorCode::backend_failure, "macOS mouse event source is unavailable"); } + if (event.kind == relative_motion || event.kind == absolute_motion || event.kind == button) { + viewport_bounds_ = mouse_viewport_bounds(viewport_); + if (viewport_bounds_.size.width < 1 || viewport_bounds_.size.height < 1) { + return OperationStatus::failure(ErrorCode::backend_failure, "macOS mouse viewport is unavailable"); + } + } + switch (event.kind) { case relative_motion: return submit_relative_motion(event.x, event.y); @@ -772,8 +776,7 @@ namespace lvh::detail { CGPoint current_location() const { const auto snapshot_event = CGEventCreate(state_->source); if (!snapshot_event) { - const auto display_bounds = CGDisplayBounds(state_->display); - return display_bounds.origin; + return viewport_bounds_.origin; } const auto current = CGEventGetLocation(snapshot_event); @@ -788,10 +791,9 @@ namespace lvh::detail { CGPoint previous_location, int click_count ) const { - const auto display_bounds = CGDisplayBounds(state_->display); const auto location = CGPoint { - std::clamp(raw_location.x, display_bounds.origin.x, display_bounds.origin.x + display_bounds.size.width - 1), - std::clamp(raw_location.y, display_bounds.origin.y, display_bounds.origin.y + display_bounds.size.height - 1) + std::clamp(raw_location.x, viewport_bounds_.origin.x, viewport_bounds_.origin.x + viewport_bounds_.size.width - 1), + std::clamp(raw_location.y, viewport_bounds_.origin.y, viewport_bounds_.origin.y + viewport_bounds_.size.height - 1) }; const auto event = state_->mouse_event; @@ -820,8 +822,7 @@ namespace lvh::detail { } OperationStatus submit_absolute_motion(const MouseEvent &event) { - const auto display_bounds = CGDisplayBounds(state_->display); - const auto location = absolute_mouse_location(event, display_bounds); + const auto location = absolute_mouse_location(event, viewport_bounds_); const auto motion = macos_mouse_motion(mouse_down_); return post_mouse(motion.button, motion.event_type, location, current_location(), 0); } @@ -865,6 +866,8 @@ namespace lvh::detail { } std::shared_ptr state_; + PointerViewport viewport_; ///< Requested screen geometry, with zero dimensions selecting the current main display. + CGRect viewport_bounds_ {}; ///< CoreGraphics bounds resolved for the current mouse submission. std::array mouse_down_ {}; std::array, 3> last_mouse_event_ {}; std::mutex mutex_; @@ -914,7 +917,7 @@ namespace lvh::detail { return {OperationStatus::failure(ErrorCode::backend_failure, "macOS mouse event source is unavailable"), nullptr}; } - return {OperationStatus::success(), std::make_unique(std::move(state))}; + return {OperationStatus::success(), std::make_unique(std::move(state), options.viewport)}; } BackendTouchscreenCreationResult create_touchscreen( diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index a6553497..c7f99e0f 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -432,6 +432,36 @@ namespace lvh::detail { return static_cast(std::lround(scaled)); } + /** + * @brief Scale an absolute source coordinate through a target viewport into a virtual desktop axis. + * + * @param value Absolute source coordinate. + * @param source_dimension Source coordinate-space dimension. + * @param viewport_offset Target viewport offset in native desktop pixels. + * @param viewport_dimension Target viewport dimension in native desktop pixels. + * @param desktop_offset Virtual desktop origin in native desktop pixels. + * @param desktop_dimension Virtual desktop dimension in native desktop pixels. + * @return Win32 normalized absolute coordinate. + */ + LONG scale_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ) { + if (source_dimension <= 0 || viewport_dimension <= 0 || desktop_dimension <= 0) { + return 0; + } + + const auto clamped = std::clamp(value, 0.0F, static_cast(source_dimension)); + const auto viewport_span = static_cast(std::max(viewport_dimension - 1, 0)); + const auto target = static_cast(viewport_offset - desktop_offset) + + clamped * viewport_span / static_cast(source_dimension); + return scale_absolute_axis(target, desktop_dimension); + } + PointerViewport resolve_pointer_viewport(PointerViewport viewport) { if (viewport.width > 0 && viewport.height > 0) { return viewport; @@ -2146,6 +2176,16 @@ namespace lvh::detail { class WindowsMouse final: public BackendMouse { public: + /** + * @brief Construct a Win32 mouse injection backend. + * + * @param desktop Native virtual-desktop bounds. + * @param viewport Native desktop viewport receiving absolute input. + */ + explicit WindowsMouse(PointerViewport desktop = {}, PointerViewport viewport = {}): + desktop_ {desktop}, + viewport_ {viewport} {} + OperationStatus submit(const MouseEvent &event) override { using enum ErrorCode; @@ -2167,14 +2207,33 @@ namespace lvh::detail { break; case absolute_motion: mouse.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; - mouse.dx = scale_absolute_axis( - event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x), - event.width - ); - mouse.dy = scale_absolute_axis( - event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y), - event.height - ); + if (viewport_.width > 0 && viewport_.height > 0) { + mouse.dx = scale_absolute_axis_to_viewport( + event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x), + event.width, + viewport_.offset_x, + viewport_.width, + desktop_.offset_x, + desktop_.width + ); + mouse.dy = scale_absolute_axis_to_viewport( + event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y), + event.height, + viewport_.offset_y, + viewport_.height, + desktop_.offset_y, + desktop_.height + ); + } else { + mouse.dx = scale_absolute_axis( + event.has_fractional_absolute_coordinates ? event.absolute_x : static_cast(event.x), + event.width + ); + mouse.dy = scale_absolute_axis( + event.has_fractional_absolute_coordinates ? event.absolute_y : static_cast(event.y), + event.height + ); + } break; case button: mouse.dwFlags = mouse_button_flags(event.button, event.pressed); @@ -2199,6 +2258,8 @@ namespace lvh::detail { } private: + PointerViewport desktop_; ///< Native virtual-desktop bounds. + PointerViewport viewport_; ///< Native desktop viewport receiving absolute input. bool open_ = true; }; @@ -2278,10 +2339,13 @@ namespace lvh::detail { public: WindowsHidMouse( std::shared_ptr context, - std::shared_ptr state + std::shared_ptr state, + const PointerViewport &desktop, + const PointerViewport &viewport ): context_ {std::move(context)}, - state_ {std::move(state)} {} + state_ {std::move(state)}, + fallback_ {desktop, viewport} {} OperationStatus submit(const MouseEvent &event) override { using enum ErrorCode; @@ -2439,7 +2503,10 @@ namespace lvh::detail { if (!status.ok()) { return {status, nullptr}; } - return {std::move(status), std::make_unique(shared_from_this(), std::move(state))}; + return { + std::move(status), + std::make_unique(shared_from_this(), std::move(state), options.desktop, options.viewport), + }; } /** @@ -3079,7 +3146,7 @@ namespace lvh::detail { } } - return {OperationStatus::success(), std::make_unique()}; + return {OperationStatus::success(), std::make_unique(options.desktop, options.viewport)}; } BackendTouchscreenCreationResult create_touchscreen( diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 6015baec..d53c6a22 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -644,6 +644,26 @@ namespace lvh::detail::test { */ int linux_absolute_axis(std::int32_t value, std::int32_t limit); + /** + * @brief Scale an absolute pointer coordinate through a target viewport. + * + * @param value Source coordinate. + * @param source_dimension Source coordinate-space dimension. + * @param viewport_offset Target viewport offset. + * @param viewport_dimension Target viewport dimension. + * @param desktop_offset Virtual desktop origin. + * @param desktop_dimension Virtual desktop dimension. + * @return Linux absolute axis value. + */ + int linux_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ); + /** * @brief Decode UTF-8 into Unicode code points using the Linux backend decoder. * @@ -927,6 +947,18 @@ namespace lvh::detail::test { */ LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event); + /** + * @brief Submit a mouse event to a pipe-backed uinput mouse with explicit desktop mapping. + * + * @param event Mouse event. + * @param options Mouse creation options containing desktop and target viewports. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe( + const MouseEvent &event, + const CreateMouseOptions &options + ); + /** * @brief Submit mouse events to one pipe-backed uinput mouse. * diff --git a/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp index 528bc54b..0540fe4d 100644 --- a/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/macos_backend_test_hooks.hpp @@ -23,6 +23,34 @@ namespace lvh::detail::test { double y {}; ///< Vertical coordinate. }; + /** + * @brief Portable representation of CoreGraphics viewport bounds for tests. + */ + struct MacosViewportBounds { + double origin_x {}; ///< Horizontal viewport origin. + double origin_y {}; ///< Vertical viewport origin. + double width {}; ///< Viewport width. + double height {}; ///< Viewport height. + }; + + /** + * @brief Display and cursor state supplied before one mouse submission. + */ + struct MacosMouseSubmission { + MacosViewportBounds display_bounds; ///< Current main-display bounds in screen points. + MacosPoint cursor_location; ///< Cursor location before submission, in screen points. + MouseEvent event; ///< Mouse event submitted to the backend. + }; + + /** + * @brief Mouse submission status and captured CoreGraphics destinations. + */ + struct MacosMouseSubmissionResult { + OperationStatus status; ///< Backend submission status. + std::optional posted_location; ///< Location on the posted event, if any. + std::optional warped_location; ///< Cursor warp destination, if any. + }; + /** * @brief Portable representation of CoreGraphics mouse motion metadata for tests. */ @@ -132,6 +160,28 @@ namespace lvh::detail::test { double height ); + /** + * @brief Resolve an explicit portable mouse viewport to CoreGraphics bounds. + * + * @param viewport Portable pointer viewport. + * @return CoreGraphics bounds represented with portable scalar fields. + */ + MacosViewportBounds macos_backend_mouse_viewport_bounds(const PointerViewport &viewport); + + /** + * @brief Submit to one backend mouse while simulating changing display geometry without delivering input. + * + * @param viewport Requested mouse viewport in native screen points, or zero dimensions for the main display. + * @param initial_display_bounds Main-display bounds when the mouse is created. + * @param submissions Display geometry, cursor location, and event for each submission. + * @return Submission statuses and captured event and warp locations. + */ + std::vector macos_backend_mouse_submissions( + const PointerViewport &viewport, + const MacosViewportBounds &initial_display_bounds, + const std::vector &submissions + ); + /** * @brief Select CoreGraphics motion metadata for a mouse button state. * diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp index 11514c7b..85701cd7 100644 --- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp @@ -278,4 +278,24 @@ namespace lvh::detail::test { WindowsOverlappedIoResult windows_backend_overlapped_device_io(); WindowsBackendSendInputResult windows_backend_send_input_devices(); + /** + * @brief Scale a source coordinate through a target viewport into a virtual desktop axis. + * + * @param value Source coordinate. + * @param source_dimension Source coordinate-space dimension. + * @param viewport_offset Target viewport offset. + * @param viewport_dimension Target viewport dimension. + * @param desktop_offset Virtual desktop origin. + * @param desktop_dimension Virtual desktop dimension. + * @return Win32 normalized absolute coordinate. + */ + std::int32_t windows_backend_scale_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ); + } // namespace lvh::detail::test diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index a4022391..34ae3852 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -996,6 +996,24 @@ namespace lvh::detail::test { return scale_absolute_axis(value, limit); } + int linux_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ) { + return scale_absolute_axis_to_viewport( + value, + source_dimension, + viewport_offset, + viewport_dimension, + desktop_offset, + desktop_dimension + ); + } + std::vector linux_decode_utf8(const std::string &text) { return decode_utf8(text); } @@ -1321,35 +1339,58 @@ namespace lvh::detail::test { return mouse.submit({.kind = MouseEventKind::relative_motion, .x = 1, .y = 1}); } - LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event) { - return linux_uinput_mouse_submit_pipe_sequence(std::vector {event}); - } + namespace { + /** + * @brief Submit mouse events through pipe-backed uinput devices. + * + * @param events Mouse events to submit. + * @param options Mouse creation options containing optional viewport bounds. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult submit_uinput_mouse_pipe_sequence( + const std::vector &events, + const CreateMouseOptions &options + ) { + std::array descriptors {-1, -1}; + if (::pipe(descriptors.data()) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } - 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), {}}; - } + const auto absolute_descriptor = ::dup(descriptors[1]); + if (absolute_descriptor < 0) { + static_cast(::close(descriptors[0])); + static_cast(::close(descriptors[1])); + return {system_error_status(ErrorCode::backend_failure, "failed to duplicate pipe", errno), {}}; + } - const auto absolute_descriptor = ::dup(descriptors[1]); - if (absolute_descriptor < 0) { + UinputMouse mouse {descriptors[1], absolute_descriptor, options.desktop, options.viewport}; + 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])); - static_cast(::close(descriptors[1])); - return {system_error_status(ErrorCode::backend_failure, "failed to duplicate pipe", errno), {}}; + return {std::move(status), std::move(records)}; } + } // namespace - UinputMouse mouse {descriptors[1], absolute_descriptor}; - 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])); - return {std::move(status), std::move(records)}; + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event) { + return submit_uinput_mouse_pipe_sequence(std::vector {event}, {}); + } + + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe( + const MouseEvent &event, + const CreateMouseOptions &options + ) { + return submit_uinput_mouse_pipe_sequence(std::vector {event}, options); + } + + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe_sequence(const std::vector &events) { + return submit_uinput_mouse_pipe_sequence(events, {}); } LinuxMouseInputSubmissionResult linux_uinput_mouse_submit_split_pipe_sequence(const std::vector &events) { diff --git a/tests/fixtures/macos_backend_test_hooks.cpp b/tests/fixtures/macos_backend_test_hooks.cpp index 97f04953..aaf01698 100644 --- a/tests/fixtures/macos_backend_test_hooks.cpp +++ b/tests/fixtures/macos_backend_test_hooks.cpp @@ -3,15 +3,126 @@ * @brief macOS backend test hook definitions. */ +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include + // local includes +#include "core/backend.hpp" #include "fixtures/macos_backend_test_hooks.hpp" +#include "platform/macos/macos_broker_client.hpp" + +// platform includes +#include +#include +#include +#include + +namespace { + + struct MouseEnvironment; + + /** + * @brief Access the active mouse environment for the calling thread. + * + * @return Reference to the scoped environment pointer, or a null pointer when no environment is active. + */ + MouseEnvironment *&mouse_environment() { + static thread_local MouseEnvironment *environment = nullptr; + return environment; + } + + /** + * @brief Scoped display geometry and captured mouse delivery for backend submission tests. + */ + struct MouseEnvironment { + explicit MouseEnvironment(const lvh::detail::test::MacosViewportBounds &bounds): + display_bounds {bounds} { + mouse_environment() = this; + } + + MouseEnvironment(const MouseEnvironment &) = delete; + MouseEnvironment &operator=(const MouseEnvironment &) = delete; + MouseEnvironment(MouseEnvironment &&) = delete; + MouseEnvironment &operator=(MouseEnvironment &&) = delete; + + ~MouseEnvironment() { + mouse_environment() = nullptr; + } + + lvh::detail::test::MacosViewportBounds display_bounds; + lvh::detail::test::MacosPoint cursor_location; + std::optional posted_location; + std::optional warped_location; + }; + + CGDirectDisplayID mouse_main_display_id() { + return mouse_environment() ? 42 : CGMainDisplayID(); + } + + CGRect mouse_display_bounds(CGDirectDisplayID display) { + if (!mouse_environment()) { + return CGDisplayBounds(display); + } + const auto &bounds = mouse_environment()->display_bounds; + return CGRect {{bounds.origin_x, bounds.origin_y}, {bounds.width, bounds.height}}; + } + + CGPoint mouse_event_location(CGEventRef event) { + if (!mouse_environment()) { + return CGEventGetLocation(event); + } + return CGPoint {mouse_environment()->cursor_location.x, mouse_environment()->cursor_location.y}; + } + void mouse_event_post(CGEventTapLocation tap, CGEventRef event) { + if (!mouse_environment()) { + CGEventPost(tap, event); + return; + } + const auto point = CGEventGetLocation(event); + mouse_environment()->posted_location = {.x = point.x, .y = point.y}; + } + + CGError mouse_warp_cursor_position(CGPoint point) { + if (!mouse_environment()) { + return CGWarpMouseCursorPosition(point); + } + mouse_environment()->warped_location = {.x = point.x, .y = point.y}; + return kCGErrorSuccess; + } + +} // namespace + +// Load the backend's dependencies before these macros, then compile it in a +// separate namespace so instrumented inline symbols remain isolated. +#define macos macos_backend_test +#define CGMainDisplayID mouse_main_display_id +#define CGDisplayBounds mouse_display_bounds +#define CGEventGetLocation mouse_event_location +#define CGEventPost mouse_event_post +#define CGWarpMouseCursorPosition mouse_warp_cursor_position #define create_platform_backend create_platform_backend_for_macos_backend_test_hooks #include "../../src/platform/macos/macos_backend.cpp" #undef create_platform_backend +#undef CGWarpMouseCursorPosition +#undef CGEventPost +#undef CGEventGetLocation +#undef CGDisplayBounds +#undef CGMainDisplayID +#undef macos namespace lvh::detail::test { + namespace macos = lvh::detail::macos_backend_test; + std::optional macos_backend_key_code(KeyboardKeyCode key_code) { const auto mapped = macos::macos_key_code(key_code); if (!mapped) { @@ -93,6 +204,43 @@ namespace lvh::detail::test { return {.x = location.x, .y = location.y}; } + MacosViewportBounds macos_backend_mouse_viewport_bounds(const PointerViewport &viewport) { + const auto bounds = macos::mouse_viewport_bounds(viewport); + return { + .origin_x = bounds.origin.x, + .origin_y = bounds.origin.y, + .width = bounds.size.width, + .height = bounds.size.height, + }; + } + + std::vector macos_backend_mouse_submissions( + const PointerViewport &viewport, + const MacosViewportBounds &initial_display_bounds, + const std::vector &submissions + ) { + MouseEnvironment environment {initial_display_bounds}; + auto backend = create_platform_backend_for_macos_backend_test_hooks(); + CreateMouseOptions options; + options.profile.device_type = DeviceType::mouse; + options.viewport = viewport; + auto created = backend->create_mouse(1, options); + if (!created) { + return {{.status = created.status, .posted_location = std::nullopt, .warped_location = std::nullopt}}; + } + + std::vector results; + for (const auto &submission : submissions) { + environment.display_bounds = submission.display_bounds; + environment.cursor_location = submission.cursor_location; + environment.posted_location.reset(); + environment.warped_location.reset(); + const auto status = created.mouse->submit(submission.event); + results.emplace_back(status, environment.posted_location, environment.warped_location); + } + return results; + } + MacosMouseMotionResult macos_backend_mouse_motion(bool left_down, bool right_down, bool middle_down) { const auto motion = macos::macos_mouse_motion({left_down, right_down, middle_down}); return { diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index 079dae4d..892dbf80 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -486,6 +486,24 @@ namespace lvh::detail { namespace test { + std::int32_t windows_backend_scale_absolute_axis_to_viewport( + float value, + std::int32_t source_dimension, + std::int32_t viewport_offset, + std::int32_t viewport_dimension, + std::int32_t desktop_offset, + std::int32_t desktop_dimension + ) { + return scale_absolute_axis_to_viewport( + value, + source_dimension, + viewport_offset, + viewport_dimension, + desktop_offset, + desktop_dimension + ); + } + WindowsBackendLifecycleResult windows_backend_fake_channel_lifecycle() { WindowsBackendLifecycleResult result; auto command_state = std::make_shared(); diff --git a/tests/unit/test_license.cpp b/tests/unit/test_license.cpp index 69677b96..03b6f69e 100644 --- a/tests/unit/test_license.cpp +++ b/tests/unit/test_license.cpp @@ -95,7 +95,7 @@ TEST(GitHubActionsEvaluationTest, RemainingTimeClampsAtWindowBoundaries) { EXPECT_EQ(remaining(started_at, started_at - 1s), 0s); } -#if !defined(_WIN32) +#if !defined(_WIN32) && !defined(__APPLE__) TEST(LicenseApiTest, UnsupportedPlatformReturnsExplicitFailure) { const auto queried = lvh::get_license_status(); EXPECT_FALSE(queried); diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 05ae076d..f9ba33b3 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -150,6 +150,13 @@ TEST_F(LinuxBackendTest, ScalesAbsoluteAxesAndScrollSteps) { EXPECT_EQ(lvh::detail::test::linux_absolute_axis(101, 100), 65535); EXPECT_EQ(lvh::detail::test::linux_absolute_axis(1, 0), 0); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(0.0F, 1920, 0, 1920, -1920, 3840), 32768); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(960.0F, 1920, 0, 1920, -1920, 3840), 49143); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(1920.0F, 1920, 0, 1920, -1920, 3840), 65518); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(1.0F, 0, 0, 1920, -1920, 3840), 0); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(1.0F, 1, 0, 0, 0, 1), 0); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis_to_viewport(1.0F, 1, 0, 1, 0, 0), 0); + EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(0), 0); EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(1), 1); EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(-1), -1); @@ -677,6 +684,27 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { EXPECT_EQ(result.events.back().type, EV_SYN); } +TEST_F(LinuxBackendTest, PipeBackedUinputMouseMapsConfiguredViewport) { + lvh::MouseEvent event { + .kind = lvh::MouseEventKind::absolute_motion, + .x = 960, + .y = 540, + .width = 1920, + .height = 1080, + }; + lvh::CreateMouseOptions options; + options.desktop = {.offset_x = -1920, .offset_y = 0, .width = 3840, .height = 1080}; + options.viewport = {.offset_x = 0, .offset_y = 0, .width = 1920, .height = 1080}; + + const auto result = lvh::detail::test::linux_uinput_mouse_submit_pipe(event, options); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + ASSERT_EQ(result.events.size(), 3U); + EXPECT_EQ(result.events[0].code, ABS_X); + EXPECT_EQ(result.events[0].value, 49143); + EXPECT_EQ(result.events[1].code, ABS_Y); + EXPECT_EQ(result.events[1].value, 32737); +} + TEST_F(LinuxBackendTest, PipeBackedUinputMouseRoutesMotionAndButtonsAcrossSplitDevices) { const std::vector events { {.kind = lvh::MouseEventKind::absolute_motion, .x = 50, .y = 25, .width = 100, .height = 100}, diff --git a/tests/unit/test_macos_backend.cpp b/tests/unit/test_macos_backend.cpp index db39cfcf..908b799e 100644 --- a/tests/unit/test_macos_backend.cpp +++ b/tests/unit/test_macos_backend.cpp @@ -3,6 +3,9 @@ * @brief Unit tests for macOS backend internals. */ +// standard includes +#include + // lib includes #include @@ -189,6 +192,143 @@ TEST_F(MacosBackendTest, ConvertsAbsoluteMouseCoordinates) { EXPECT_DOUBLE_EQ(location.y, 170.0); } +TEST_F(MacosBackendTest, UsesConfiguredMouseViewport) { + const auto bounds = lvh::detail::test::macos_backend_mouse_viewport_bounds({ + .offset_x = -1920, + .offset_y = 120, + .width = 1920, + .height = 1080, + }); + + EXPECT_DOUBLE_EQ(bounds.origin_x, -1920.0); + EXPECT_DOUBLE_EQ(bounds.origin_y, 120.0); + EXPECT_DOUBLE_EQ(bounds.width, 1920.0); + EXPECT_DOUBLE_EQ(bounds.height, 1080.0); +} + +TEST_F(MacosBackendTest, RefreshesDefaultMouseBoundsBetweenSubmissions) { + const lvh::MouseEvent center { + .kind = lvh::MouseEventKind::absolute_motion, + .x = 960, + .y = 540, + .width = 1920, + .height = 1080, + }; + const std::vector submissions { + {.display_bounds = {0, 0, 1920, 1080}, .cursor_location = {}, .event = center}, + {.display_bounds = {0, 0, 2560, 1440}, .cursor_location = {}, .event = center}, + {.display_bounds = {0, 0, 2560, 1440}, .cursor_location = {2400, 1300}, .event = {.kind = lvh::MouseEventKind::relative_motion, .x = 400, .y = 300}}, + {.display_bounds = {-1280, -720, 1280, 720}, .cursor_location = {100, 100}, .event = {.kind = lvh::MouseEventKind::button, .button = lvh::MouseButton::left, .pressed = true}}, + }; + const auto results = lvh::detail::test::macos_backend_mouse_submissions({}, {0, 0, 1920, 1080}, submissions); + + ASSERT_EQ(results.size(), 4U); + const std::array expected {{{960, 540}, {1280, 720}, {2559, 1439}, {-1, -1}}}; + for (std::size_t index = 0; index < results.size(); ++index) { + SCOPED_TRACE(index); + ASSERT_TRUE(results[index].status.ok()) << results[index].status.message(); + ASSERT_TRUE(results[index].posted_location); + ASSERT_TRUE(results[index].warped_location); + EXPECT_DOUBLE_EQ(results[index].posted_location->x, expected[index].x); + EXPECT_DOUBLE_EQ(results[index].posted_location->y, expected[index].y); + EXPECT_DOUBLE_EQ(results[index].warped_location->x, expected[index].x); + EXPECT_DOUBLE_EQ(results[index].warped_location->y, expected[index].y); + } +} + +TEST_F(MacosBackendTest, RecoversFromTemporarilyUnavailableDefaultMouseBounds) { + const lvh::MouseEvent center { + .kind = lvh::MouseEventKind::absolute_motion, + .x = 50, + .y = 50, + .width = 100, + .height = 100, + }; + const std::vector submissions { + {.display_bounds = {0, 0, 1920, 1080}, .cursor_location = {}, .event = center}, + {.display_bounds = {0, 0, 0, 1080}, .cursor_location = {}, .event = center}, + {.display_bounds = {0, 0, 1920, 0}, .cursor_location = {}, .event = center}, + {.display_bounds = {0, 0, 2560, 1440}, .cursor_location = {}, .event = center}, + }; + const auto results = lvh::detail::test::macos_backend_mouse_submissions({}, {0, 0, 0, 0}, submissions); + + ASSERT_EQ(results.size(), 4U); + ASSERT_TRUE(results[0].status.ok()) << results[0].status.message(); + ASSERT_TRUE(results[0].posted_location); + EXPECT_DOUBLE_EQ(results[0].posted_location->x, 960); + EXPECT_DOUBLE_EQ(results[0].posted_location->y, 540); + for (const auto index : {1U, 2U}) { + EXPECT_EQ(results[index].status.code(), lvh::ErrorCode::backend_failure); + EXPECT_FALSE(results[index].posted_location); + EXPECT_FALSE(results[index].warped_location); + } + ASSERT_TRUE(results[3].status.ok()) << results[3].status.message(); + ASSERT_TRUE(results[3].posted_location); + ASSERT_TRUE(results[3].warped_location); + EXPECT_DOUBLE_EQ(results[3].posted_location->x, 1280); + EXPECT_DOUBLE_EQ(results[3].posted_location->y, 720); + EXPECT_DOUBLE_EQ(results[3].warped_location->x, 1280); + EXPECT_DOUBLE_EQ(results[3].warped_location->y, 720); +} + +TEST_F(MacosBackendTest, KeepsConfiguredMouseViewportWhenDefaultDisplayChanges) { + const lvh::PointerViewport viewport {.offset_x = -1920, .offset_y = 120, .width = 1920, .height = 1080}; + const lvh::MouseEvent center { + .kind = lvh::MouseEventKind::absolute_motion, + .x = 50, + .y = 50, + .width = 100, + .height = 100, + }; + const std::vector submissions { + {.display_bounds = {0, 0, 2560, 1440}, .cursor_location = {}, .event = center}, + {.display_bounds = {0, 0, 0, 0}, .cursor_location = {-100, 200}, .event = {.kind = lvh::MouseEventKind::relative_motion, .x = 400, .y = -300}}, + }; + const auto results = lvh::detail::test::macos_backend_mouse_submissions(viewport, {0, 0, 1920, 1080}, submissions); + + ASSERT_EQ(results.size(), 2U); + const std::array expected {{{-960, 660}, {-1, 120}}}; + for (std::size_t index = 0; index < results.size(); ++index) { + ASSERT_TRUE(results[index].status.ok()) << results[index].status.message(); + ASSERT_TRUE(results[index].posted_location); + ASSERT_TRUE(results[index].warped_location); + EXPECT_DOUBLE_EQ(results[index].posted_location->x, expected[index].x); + EXPECT_DOUBLE_EQ(results[index].posted_location->y, expected[index].y); + EXPECT_DOUBLE_EQ(results[index].warped_location->x, expected[index].x); + EXPECT_DOUBLE_EQ(results[index].warped_location->y, expected[index].y); + } +} + +TEST_F(MacosBackendTest, ConfiguredAndDefaultRetinaMouseMappingsAgreeInScreenPoints) { + const std::vector submissions { + {.display_bounds = {0, 0, 1920, 1080}, .cursor_location = {}, .event = {.kind = lvh::MouseEventKind::absolute_motion, .x = 1920, .y = 1080, .width = 3840, .height = 2160}}, + {.display_bounds = {0, 0, 1920, 1080}, .cursor_location = {}, .event = {.kind = lvh::MouseEventKind::absolute_motion, .x = 3840, .y = 2160, .width = 3840, .height = 2160}}, + }; + const auto defaults = lvh::detail::test::macos_backend_mouse_submissions({}, {0, 0, 1920, 1080}, submissions); + const auto configured = lvh::detail::test::macos_backend_mouse_submissions({.width = 1920, .height = 1080}, {0, 0, 1920, 1080}, submissions); + + ASSERT_EQ(defaults.size(), 2U); + ASSERT_EQ(configured.size(), defaults.size()); + const std::array expected {{{960, 540}, {1919, 1079}}}; + for (std::size_t index = 0; index < defaults.size(); ++index) { + SCOPED_TRACE(index); + ASSERT_TRUE(defaults[index].status.ok()) << defaults[index].status.message(); + ASSERT_TRUE(configured[index].status.ok()) << configured[index].status.message(); + ASSERT_TRUE(defaults[index].posted_location); + ASSERT_TRUE(configured[index].posted_location); + ASSERT_TRUE(defaults[index].warped_location); + ASSERT_TRUE(configured[index].warped_location); + EXPECT_DOUBLE_EQ(defaults[index].posted_location->x, expected[index].x); + EXPECT_DOUBLE_EQ(defaults[index].posted_location->y, expected[index].y); + EXPECT_DOUBLE_EQ(configured[index].posted_location->x, expected[index].x); + EXPECT_DOUBLE_EQ(configured[index].posted_location->y, expected[index].y); + EXPECT_DOUBLE_EQ(defaults[index].warped_location->x, expected[index].x); + EXPECT_DOUBLE_EQ(defaults[index].warped_location->y, expected[index].y); + EXPECT_DOUBLE_EQ(configured[index].warped_location->x, expected[index].x); + EXPECT_DOUBLE_EQ(configured[index].warped_location->y, expected[index].y); + } +} + TEST_F(MacosBackendTest, SelectsMouseMotionMetadataForHeldButtons) { using lvh::detail::test::macos_backend_mouse_motion; diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index c301e7ac..ee9fff32 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -6,6 +6,12 @@ // lib includes #include +// standard includes +#include +#include +#include +#include + // local includes #include "fixtures/fixtures.hpp" #if defined(_WIN32) @@ -32,6 +38,65 @@ TEST(RuntimeTest, FakeBackendReportsCapabilities) { EXPECT_FALSE(runtime->capabilities().requires_installed_driver); } +TEST(RuntimeTest, RoutesDiagnosticsToConsumerCallback) { + struct Diagnostic { + lvh::LogLevel level; + std::string message; + }; + + std::vector diagnostics; + + lvh::RuntimeOptions runtime_options; + runtime_options.log_callback = [&diagnostics](lvh::LogLevel level, const std::string &message) { + diagnostics.emplace_back(level, message); + }; + auto runtime = lvh::Runtime::create(runtime_options); + + lvh::CreateMouseOptions mouse_options; + mouse_options.profile = lvh::profiles::mouse(); + mouse_options.desktop = {.offset_x = -1920, .offset_y = 0, .width = 3840, .height = 1080}; + mouse_options.viewport = {.offset_x = -1920, .offset_y = 0, .width = 1920, .height = 1080}; + auto created = runtime->create_mouse(mouse_options); + ASSERT_TRUE(created) << created.status.message(); + EXPECT_TRUE(created.mouse->move_relative(1, -1).ok()); + EXPECT_TRUE(created.mouse->move_absolute(960, 540, 1920, 1080).ok()); + EXPECT_TRUE(created.mouse->move_absolute(0.5F, 0.25F, 1, 1).ok()); + EXPECT_TRUE(created.mouse->button(lvh::MouseButton::left, true).ok()); + EXPECT_TRUE(created.mouse->vertical_scroll(120).ok()); + EXPECT_TRUE(created.mouse->horizontal_scroll(-120).ok()); + EXPECT_TRUE(created.mouse->close().ok()); + EXPECT_EQ(created.mouse->move_relative(1, 1).code(), lvh::ErrorCode::device_closed); + + const auto has_message = [&diagnostics](lvh::LogLevel level, std::string_view text) { + return std::ranges::any_of(diagnostics, [level, text](const auto &diagnostic) { + return diagnostic.level == level && diagnostic.message.contains(text); + }); + }; + EXPECT_TRUE(has_message(lvh::LogLevel::info, "initialized fake backend")); + EXPECT_TRUE(has_message(lvh::LogLevel::info, "created mouse 1")); + EXPECT_TRUE(has_message(lvh::LogLevel::debug, "viewport=-1920,0 1920x1080")); + EXPECT_TRUE(has_message(lvh::LogLevel::error, "mouse input failed: mouse is closed")); +} + +TEST(RuntimeTest, DisablesConsumerDiagnosticsAfterException) { + struct ConsumerLoggerFailure {}; + + std::size_t callback_count = 0; + lvh::RuntimeOptions options; + options.log_callback = [&callback_count](lvh::LogLevel, const std::string &) { + ++callback_count; + throw ConsumerLoggerFailure {}; + }; + + EXPECT_NO_THROW({ + auto runtime = lvh::Runtime::create(options); + auto created = runtime->create_mouse(); + ASSERT_TRUE(created); + EXPECT_TRUE(created.mouse->move_relative(1, 1).ok()); + }); + EXPECT_EQ(callback_count, 1U); +} + TEST(RuntimeTest, PlatformDefaultReportsCurrentPlatformCapabilities) { lvh::RuntimeOptions options; options.backend = lvh::BackendKind::platform_default; @@ -273,6 +338,29 @@ TEST(RuntimeTest, CreatesSubmitsAndClosesMouse) { EXPECT_EQ(created.mouse->move_relative(1, 1).code(), lvh::ErrorCode::device_closed); } +TEST(RuntimeTest, RejectsIncompleteMouseViewport) { + auto runtime = lvh::Runtime::create(); + const auto expect_invalid = [&runtime](lvh::PointerViewport desktop, lvh::PointerViewport viewport) { + lvh::CreateMouseOptions options; + options.profile = lvh::profiles::mouse(); + options.desktop = desktop; + options.viewport = viewport; + + const auto created = runtime->create_mouse(options); + EXPECT_FALSE(created); + EXPECT_EQ(created.status.code(), lvh::ErrorCode::invalid_argument); + }; + + expect_invalid({}, {.width = 1920}); + expect_invalid({}, {.width = 1920, .height = 1080}); + expect_invalid({.width = 3840, .height = 1080}, {}); + expect_invalid({.width = -1, .height = 1080}, {}); + expect_invalid( + {.offset_x = -1920, .width = 3840, .height = 1080}, + {.offset_x = 0, .offset_y = 1080, .width = 1920, .height = 1080} + ); +} + TEST(RuntimeTest, CreatesSubmitsAndClosesTouchDevices) { auto runtime = lvh::Runtime::create(); diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp index c6c6f95b..2af5454a 100644 --- a/tests/unit/test_windows_backend.cpp +++ b/tests/unit/test_windows_backend.cpp @@ -449,3 +449,14 @@ TEST_F(WindowsBackendTest, SendInputDevicesTranslateKeyboardMouseFailuresAndUnsu EXPECT_EQ(result.sent_inputs[17].mouse_x, 1); EXPECT_EQ(result.sent_inputs[17].mouse_y, 1); } + +TEST_F(WindowsBackendTest, MapsAbsoluteMouseInputIntoConfiguredViewport) { + using lvh::detail::test::windows_backend_scale_absolute_axis_to_viewport; + + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(0.0F, 1920, 0, 1920, -1920, 3840), 32768); + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(960.0F, 1920, 0, 1920, -1920, 3840), 49143); + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(1920.0F, 1920, 0, 1920, -1920, 3840), 65518); + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(1.0F, 0, 0, 1920, -1920, 3840), 0); + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(1.0F, 1, 0, 0, 0, 1), 0); + EXPECT_EQ(windows_backend_scale_absolute_axis_to_viewport(1.0F, 1, 0, 1, 0, 0), 0); +}