diff --git a/cmake/compile_definitions/windows.cmake b/cmake/compile_definitions/windows.cmake index f255d38ab67..1c5b53f4edd 100644 --- a/cmake/compile_definitions/windows.cmake +++ b/cmake/compile_definitions/windows.cmake @@ -71,6 +71,7 @@ set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/windows/publish.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.h" "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/input_utils.h" "${CMAKE_SOURCE_DIR}/src/platform/windows/input.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/display.h" "${CMAKE_SOURCE_DIR}/src/platform/windows/display_base.cpp" diff --git a/docs/configuration.md b/docs/configuration.md index ed503973a4b..b0acf5f50df 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -688,6 +688,65 @@ editing the `conf` file in a text editor. Use the examples as reference. +### touch_send_to_primary_display + + + + + + + + + + + + + + +
Description + When enabled on Windows, Sunshine maps native touch input sent by the streaming client to the current + primary display instead of the display shown in the stream. Relative touch positions are preserved, and + physical display bounds are used so the mapping covers the full primary display even when display + resolutions or Windows scaling settings differ. +
+ This setting does not affect pen, absolute mouse, or relative mouse input. If Sunshine cannot determine + valid primary display dimensions, touch input falls back to the streamed display. +
Default@code{} + disabled + @endcode
Example@code{} + touch_send_to_primary_display = enabled + @endcode
+ +### touch_primary_display_rotation + + + + + + + + + + + + + + + + + + +
Description + On Windows, rotates native touch coordinates clockwise before mapping them to the primary display. + This option only applies when @code{touch_send_to_primary_display} is enabled and does not affect pen + or mouse input. Select the rotation that compensates for applications that interpret touchscreen + coordinates in a different display orientation. +
Allowed values@code{0}, @code{90}, @code{180}, or @code{270}
Default@code{} + 0 + @endcode
Example@code{} + touch_primary_display_rotation = 90 + @endcode
+ ### keybindings diff --git a/src/config.cpp b/src/config.cpp index 5392bca2ee0..4966f9f66f9 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -853,11 +853,14 @@ namespace config { true, // ds5_inputtino_randomize_mac true, // keyboard enabled + false, // map right Alt to the Windows key true, // mouse enabled true, // controller enabled true, // always send scancodes true, // high resolution scrolling true, // native pen/touch support + false, // send touch input to the primary display + "0", // primary display touch map rotation }; /** @@ -1802,6 +1805,13 @@ namespace config { bool_f(vars, "high_resolution_scrolling", input.high_resolution_scrolling); bool_f(vars, "native_pen_touch", input.native_pen_touch); + bool_f(vars, "touch_send_to_primary_display", input.touch_send_to_primary_display); + string_restricted_f( + vars, + "touch_primary_display_rotation", + input.touch_primary_display_rotation, + {"0"sv, "90"sv, "180"sv, "270"sv} + ); bool_f(vars, "notify_pre_releases", sunshine.notify_pre_releases); bool_f(vars, "system_tray", sunshine.system_tray); diff --git a/src/config.h b/src/config.h index 2795cf17958..6baf6cd8844 100644 --- a/src/config.h +++ b/src/config.h @@ -289,6 +289,8 @@ namespace config { bool high_resolution_scrolling; ///< Enable high-resolution mouse-wheel events. bool native_pen_touch; ///< Enable native pen and touch injection. + bool touch_send_to_primary_display {false}; ///< Map streaming-client touch input to the Windows primary display. + std::string touch_primary_display_rotation {"0"}; ///< Rotate primary-display touch coordinates clockwise. }; namespace flag { diff --git a/src/input.cpp b/src/input.cpp index 270409a3be2..1a6afb2036f 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -1086,28 +1086,43 @@ namespace input { input->gamepads[packet->controllerNumber].id = id; } - /** - * @brief Normalizes coordinates to monitor-local logical touch dimensions. - * @param touch_port The current touch port metadata. - * @param coords The in/out coordinate pair to normalize. - * @return The monitor-local touch port, or std::nullopt if dimensions are invalid. - */ - std::optional monitor_touch_port(const input::touch_port_t &touch_port, std::pair &coords) { - const float monitor_logical_w = (touch_port.width * touch_port.scalar_inv) / touch_port.scalar_tpcoords; - const float monitor_logical_h = (touch_port.height * touch_port.scalar_inv) / touch_port.scalar_tpcoords; - if (monitor_logical_w <= 0.0f || monitor_logical_h <= 0.0f) { + std::optional monitor_touch_port( + const input::touch_port_t &touch_port, + std::pair &coords, + bool use_effective_content + ) { + // client_to_touchport() has already subtracted the leading client padding from the coordinates. Remove padding + // from both sides of the encoded extent here so effective-content coordinates still normalize to 0.0-1.0. + const float frame_logical_w = (touch_port.width * touch_port.scalar_inv) / touch_port.scalar_tpcoords; + const float frame_logical_h = (touch_port.height * touch_port.scalar_inv) / touch_port.scalar_tpcoords; + const float content_width = touch_port.width - (2.0f * touch_port.client_offsetX); + const float content_height = touch_port.height - (2.0f * touch_port.client_offsetY); + const float monitor_logical_w = (content_width * touch_port.scalar_inv) / touch_port.scalar_tpcoords; + const float monitor_logical_h = (content_height * touch_port.scalar_inv) / touch_port.scalar_tpcoords; + const float normalization_width = use_effective_content ? monitor_logical_w : frame_logical_w; + const float normalization_height = use_effective_content ? monitor_logical_h : frame_logical_h; + if (!std::isfinite(normalization_width) || + !std::isfinite(normalization_height) || + normalization_width <= 0.0f || + normalization_height <= 0.0f) { BOOST_LOG(warning) << "Ignoring touch/pen input due to invalid logical touch dimensions"sv; return std::nullopt; } - coords.first = (coords.first - touch_port.offset_x) / monitor_logical_w; - coords.second = (coords.second - touch_port.offset_y) / monitor_logical_h; + coords.first = (coords.first - touch_port.offset_x) / normalization_width; + coords.second = (coords.second - touch_port.offset_y) / normalization_height; + if (use_effective_content) { + // client_to_touchport() maps encoded padding to the content edges. Keep those edge coordinates inside the + // normalized target so native touch cannot escape onto an adjacent display. + coords.first = std::clamp(coords.first, 0.0f, 1.0f); + coords.second = std::clamp(coords.second, 0.0f, 1.0f); + } return platf::touch_port_t { touch_port.offset_x, touch_port.offset_y, - static_cast(monitor_logical_w), - static_cast(monitor_logical_h) + static_cast(normalization_width), + static_cast(normalization_height) }; } @@ -1129,7 +1144,12 @@ namespace input { auto &touch_port = input->touch_port; - auto abs_port = monitor_touch_port(touch_port, *coords); +#ifdef _WIN32 + const bool use_effective_touch_content = config::input.touch_send_to_primary_display; +#else + constexpr bool use_effective_touch_content = false; +#endif + auto abs_port = monitor_touch_port(touch_port, *coords, use_effective_touch_content); if (!abs_port) { return; } @@ -1180,7 +1200,7 @@ namespace input { auto &touch_port = input->touch_port; - auto abs_port = monitor_touch_port(touch_port, *coords); + auto abs_port = monitor_touch_port(touch_port, *coords, false); if (!abs_port) { return; } diff --git a/src/input.h b/src/input.h index 42e2e680784..db8c306d33d 100644 --- a/src/input.h +++ b/src/input.h @@ -6,6 +6,7 @@ // standard includes #include +#include // local includes #include "platform/common.h" @@ -79,6 +80,22 @@ namespace input { } }; + /** + * @brief Normalize host coordinates against the encoded frame or its effective display content. + * @details When effective content is selected, symmetric letterbox or pillarbox padding is removed from the + * encoded extent and coordinates in that padding are clamped to the nearest content edge. + * + * @param touch_port Touch-port metadata for the active stream and captured display. + * @param coords Host-relative coordinates to normalize in place. + * @param use_effective_content Whether to exclude encoded letterbox or pillarbox regions from normalization. + * @return The selected logical touch port, or `std::nullopt` when dimensions are invalid. + */ + std::optional monitor_touch_port( + const touch_port_t &touch_port, + std::pair &coords, + bool use_effective_content + ); + /** * @brief Scale the ellipse axes according to the provided size. * @param val The major and minor axis pair. diff --git a/src/platform/windows/input.cpp b/src/platform/windows/input.cpp index c979feb84fb..f93ca3cb957 100644 --- a/src/platform/windows/input.cpp +++ b/src/platform/windows/input.cpp @@ -17,7 +17,10 @@ #include // standard includes +#include #include +#include +#include #include #include @@ -25,6 +28,7 @@ #include // local includes +#include "input_utils.h" #include "keylayout.h" #include "misc.h" #include "src/config.h" @@ -47,6 +51,234 @@ namespace platf { 65535 }; + /** + * @brief Windows pointer flags that are valid only for the frame in which they are injected. + */ + constexpr auto EDGE_TRIGGERED_POINTER_FLAGS = + POINTER_FLAG_DOWN | + POINTER_FLAG_UP | + POINTER_FLAG_CANCELED | + POINTER_FLAG_UPDATE; + + std::optional win_input::make_primary_display_touch_port(std::span displays) { + if (displays.empty()) { + return std::nullopt; + } + + const display_bounds_t *primary_display = nullptr; + auto virtual_origin_x = std::numeric_limits::max(); + auto virtual_origin_y = std::numeric_limits::max(); + + for (const auto &display : displays) { + if (display.width <= 0 || display.height <= 0) { + return std::nullopt; + } + + virtual_origin_x = std::min(virtual_origin_x, display.offset_x); + virtual_origin_y = std::min(virtual_origin_y, display.offset_y); + + if (display.is_primary) { + if (primary_display) { + return std::nullopt; + } + primary_display = &display; + } + } + + if (!primary_display) { + return std::nullopt; + } + + // Windows positions displays relative to the primary display and may report negative coordinates. Sunshine's + // touch ports are relative to the top-left of the virtual desktop, so translate to that nonnegative space. + const auto primary_offset_x = static_cast(primary_display->offset_x) - virtual_origin_x; + const auto primary_offset_y = static_cast(primary_display->offset_y) - virtual_origin_y; + if (primary_offset_x > std::numeric_limits::max() || primary_offset_y > std::numeric_limits::max()) { + return std::nullopt; + } + + return touch_port_t { + static_cast(primary_offset_x), + static_cast(primary_offset_y), + primary_display->width, + primary_display->height, + 0, + 0 + }; + } + + touch_port_t win_input::select_touch_port( + const touch_port_t &streamed_touch_port, + bool send_to_primary_display, + const std::optional &primary_touch_port + ) { + if (!send_to_primary_display || !primary_touch_port) { + return streamed_touch_port; + } + + return *primary_touch_port; + } + + win_input::touch_rotation_e win_input::parse_touch_rotation(std::string_view rotation) { + using enum touch_rotation_e; + + if (rotation == "90"sv) { + return clockwise_90; + } + if (rotation == "180"sv) { + return clockwise_180; + } + if (rotation == "270"sv) { + return clockwise_270; + } + return none; + } + + win_input::touch_rotation_e win_input::select_touch_rotation( + bool primary_display_selected, + std::string_view configured_rotation + ) { + if (!primary_display_selected) { + return touch_rotation_e::none; + } + + return parse_touch_rotation(configured_rotation); + } + + std::pair win_input::rotate_normalized_touch_position( + float normalized_x, + float normalized_y, + win_input::touch_rotation_e rotation + ) { + using enum touch_rotation_e; + + switch (rotation) { + case clockwise_90: + return {1.0f - normalized_y, normalized_x}; + case clockwise_180: + return {1.0f - normalized_x, 1.0f - normalized_y}; + case clockwise_270: + return {normalized_y, 1.0f - normalized_x}; + case none: + default: + return {normalized_x, normalized_y}; + } + } + + std::pair win_input::map_normalized_touch_position( + const touch_port_t &touch_port, + float normalized_x, + float normalized_y + ) { + const auto map_axis = [](float normalized_coordinate, int offset, int extent) { + if (extent <= 0) { + return offset; + } + + if (!std::isfinite(normalized_coordinate)) { + normalized_coordinate = 0.0f; + } + + normalized_coordinate = std::clamp(normalized_coordinate, 0.0f, 1.0f); + // A normalized coordinate of 1.0 is the far edge of this display. Select its final pixel rather than the first + // pixel of a neighboring display. + const auto pixel_offset = std::min(static_cast(normalized_coordinate * extent), extent - 1); + return offset + pixel_offset; + }; + + return std::pair { + map_axis(normalized_x, touch_port.offset_x, touch_port.width), + map_axis(normalized_y, touch_port.offset_y, touch_port.height) + }; + } + + std::uint32_t win_input::apply_touch_pointer_event_flags( + std::uint32_t pointer_flags, + std::uint8_t event_type, + bool designate_primary + ) { + switch (event_type) { + case LI_TOUCH_EVENT_DOWN: + case LI_TOUCH_EVENT_MOVE: + pointer_flags |= POINTER_FLAG_FIRSTBUTTON; + break; + case LI_TOUCH_EVENT_HOVER: + case LI_TOUCH_EVENT_UP: + case LI_TOUCH_EVENT_CANCEL: + case LI_TOUCH_EVENT_CANCEL_ALL: + case LI_TOUCH_EVENT_HOVER_LEAVE: + pointer_flags &= ~POINTER_FLAG_FIRSTBUTTON; + break; + default: + break; + } + + if (event_type == LI_TOUCH_EVENT_DOWN && designate_primary) { + pointer_flags |= POINTER_FLAG_PRIMARY; + } + + return pointer_flags; + } + + std::uint32_t win_input::finish_touch_pointer_frame(std::uint32_t pointer_flags) { + pointer_flags &= ~EDGE_TRIGGERED_POINTER_FLAGS; + if (!(pointer_flags & POINTER_FLAG_INCONTACT)) { + pointer_flags &= ~(POINTER_FLAG_FIRSTBUTTON | POINTER_FLAG_PRIMARY); + } + return pointer_flags; + } + + bool win_input::touch_pointer_blocks_primary_designation(std::uint32_t pointer_flags) { + return pointer_flags & (POINTER_FLAG_INCONTACT | POINTER_FLAG_PRIMARY); + } + + namespace { + /** + * @brief Query physical bounds for attached Windows displays and build the primary-display touch port. + * + * @return Primary-display touch port, or `std::nullopt` when the current topology cannot be queried. + */ + std::optional query_primary_display_touch_port() { + std::vector displays; + + for (DWORD display_index = 0;; ++display_index) { + DISPLAY_DEVICEW display_device {}; + display_device.cb = sizeof(display_device); + if (!EnumDisplayDevicesW(nullptr, display_index, &display_device, 0)) { + break; + } + + if (!(display_device.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) || + (display_device.StateFlags & DISPLAY_DEVICE_MIRRORING_DRIVER)) { + continue; + } + + DEVMODEW display_mode {}; + display_mode.dmSize = sizeof(display_mode); + if (!EnumDisplaySettingsExW(display_device.DeviceName, ENUM_CURRENT_SETTINGS, &display_mode, 0)) { + return std::nullopt; + } + + constexpr DWORD required_fields = DM_POSITION | DM_PELSWIDTH | DM_PELSHEIGHT; + if ((display_mode.dmFields & required_fields) != required_fields || + display_mode.dmPelsWidth > static_cast(std::numeric_limits::max()) || + display_mode.dmPelsHeight > static_cast(std::numeric_limits::max())) { + return std::nullopt; + } + + displays.push_back({ + static_cast(display_mode.dmPosition.x), + static_cast(display_mode.dmPosition.y), + static_cast(display_mode.dmPelsWidth), + static_cast(display_mode.dmPelsHeight), + (display_device.StateFlags & DISPLAY_DEVICE_PRIMARY_DEVICE) != 0 + }); + } + + return win_input::make_primary_display_touch_port(displays); + } + } // namespace + /** * @brief ViGEm client pointer released with `vigem_free`. */ @@ -766,6 +998,7 @@ namespace platf { POINTER_TYPE_INFO touchInfo[10] {}; ///< Touch info. UINT32 activeTouchSlots {}; ///< Active touch slots. thread_pool_util::ThreadPool::task_id_t touchRepeatTask {}; ///< Touch repeat task. + bool primaryTouchPortWarningLogged {}; ///< Whether invalid primary-display metrics have already been logged. }; /** @@ -845,36 +1078,29 @@ namespace platf { } /** - * @brief Populate common `POINTER_INFO` members shared between pen and touch events. - * @param pointerInfo The pointer info to populate. - * @param touchPort The current viewport for translating to screen coordinates. - * @param eventType The type of touch/pen event. - * @param x The normalized 0.0-1.0 X coordinate. - * @param y The normalized 0.0-1.0 Y coordinate. + * @brief Apply common pointer flags for a pen or touch event. + * + * @param pointerInfo Pointer state to update. + * @param eventType Moonlight touch or pen event type. + * @return `true` when the event also requires an updated pixel location. */ - void populate_common_pointer_info(POINTER_INFO &pointerInfo, const touch_port_t &touchPort, uint8_t eventType, float x, float y) { + [[nodiscard]] bool apply_common_pointer_event(POINTER_INFO &pointerInfo, uint8_t eventType) { switch (eventType) { case LI_TOUCH_EVENT_HOVER: pointerInfo.pointerFlags &= ~POINTER_FLAG_INCONTACT; pointerInfo.pointerFlags |= POINTER_FLAG_INRANGE | POINTER_FLAG_UPDATE; - pointerInfo.ptPixelLocation.x = x * touchPort.width + touchPort.offset_x; - pointerInfo.ptPixelLocation.y = y * touchPort.height + touchPort.offset_y; - break; + return true; case LI_TOUCH_EVENT_DOWN: pointerInfo.pointerFlags |= POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT | POINTER_FLAG_DOWN; - pointerInfo.ptPixelLocation.x = x * touchPort.width + touchPort.offset_x; - pointerInfo.ptPixelLocation.y = y * touchPort.height + touchPort.offset_y; - break; + return true; case LI_TOUCH_EVENT_UP: // We expect to get another LI_TOUCH_EVENT_HOVER if the pointer remains in range pointerInfo.pointerFlags &= ~(POINTER_FLAG_INCONTACT | POINTER_FLAG_INRANGE); pointerInfo.pointerFlags |= POINTER_FLAG_UP; - break; + return false; case LI_TOUCH_EVENT_MOVE: pointerInfo.pointerFlags |= POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT | POINTER_FLAG_UPDATE; - pointerInfo.ptPixelLocation.x = x * touchPort.width + touchPort.offset_x; - pointerInfo.ptPixelLocation.y = y * touchPort.height + touchPort.offset_y; - break; + return true; case LI_TOUCH_EVENT_CANCEL: case LI_TOUCH_EVENT_CANCEL_ALL: // If we were in contact with the touch surface at the time of the cancellation, @@ -886,20 +1112,20 @@ namespace platf { } pointerInfo.pointerFlags &= ~(POINTER_FLAG_INCONTACT | POINTER_FLAG_INRANGE); pointerInfo.pointerFlags |= POINTER_FLAG_CANCELED; - break; + return false; case LI_TOUCH_EVENT_HOVER_LEAVE: pointerInfo.pointerFlags &= ~(POINTER_FLAG_INCONTACT | POINTER_FLAG_INRANGE); pointerInfo.pointerFlags |= POINTER_FLAG_UPDATE; - break; + return false; case LI_TOUCH_EVENT_BUTTON_ONLY: // On Windows, we can only pass buttons if we have an active pointer if (pointerInfo.pointerFlags != POINTER_FLAG_NONE) { pointerInfo.pointerFlags |= POINTER_FLAG_UPDATE; } - break; + return false; default: BOOST_LOG(warning) << "Unknown touch event: "sv << (uint32_t) eventType; - break; + return false; } } @@ -951,7 +1177,15 @@ namespace platf { // If we have active slots, cancel them all if (raw->activeTouchSlots > 0) { for (UINT32 i = 0; i < raw->activeTouchSlots; i++) { - populate_common_pointer_info(raw->touchInfo[i].touchInfo.pointerInfo, {}, LI_TOUCH_EVENT_CANCEL_ALL, 0.0f, 0.0f); + static_cast(apply_common_pointer_event( + raw->touchInfo[i].touchInfo.pointerInfo, + LI_TOUCH_EVENT_CANCEL_ALL + )); + raw->touchInfo[i].touchInfo.pointerInfo.pointerFlags = win_input::apply_touch_pointer_event_flags( + raw->touchInfo[i].touchInfo.pointerInfo.pointerFlags, + LI_TOUCH_EVENT_CANCEL_ALL, + false + ); raw->touchInfo[i].touchInfo.touchMask = TOUCH_MASK_NONE; } if (!inject_synthetic_pointer_input(raw->global, raw->touch, raw->touchInfo, raw->activeTouchSlots)) { @@ -965,9 +1199,6 @@ namespace platf { raw->activeTouchSlots = 0; } - // These are edge-triggered pointer state flags that should always be cleared next frame - constexpr auto EDGE_TRIGGERED_POINTER_FLAGS = POINTER_FLAG_DOWN | POINTER_FLAG_UP | POINTER_FLAG_CANCELED | POINTER_FLAG_UPDATE; ///< Protocol or platform constant for edge triggered pointer flags. - /** * @brief Sends a touch event to the OS. * @param input The client-specific input context. @@ -1011,6 +1242,46 @@ namespace platf { return; } + const bool send_to_primary_display = config::input.touch_send_to_primary_display; + std::optional primary_touch_port; + if (send_to_primary_display) { + primary_touch_port = query_primary_display_touch_port(); + + if (!primary_touch_port && !raw->primaryTouchPortWarningLogged) { + BOOST_LOG(warning) + << "Unable to target the primary display for touch input due to an invalid physical display topology; "sv + << "using the streamed display instead"sv; + raw->primaryTouchPortWarningLogged = true; + } else if (primary_touch_port) { + raw->primaryTouchPortWarningLogged = false; + } + } else { + raw->primaryTouchPortWarningLogged = false; + } + + // Keep this selection local to native touch injection so pen and mouse input + // continue using the streamed display's touch port. + const auto selected_touch_port = win_input::select_touch_port( + touch_port, + send_to_primary_display, + primary_touch_port + ); + const auto touch_rotation = win_input::select_touch_rotation( + send_to_primary_display && primary_touch_port.has_value(), + config::input.touch_primary_display_rotation + ); + + bool designate_primary_touch = touch.eventType == LI_TOUCH_EVENT_DOWN; + if (designate_primary_touch) { + // Windows permits only one primary pointer during an active touch interaction. + for (const auto &active_pointer : raw->touchInfo) { + if (win_input::touch_pointer_blocks_primary_designation(active_pointer.touchInfo.pointerInfo.pointerFlags)) { + designate_primary_touch = false; + break; + } + } + } + // Find or allocate an entry for this touch pointer ID auto pointer = pointer_by_id(raw, touch.pointerId, touch.eventType); if (!pointer) { @@ -1024,8 +1295,20 @@ namespace platf { auto &touchInfo = pointer->touchInfo; touchInfo.pointerInfo.pointerType = PT_TOUCH; - // Populate shared pointer info fields - populate_common_pointer_info(touchInfo.pointerInfo, touch_port, touch.eventType, touch.x, touch.y); + // Apply shared pointer state and constrain native touch coordinates to the selected display. + if (apply_common_pointer_event(touchInfo.pointerInfo, touch.eventType)) { + const auto [normalized_x, normalized_y] = + win_input::rotate_normalized_touch_position(touch.x, touch.y, touch_rotation); + const auto [pixel_x, pixel_y] = + win_input::map_normalized_touch_position(selected_touch_port, normalized_x, normalized_y); + touchInfo.pointerInfo.ptPixelLocation.x = pixel_x; + touchInfo.pointerInfo.ptPixelLocation.y = pixel_y; + } + touchInfo.pointerInfo.pointerFlags = win_input::apply_touch_pointer_event_flags( + touchInfo.pointerInfo.pointerFlags, + touch.eventType, + designate_primary_touch + ); touchInfo.touchMask = TOUCH_MASK_NONE; @@ -1058,10 +1341,22 @@ namespace platf { float contactHeight = (std::sin(majorAxisAngle) * touch.contactAreaMajor) + (std::sin(minorAxisAngle) * touch.contactAreaMinor); // Convert into screen coordinates centered at the touch location and constrained by screen dimensions - touchInfo.rcContact.left = std::max(touch_port.offset_x, touchInfo.pointerInfo.ptPixelLocation.x - std::floor(contactWidth / 2)); - touchInfo.rcContact.right = std::min(touch_port.offset_x + touch_port.width, touchInfo.pointerInfo.ptPixelLocation.x + std::ceil(contactWidth / 2)); - touchInfo.rcContact.top = std::max(touch_port.offset_y, touchInfo.pointerInfo.ptPixelLocation.y - std::floor(contactHeight / 2)); - touchInfo.rcContact.bottom = std::min(touch_port.offset_y + touch_port.height, touchInfo.pointerInfo.ptPixelLocation.y + std::ceil(contactHeight / 2)); + touchInfo.rcContact.left = std::max( + selected_touch_port.offset_x, + touchInfo.pointerInfo.ptPixelLocation.x - std::floor(contactWidth / 2) + ); + touchInfo.rcContact.right = std::min( + selected_touch_port.offset_x + selected_touch_port.width, + touchInfo.pointerInfo.ptPixelLocation.x + std::ceil(contactWidth / 2) + ); + touchInfo.rcContact.top = std::max( + selected_touch_port.offset_y, + touchInfo.pointerInfo.ptPixelLocation.y - std::floor(contactHeight / 2) + ); + touchInfo.rcContact.bottom = std::min( + selected_touch_port.offset_y + selected_touch_port.height, + touchInfo.pointerInfo.ptPixelLocation.y + std::ceil(contactHeight / 2) + ); touchInfo.touchMask |= TOUCH_MASK_CONTACTAREA; } @@ -1084,7 +1379,7 @@ namespace platf { } // Clear pointer flags that should only remain set for one frame - touchInfo.pointerInfo.pointerFlags &= ~EDGE_TRIGGERED_POINTER_FLAGS; + touchInfo.pointerInfo.pointerFlags = win_input::finish_touch_pointer_frame(touchInfo.pointerInfo.pointerFlags); // If we still have an active touch, refresh the touch state periodically if (raw->activeTouchSlots > 1 || touchInfo.pointerInfo.pointerFlags != POINTER_FLAG_NONE) { @@ -1135,8 +1430,11 @@ namespace platf { penInfo.pointerInfo.pointerType = PT_PEN; penInfo.pointerInfo.pointerId = 0; - // Populate shared pointer info fields - populate_common_pointer_info(penInfo.pointerInfo, touch_port, pen.eventType, pen.x, pen.y); + // Apply shared pointer state while preserving the existing pen coordinate path. + if (apply_common_pointer_event(penInfo.pointerInfo, pen.eventType)) { + penInfo.pointerInfo.ptPixelLocation.x = pen.x * touch_port.width + touch_port.offset_x; + penInfo.pointerInfo.ptPixelLocation.y = pen.y * touch_port.height + touch_port.offset_y; + } // Windows only supports a single pen button, so send all buttons as the barrel button if (pen.penButtons) { diff --git a/src/platform/windows/input_utils.h b/src/platform/windows/input_utils.h new file mode 100644 index 00000000000..54765e93352 --- /dev/null +++ b/src/platform/windows/input_utils.h @@ -0,0 +1,141 @@ +/** + * @file src/platform/windows/input_utils.h + * @brief Helpers for selecting Windows touch input targets. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include + +// local includes +#include "src/platform/common.h" + +namespace platf::win_input { + /** + * @brief Clockwise rotation applied to normalized native-touch coordinates. + */ + enum class touch_rotation_e : std::uint16_t { + none = 0, ///< Preserve client touch coordinates. + clockwise_90 = 90, ///< Rotate coordinates 90 degrees clockwise. + clockwise_180 = 180, ///< Rotate coordinates 180 degrees clockwise. + clockwise_270 = 270 ///< Rotate coordinates 270 degrees clockwise. + }; + + /** + * @brief Physical bounds of an attached Windows display. + */ + struct display_bounds_t { + int offset_x; ///< Horizontal display offset in physical virtual-desktop coordinates. + int offset_y; ///< Vertical display offset in physical virtual-desktop coordinates. + int width; ///< Display width in physical pixels. + int height; ///< Display height in physical pixels. + bool is_primary; ///< Whether Windows marks this display as the primary display. + }; + + /** + * @brief Build a touch port targeting the Windows primary display. + * @details The returned offset is relative to the virtual desktop's top-left corner, matching Sunshine's + * nonnegative touch-port coordinate system even when Windows reports displays at negative coordinates. + * + * @param displays Physical bounds of the currently attached displays. + * @return Primary-display touch port, or `std::nullopt` when the display topology is invalid. + */ + std::optional make_primary_display_touch_port(std::span displays); + + /** + * @brief Select the touch port used for native Windows touch injection. + * + * @param streamed_touch_port Touch port associated with the streamed display. + * @param send_to_primary_display Whether native touch should target the primary display. + * @param primary_touch_port Available primary-display touch port, if valid. + * @return The selected touch port. + */ + touch_port_t select_touch_port( + const touch_port_t &streamed_touch_port, + bool send_to_primary_display, + const std::optional &primary_touch_port + ); + + /** + * @brief Parse a configured clockwise native-touch rotation. + * + * @param rotation Rotation expressed as `0`, `90`, `180`, or `270` degrees. + * @return Parsed rotation, or `touch_rotation_e::none` for an unsupported value. + */ + touch_rotation_e parse_touch_rotation(std::string_view rotation); + + /** + * @brief Select the native-touch rotation for the active target. + * + * @param primary_display_selected Whether a valid primary-display touch port was selected. + * @param configured_rotation Configured clockwise rotation in degrees. + * @return Configured rotation for a primary-display target, otherwise `touch_rotation_e::none`. + */ + touch_rotation_e select_touch_rotation(bool primary_display_selected, std::string_view configured_rotation); + + /** + * @brief Rotate normalized native-touch coordinates around the center of their target display. + * + * @param normalized_x Horizontal coordinate in normalized video coordinates. + * @param normalized_y Vertical coordinate in normalized video coordinates. + * @param rotation Clockwise rotation to apply before mapping to display pixels. + * @return Rotated normalized coordinates. + */ + std::pair rotate_normalized_touch_position( + float normalized_x, + float normalized_y, + touch_rotation_e rotation + ); + + /** + * @brief Map normalized native-touch coordinates to a pixel inside a Windows display. + * @details Coordinates outside the normalized content, including client-side black bars, are clamped to the + * nearest pixel of the selected display. + * + * @param touch_port Physical bounds of the selected touch target. + * @param normalized_x Horizontal coordinate in normalized video coordinates. + * @param normalized_y Vertical coordinate in normalized video coordinates. + * @return Pixel coordinates clamped to the selected display's inclusive bounds. + */ + std::pair map_normalized_touch_position( + const touch_port_t &touch_port, + float normalized_x, + float normalized_y + ); + + /** + * @brief Add Windows compatibility flags for a native touch event. + * @details Active contacts receive the mouse-compatible first-button flag. The first contact in an interaction + * also receives the Windows primary-pointer flag. + * + * @param pointer_flags Existing Windows pointer flags after applying the event state transition. + * @param event_type Moonlight touch event type. + * @param designate_primary Whether this contact starts a new primary touch interaction. + * @return Pointer flags to inject for the event. + */ + std::uint32_t apply_touch_pointer_event_flags( + std::uint32_t pointer_flags, + std::uint8_t event_type, + bool designate_primary + ); + + /** + * @brief Remove transient Windows touch flags after a frame is injected. + * + * @param pointer_flags Pointer flags used for the injected frame. + * @return Persistent flags to retain for subsequent touch frames. + */ + std::uint32_t finish_touch_pointer_frame(std::uint32_t pointer_flags); + + /** + * @brief Determine whether a touch pointer prevents a new primary contact from being designated. + * + * @param pointer_flags Current Windows pointer flags for a touch pointer. + * @return `true` when the pointer belongs to the current contact interaction. + */ + bool touch_pointer_blocks_primary_designation(std::uint32_t pointer_flags); +} // namespace platf::win_input diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index e62c61ed838..899f3f0e096 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -237,6 +237,8 @@

{{ $t('config.configuration') }}

"mouse": "enabled", "high_resolution_scrolling": "enabled", "native_pen_touch": "enabled", + "touch_send_to_primary_display": "disabled", + "touch_primary_display_rotation": "0", "keybindings": "[0x10,0xA0,0x11,0xA2,0x12,0xA4]", // todo: add this to UI }, }, diff --git a/src_assets/common/assets/web/configs/tabs/Inputs.vue b/src_assets/common/assets/web/configs/tabs/Inputs.vue index 7fa76a20721..555425269da 100644 --- a/src_assets/common/assets/web/configs/tabs/Inputs.vue +++ b/src_assets/common/assets/web/configs/tabs/Inputs.vue @@ -181,6 +181,28 @@ const config = ref(props.config) v-model="config.native_pen_touch" default="true" > + + + + + +
+ + +
{{ $t('config.touch_primary_display_rotation_desc') }}
+
diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 1c0b949c6cc..f43a2afa968 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -394,6 +394,14 @@ "sw_tune_zerolatency": "zerolatency -- good for fast encoding and low-latency streaming (default)", "system_tray": "Enable system tray", "system_tray_desc": "Show icon in system tray and display desktop notifications", + "touch_send_to_primary_display": "Send Touch Input to the Primary Display", + "touch_send_to_primary_display_desc": "When enabled on Windows, Sunshine maps native touch input sent by the streaming client to the current primary display instead of the display shown in the stream. Relative touch positions are preserved, and pen and mouse input are unaffected.", + "touch_primary_display_rotation": "Primary Display Touch Map Rotation", + "touch_primary_display_rotation_0": "No rotation", + "touch_primary_display_rotation_90": "90° clockwise", + "touch_primary_display_rotation_180": "180° clockwise", + "touch_primary_display_rotation_270": "270° clockwise", + "touch_primary_display_rotation_desc": "Rotates native touch coordinates clockwise before mapping them to the primary display. Select the rotation that matches the display orientation expected by the application. Pen and mouse input are unaffected.", "touchpad_as_ds4": "Emulate a DS4 gamepad if the client gamepad reports a touchpad is present", "touchpad_as_ds4_desc": "If disabled, touchpad presence will not be taken into account during gamepad type selection.", "upnp": "UPnP", diff --git a/tests/unit/platform/windows/test_touch_target.cpp b/tests/unit/platform/windows/test_touch_target.cpp new file mode 100644 index 00000000000..a7fd45064b7 --- /dev/null +++ b/tests/unit/platform/windows/test_touch_target.cpp @@ -0,0 +1,381 @@ +/** + * @file tests/unit/platform/windows/test_touch_target.cpp + * @brief Test Windows touch target selection. + */ +#include "../../../tests_common.h" + +#ifdef _WIN32 + + // platform includes + #include + + // standard includes + #include + #include + + // local includes + #include "src/platform/windows/input_utils.h" + +namespace { + /** + * @brief Verify that two touch ports contain identical bounds. + * + * @param actual Actual touch port. + * @param expected Expected touch port. + */ + void expect_touch_ports_equal(const platf::touch_port_t &actual, const platf::touch_port_t &expected) { + EXPECT_EQ(actual.offset_x, expected.offset_x); + EXPECT_EQ(actual.offset_y, expected.offset_y); + EXPECT_EQ(actual.width, expected.width); + EXPECT_EQ(actual.height, expected.height); + EXPECT_EQ(actual.logical_width, expected.logical_width); + EXPECT_EQ(actual.logical_height, expected.logical_height); + } +} // namespace + +TEST(WindowsTouchTargetTest, DisabledUsesStreamedTouchPort) { + const platf::touch_port_t streamed_touch_port {640, 360, 2560, 1440, 1280, 720}; + const std::array displays { + platf::win_input::display_bounds_t {0, 0, 1920, 1080, true} + }; + const auto primary_touch_port = platf::win_input::make_primary_display_touch_port(displays); + + const auto selected_touch_port = platf::win_input::select_touch_port(streamed_touch_port, false, primary_touch_port); + + expect_touch_ports_equal(selected_touch_port, streamed_touch_port); +} + +TEST(WindowsTouchTargetTest, SingleDisplayUsesPrimaryDisplayBounds) { + const platf::touch_port_t streamed_touch_port {0, 0, 2560, 1440, 0, 0}; + const std::array displays { + platf::win_input::display_bounds_t {0, 0, 1920, 1080, true} + }; + const auto primary_touch_port = platf::win_input::make_primary_display_touch_port(displays); + ASSERT_TRUE(primary_touch_port); + + const auto selected_touch_port = platf::win_input::select_touch_port(streamed_touch_port, true, primary_touch_port); + + expect_touch_ports_equal(selected_touch_port, {0, 0, 1920, 1080, 0, 0}); +} + +TEST(WindowsTouchTargetTest, VirtualDesktopOriginOffsetsPrimaryDisplay) { + const std::array displays { + platf::win_input::display_bounds_t {-2560, -1440, 2560, 1440, false}, + platf::win_input::display_bounds_t {0, 0, 1920, 1080, true} + }; + const auto primary_touch_port = platf::win_input::make_primary_display_touch_port(displays); + ASSERT_TRUE(primary_touch_port); + + expect_touch_ports_equal(*primary_touch_port, {2560, 1440, 1920, 1080, 0, 0}); +} + +TEST(WindowsTouchTargetTest, DifferentResolutionPreservesRelativePosition) { + constexpr float normalized_x = 0.5f; + constexpr float normalized_y = 0.5f; + const platf::touch_port_t streamed_touch_port {3840, 0, 1280, 768, 0, 0}; + const std::array displays { + platf::win_input::display_bounds_t {0, 0, 3840, 2160, true}, + platf::win_input::display_bounds_t {3840, 0, 1280, 768, false} + }; + const auto primary_touch_port = platf::win_input::make_primary_display_touch_port(displays); + + const auto selected_touch_port = platf::win_input::select_touch_port(streamed_touch_port, true, primary_touch_port); + const auto [pixel_x, pixel_y] = platf::win_input::map_normalized_touch_position( + selected_touch_port, + normalized_x, + normalized_y + ); + + EXPECT_EQ(pixel_x, 1920); + EXPECT_EQ(pixel_y, 1080); +} + +TEST(WindowsTouchTargetTest, BlackBarTouchStaysInsideSelectedDisplay) { + constexpr platf::touch_port_t selected_touch_port {2560, 1440, 1920, 1080, 0, 0}; + + const auto [left, top] = platf::win_input::map_normalized_touch_position(selected_touch_port, -0.25f, -0.25f); + const auto [right, bottom] = platf::win_input::map_normalized_touch_position(selected_touch_port, 1.25f, 1.25f); + + EXPECT_EQ(left, 2560); + EXPECT_EQ(top, 1440); + EXPECT_EQ(right, 4479); + EXPECT_EQ(bottom, 2519); +} + +TEST(WindowsTouchTargetTest, ExactVideoEdgeDoesNotReachAdjacentDesktop) { + constexpr platf::touch_port_t selected_touch_port {0, 0, 3840, 2160, 0, 0}; + + const auto [right, bottom] = platf::win_input::map_normalized_touch_position(selected_touch_port, 1.0f, 1.0f); + + EXPECT_EQ(right, 3839); + EXPECT_EQ(bottom, 2159); +} + +TEST(WindowsTouchRotationTest, ParsesSupportedClockwiseRotations) { + EXPECT_EQ(platf::win_input::parse_touch_rotation("0"), platf::win_input::touch_rotation_e::none); + EXPECT_EQ(platf::win_input::parse_touch_rotation("90"), platf::win_input::touch_rotation_e::clockwise_90); + EXPECT_EQ(platf::win_input::parse_touch_rotation("180"), platf::win_input::touch_rotation_e::clockwise_180); + EXPECT_EQ(platf::win_input::parse_touch_rotation("270"), platf::win_input::touch_rotation_e::clockwise_270); + EXPECT_EQ(platf::win_input::parse_touch_rotation("invalid"), platf::win_input::touch_rotation_e::none); +} + +TEST(WindowsTouchRotationTest, RotationRequiresASelectedPrimaryDisplay) { + EXPECT_EQ( + platf::win_input::select_touch_rotation(false, "90"), + platf::win_input::touch_rotation_e::none + ); + EXPECT_EQ( + platf::win_input::select_touch_rotation(true, "90"), + platf::win_input::touch_rotation_e::clockwise_90 + ); +} + +TEST(WindowsTouchRotationTest, Clockwise90MapsAllDisplayCorners) { + constexpr platf::touch_port_t primary_touch_port {0, 0, 1920, 1080, 0, 0}; + constexpr auto rotation = platf::win_input::touch_rotation_e::clockwise_90; + + const auto map_rotated_corner = [&](float x, float y) { + const auto [rotated_x, rotated_y] = platf::win_input::rotate_normalized_touch_position(x, y, rotation); + return platf::win_input::map_normalized_touch_position(primary_touch_port, rotated_x, rotated_y); + }; + + EXPECT_EQ(map_rotated_corner(0.0f, 0.0f), std::pair(1919, 0)); + EXPECT_EQ(map_rotated_corner(0.0f, 1.0f), std::pair(0, 0)); + EXPECT_EQ(map_rotated_corner(1.0f, 0.0f), std::pair(1919, 1079)); + EXPECT_EQ(map_rotated_corner(1.0f, 1.0f), std::pair(0, 1079)); +} + +TEST(WindowsTouchRotationTest, Clockwise90CompensatesForCounterclockwiseApplicationTransform) { + constexpr float client_x = 0.2f; + constexpr float client_y = 0.7f; + + const auto [injected_x, injected_y] = platf::win_input::rotate_normalized_touch_position( + client_x, + client_y, + platf::win_input::touch_rotation_e::clockwise_90 + ); + + // An application applying (y, 1-x) receives the original point after Sunshine applies the inverse rotation. + const auto application_x = injected_y; + const auto application_y = 1.0f - injected_x; + EXPECT_FLOAT_EQ(application_x, client_x); + EXPECT_FLOAT_EQ(application_y, client_y); +} + +TEST(WindowsTouchRotationTest, OtherRotationsTransformNormalizedCoordinates) { + const auto unchanged = platf::win_input::rotate_normalized_touch_position( + 0.25f, + 0.75f, + platf::win_input::touch_rotation_e::none + ); + const auto clockwise_180 = platf::win_input::rotate_normalized_touch_position( + 0.25f, + 0.75f, + platf::win_input::touch_rotation_e::clockwise_180 + ); + const auto clockwise_270 = platf::win_input::rotate_normalized_touch_position( + 0.25f, + 0.75f, + platf::win_input::touch_rotation_e::clockwise_270 + ); + const auto unsupported = platf::win_input::rotate_normalized_touch_position( + 0.25f, + 0.75f, + static_cast(45) + ); + + EXPECT_EQ(unchanged, std::pair(0.25f, 0.75f)); + EXPECT_EQ(clockwise_180, std::pair(0.75f, 0.25f)); + EXPECT_EQ(clockwise_270, std::pair(0.75f, 0.75f)); + EXPECT_EQ(unsupported, unchanged); +} + +TEST(WindowsTouchTargetTest, InvalidExtentAndNonFiniteCoordinatesStayAtDisplayOrigin) { + constexpr platf::touch_port_t invalid_touch_port {640, 360, 0, -1, 0, 0}; + constexpr platf::touch_port_t valid_touch_port {640, 360, 1280, 768, 0, 0}; + + const auto [invalid_x, invalid_y] = platf::win_input::map_normalized_touch_position(invalid_touch_port, 0.5f, 0.5f); + const auto [non_finite_x, non_finite_y] = platf::win_input::map_normalized_touch_position( + valid_touch_port, + std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity() + ); + + EXPECT_EQ(invalid_x, 640); + EXPECT_EQ(invalid_y, 360); + EXPECT_EQ(non_finite_x, 640); + EXPECT_EQ(non_finite_y, 360); +} + +TEST(WindowsTouchTargetTest, PrimarySelectionDoesNotMutateStreamedPortUsedByPenAndMouse) { + platf::touch_port_t streamed_touch_port {640, 360, 2560, 1440, 1280, 720}; + const platf::touch_port_t original_streamed_touch_port = streamed_touch_port; + const std::array displays { + platf::win_input::display_bounds_t {-640, -360, 640, 360, false}, + platf::win_input::display_bounds_t {0, 0, 1920, 1080, true} + }; + const auto primary_touch_port = platf::win_input::make_primary_display_touch_port(displays); + + static_cast(platf::win_input::select_touch_port(streamed_touch_port, true, primary_touch_port)); + + expect_touch_ports_equal(streamed_touch_port, original_streamed_touch_port); +} + +TEST(WindowsTouchTargetTest, InvalidPrimaryDisplayWidthFallsBackToStreamedDisplay) { + const platf::touch_port_t streamed_touch_port {640, 0, 2560, 1440, 0, 0}; + const std::array displays { + platf::win_input::display_bounds_t {0, 0, 0, 1080, true} + }; + const auto primary_touch_port = platf::win_input::make_primary_display_touch_port(displays); + ASSERT_FALSE(primary_touch_port); + + const auto selected_touch_port = platf::win_input::select_touch_port(streamed_touch_port, true, primary_touch_port); + + expect_touch_ports_equal(selected_touch_port, streamed_touch_port); +} + +TEST(WindowsTouchTargetTest, InvalidPrimaryDisplayHeightFallsBackToStreamedDisplay) { + const platf::touch_port_t streamed_touch_port {0, 720, 1920, 1080, 0, 0}; + const std::array displays { + platf::win_input::display_bounds_t {0, 0, 1920, -1, true} + }; + const auto primary_touch_port = platf::win_input::make_primary_display_touch_port(displays); + ASSERT_FALSE(primary_touch_port); + + const auto selected_touch_port = platf::win_input::select_touch_port(streamed_touch_port, true, primary_touch_port); + + expect_touch_ports_equal(selected_touch_port, streamed_touch_port); +} + +TEST(WindowsTouchTargetTest, MissingPrimaryDisplayFallsBackToStreamedDisplay) { + const platf::touch_port_t streamed_touch_port {0, 0, 1280, 768, 0, 0}; + const std::array displays { + platf::win_input::display_bounds_t {0, 0, 1280, 768, false} + }; + const auto primary_touch_port = platf::win_input::make_primary_display_touch_port(displays); + ASSERT_FALSE(primary_touch_port); + + const auto selected_touch_port = platf::win_input::select_touch_port(streamed_touch_port, true, primary_touch_port); + + expect_touch_ports_equal(selected_touch_port, streamed_touch_port); +} + +TEST(WindowsTouchTargetTest, EmptyDisplayLayoutIsInvalid) { + const std::array displays {}; + + EXPECT_FALSE(platf::win_input::make_primary_display_touch_port(displays)); +} + +TEST(WindowsTouchTargetTest, MultiplePrimaryDisplaysAreInvalid) { + const std::array displays { + platf::win_input::display_bounds_t {0, 0, 3840, 2160, true}, + platf::win_input::display_bounds_t {3840, 0, 1280, 768, true} + }; + + EXPECT_FALSE(platf::win_input::make_primary_display_touch_port(displays)); +} + +TEST(WindowsTouchTargetTest, UnrepresentablePrimaryDisplayOffsetIsInvalid) { + const std::array displays { + platf::win_input::display_bounds_t {std::numeric_limits::min(), 0, 1280, 768, false}, + platf::win_input::display_bounds_t {std::numeric_limits::max(), 0, 3840, 2160, true} + }; + + EXPECT_FALSE(platf::win_input::make_primary_display_touch_port(displays)); +} + +TEST(WindowsTouchPointerFlagsTest, FirstContactIsPrimaryAndMouseCompatible) { + constexpr std::uint32_t event_flags = POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT | POINTER_FLAG_DOWN; + + const auto injected_flags = platf::win_input::apply_touch_pointer_event_flags(event_flags, LI_TOUCH_EVENT_DOWN, true); + + EXPECT_TRUE(injected_flags & POINTER_FLAG_PRIMARY); + EXPECT_TRUE(injected_flags & POINTER_FLAG_FIRSTBUTTON); + EXPECT_TRUE(platf::win_input::touch_pointer_blocks_primary_designation(injected_flags)); + + const auto persistent_flags = platf::win_input::finish_touch_pointer_frame(injected_flags); + EXPECT_FALSE(persistent_flags & POINTER_FLAG_DOWN); + EXPECT_TRUE(persistent_flags & POINTER_FLAG_PRIMARY); + EXPECT_TRUE(persistent_flags & POINTER_FLAG_FIRSTBUTTON); +} + +TEST(WindowsTouchPointerFlagsTest, AdditionalContactIsNotPrimary) { + constexpr std::uint32_t event_flags = POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT | POINTER_FLAG_DOWN; + + const auto injected_flags = platf::win_input::apply_touch_pointer_event_flags( + event_flags, + LI_TOUCH_EVENT_DOWN, + false + ); + + EXPECT_FALSE(injected_flags & POINTER_FLAG_PRIMARY); + EXPECT_TRUE(injected_flags & POINTER_FLAG_FIRSTBUTTON); +} + +TEST(WindowsTouchPointerFlagsTest, ContactMoveRetainsMouseCompatibility) { + constexpr std::uint32_t event_flags = + POINTER_FLAG_INRANGE | + POINTER_FLAG_INCONTACT | + POINTER_FLAG_UPDATE | + POINTER_FLAG_PRIMARY; + + const auto injected_flags = platf::win_input::apply_touch_pointer_event_flags( + event_flags, + LI_TOUCH_EVENT_MOVE, + false + ); + const auto persistent_flags = platf::win_input::finish_touch_pointer_frame(injected_flags); + + EXPECT_FALSE(persistent_flags & POINTER_FLAG_UPDATE); + EXPECT_TRUE(persistent_flags & POINTER_FLAG_PRIMARY); + EXPECT_TRUE(persistent_flags & POINTER_FLAG_FIRSTBUTTON); +} + +TEST(WindowsTouchPointerFlagsTest, PrimaryReleaseIsInjectedBeforeDesignationIsCleared) { + constexpr std::uint32_t event_flags = POINTER_FLAG_UP | POINTER_FLAG_FIRSTBUTTON | POINTER_FLAG_PRIMARY; + + const auto injected_flags = platf::win_input::apply_touch_pointer_event_flags(event_flags, LI_TOUCH_EVENT_UP, false); + + EXPECT_TRUE(injected_flags & POINTER_FLAG_UP); + EXPECT_TRUE(injected_flags & POINTER_FLAG_PRIMARY); + EXPECT_FALSE(injected_flags & POINTER_FLAG_FIRSTBUTTON); + EXPECT_TRUE(platf::win_input::touch_pointer_blocks_primary_designation(injected_flags)); + + const auto persistent_flags = platf::win_input::finish_touch_pointer_frame(injected_flags); + EXPECT_EQ(persistent_flags, POINTER_FLAG_NONE); + EXPECT_FALSE(platf::win_input::touch_pointer_blocks_primary_designation(persistent_flags)); +} + +TEST(WindowsTouchPointerFlagsTest, NonContactEventsClearMouseButtonCompatibility) { + constexpr std::array event_types { + LI_TOUCH_EVENT_HOVER, + LI_TOUCH_EVENT_CANCEL, + LI_TOUCH_EVENT_CANCEL_ALL, + LI_TOUCH_EVENT_HOVER_LEAVE + }; + + for (const auto event_type : event_types) { + constexpr std::uint32_t event_flags = POINTER_FLAG_UPDATE | POINTER_FLAG_FIRSTBUTTON; + const auto injected_flags = platf::win_input::apply_touch_pointer_event_flags(event_flags, event_type, false); + EXPECT_FALSE(injected_flags & POINTER_FLAG_FIRSTBUTTON); + } +} + +TEST(WindowsTouchPointerFlagsTest, UnrelatedEventDoesNotChangeCompatibilityFlags) { + constexpr std::uint32_t event_flags = POINTER_FLAG_INRANGE; + + EXPECT_EQ( + platf::win_input::apply_touch_pointer_event_flags(event_flags, LI_TOUCH_EVENT_BUTTON_ONLY, true), + event_flags + ); + EXPECT_EQ( + platf::win_input::apply_touch_pointer_event_flags( + event_flags, + std::numeric_limits::max(), + true + ), + event_flags + ); +} + +#endif // _WIN32 diff --git a/tests/unit/test_config_defaults.cpp b/tests/unit/test_config_defaults.cpp new file mode 100644 index 00000000000..338901cb563 --- /dev/null +++ b/tests/unit/test_config_defaults.cpp @@ -0,0 +1,20 @@ +/** + * @file tests/unit/test_config_defaults.cpp + * @brief Test default configuration values. + */ +#include "../tests_common.h" + +// local includes +#include "src/config.h" + +TEST(ConfigDefaultsTest, InputDefaultsKeepNativeTouchEnabled) { + EXPECT_TRUE(config::input.keyboard); + EXPECT_FALSE(config::input.key_rightalt_to_key_win); + EXPECT_TRUE(config::input.mouse); + EXPECT_TRUE(config::input.controller); + EXPECT_TRUE(config::input.always_send_scancodes); + EXPECT_TRUE(config::input.high_resolution_scrolling); + EXPECT_TRUE(config::input.native_pen_touch); + EXPECT_FALSE(config::input.touch_send_to_primary_display); + EXPECT_EQ(config::input.touch_primary_display_rotation, "0"); +} diff --git a/tests/unit/test_input.cpp b/tests/unit/test_input.cpp new file mode 100644 index 00000000000..3caafe43a86 --- /dev/null +++ b/tests/unit/test_input.cpp @@ -0,0 +1,136 @@ +/** + * @file tests/unit/test_input.cpp + * @brief Test input coordinate normalization helpers. + */ + +// test includes +#include "../tests_common.h" + +// local includes +#include "src/input.h" + +TEST(InputTouchPortTest, EncodedPillarboxUsesStreamedDisplayResolution) { + const input::touch_port_t touch_port { + {3840, 0, 1920, 1080}, + 5120, + 2160, + 60.0f, + 0.0f, + 32.0f / 45.0f, + 1.0f, + 0, + 0 + }; + std::pair coords {5120.0f, 768.0f}; + + const auto normalized_port = input::monitor_touch_port(touch_port, coords, true); + + ASSERT_TRUE(normalized_port); + EXPECT_EQ(normalized_port->width, 1280); + EXPECT_EQ(normalized_port->height, 768); + EXPECT_FLOAT_EQ(coords.first, 1.0f); + EXPECT_FLOAT_EQ(coords.second, 1.0f); +} + +TEST(InputTouchPortTest, EncodedLetterboxCoversBottomOfPrimaryDisplay) { + const input::touch_port_t touch_port { + {1280, 0, 1280, 1024}, + 2560, + 1024, + 0.0f, + 128.0f, + 1.0f, + 1.0f, + 0, + 0 + }; + std::pair coords {640.0f + touch_port.offset_x, 768.0f + touch_port.offset_y}; + + const auto normalized_port = input::monitor_touch_port(touch_port, coords, true); + + ASSERT_TRUE(normalized_port); + EXPECT_EQ(normalized_port->width, 1280); + EXPECT_EQ(normalized_port->height, 768); + EXPECT_FLOAT_EQ(coords.first, 0.5f); + EXPECT_FLOAT_EQ(coords.second, 1.0f); + + constexpr platf::touch_port_t primary_display {0, 0, 3840, 2160}; + EXPECT_FLOAT_EQ(coords.first * primary_display.width, 1920.0f); + EXPECT_FLOAT_EQ(coords.second * primary_display.height, 2160.0f); +} + +TEST(InputTouchPortTest, NormalizedCoordinatesAreClampedToDisplayBounds) { + const input::touch_port_t touch_port { + {0, 0, 1280, 768}, + 1280, + 768, + 0.0f, + 0.0f, + 1.0f, + 1.0f, + 0, + 0 + }; + std::pair coords {-1.0f, 769.0f}; + + ASSERT_TRUE(input::monitor_touch_port(touch_port, coords, true)); + EXPECT_FLOAT_EQ(coords.first, 0.0f); + EXPECT_FLOAT_EQ(coords.second, 1.0f); +} + +TEST(InputTouchPortTest, InvalidEffectiveContentDimensionsAreRejected) { + const input::touch_port_t touch_port { + {0, 0, 1280, 768}, + 1280, + 768, + 640.0f, + 0.0f, + 1.0f, + 1.0f, + 0, + 0 + }; + std::pair coords {0.0f, 0.0f}; + + EXPECT_FALSE(input::monitor_touch_port(touch_port, coords, true)); +} + +TEST(InputTouchPortTest, InvalidCoordinateScaleIsRejected) { + const input::touch_port_t touch_port { + {0, 0, 1280, 768}, + 1280, + 768, + 0.0f, + 0.0f, + 1.0f, + 0.0f, + 0, + 0 + }; + std::pair coords {0.0f, 0.0f}; + + EXPECT_FALSE(input::monitor_touch_port(touch_port, coords, false)); +} + +TEST(InputTouchPortTest, EncodedFrameNormalizationIsPreservedWhenPrimaryMappingIsDisabled) { + const input::touch_port_t touch_port { + {1280, 0, 1280, 1024}, + 2560, + 1024, + 0.0f, + 128.0f, + 1.0f, + 1.0f, + 0, + 0 + }; + std::pair coords {640.0f + touch_port.offset_x, 768.0f + touch_port.offset_y}; + + const auto normalized_port = input::monitor_touch_port(touch_port, coords, false); + + ASSERT_TRUE(normalized_port); + EXPECT_EQ(normalized_port->width, 1280); + EXPECT_EQ(normalized_port->height, 1024); + EXPECT_FLOAT_EQ(coords.first, 0.5f); + EXPECT_FLOAT_EQ(coords.second, 0.75f); +}