diff --git a/docs/configuration.md b/docs/configuration.md index d55dc380f82..efbaf560d93 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -691,6 +691,35 @@ editing the `conf` file in a text editor. Use the examples as reference. +### absolute_mouse_as_relative + + + + + + + + + + + + + + +
Description + When enabled, absolute mouse positions from Moonlight clients are converted to relative motion. +
+ The first event still places the cursor absolutely; subsequent events are emitted as relative deltas, + using the cursor position captured from the KMS cursor plane (when available) to stay in sync. +
+ This can be useful on compositors whose assistive features (e.g. screen magnifiers) only track relative + pointer motion. +
Default@code{} + disabled + @endcode
Example@code{} + absolute_mouse_as_relative = enabled + @endcode
+ ### keybindings diff --git a/src/config.cpp b/src/config.cpp index de3979ee4a9..53c1c8b85db 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -861,6 +861,7 @@ namespace config { true, // always send scancodes true, // high resolution scrolling true, // native pen/touch support + false, // absolute mouse as relative (opt-in) }; /** @@ -1803,6 +1804,7 @@ 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, "absolute_mouse_as_relative", input.absolute_mouse_as_relative); 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 adc4e84219a..f92998b7c5e 100644 --- a/src/config.h +++ b/src/config.h @@ -290,6 +290,7 @@ namespace config { bool high_resolution_scrolling; ///< Enable high-resolution mouse-wheel events. bool native_pen_touch; ///< Enable native pen and touch injection. + bool absolute_mouse_as_relative; ///< Emulate absolute client mouse input as relative motion (for compositors whose assistive features only track relative motion). }; namespace flag { diff --git a/src/input.cpp b/src/input.cpp index bac39561448..8af25e19a50 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -42,6 +42,14 @@ constexpr int WHEEL_DELTA = 120; ///< Standard Windows wheel delta used to norm using namespace std::literals; +namespace platf { + kms_cursor_feedback_t& + kms_cursor_feedback() { + static kms_cursor_feedback_t fb; + return fb; + } +} + namespace input { constexpr auto MAX_GAMEPADS = std::min((std::size_t) platf::MAX_GAMEPADS, sizeof(std::int16_t) * 8); ///< Maximum gamepads representable by the active gamepad mask. @@ -271,6 +279,20 @@ namespace input { input::touch_port_t touch_port; ///< Touch coordinate bounds for the current stream. + /// State of the absolute->relative mouse conversion (absolute_mouse_as_relative). + struct abs_mouse_t { + bool initialized = false; ///< Whether the absolute baseline is set. + float frac_x = 0; ///< Accumulated fractional X delta. + float frac_y = 0; ///< Accumulated fractional Y delta. + float host_x = 0; ///< Dead-reckoning estimate of the host cursor X, touch-port pixels. + float host_y = 0; ///< Dead-reckoning estimate of the host cursor Y, touch-port pixels. + std::uint64_t seq_last = 0; ///< Cursor-feedback sequence seen by the previous event. + float last_raw_x = 0; ///< Previous raw client X (idle detection). + float last_raw_y = 0; ///< Previous raw client Y (idle detection). + std::chrono::steady_clock::time_point last_client_move {}; ///< Last time the client coordinates changed. + }; + abs_mouse_t abs_mouse; ///< Absolute->relative mouse conversion state. + int32_t accumulated_vscroll_delta; ///< Accumulated vscroll delta. int32_t accumulated_hscroll_delta; ///< Accumulated hscroll delta. }; @@ -778,6 +800,149 @@ namespace input { return {multiply_polar_by_cartesian_scalar(major, angle, scalar), multiply_polar_by_cartesian_scalar(minor, angle + (M_PI / 2), scalar)}; } + /// Client mouse event data for the absolute->relative conversion. + struct abs_mouse_event_t { + std::pair tpcoords; ///< Client coordinates mapped to touch-port pixels. + float x; ///< Raw client X on the client surface. + float y; ///< Raw client Y on the client surface. + float width; ///< Client surface width. + float height; ///< Client surface height. + }; + + /** + * @brief Resync the dead-reckoning estimate with the real cursor position. + * + * The KMS capture path publishes the cursor-plane position once per captured + * frame. The feedback is only consumed when trustworthy: while the client is + * idle (every in-flight move has landed), or on an axis saturated at the + * client's own surface edge (the estimate may be mis-anchored there, and any + * feedback lag only overshoots toward the edge, where the compositor clamps). + * + * @param input The input context. + * @param client_idle Whether the client coordinates have been quiet lately. + * @param sat_x Whether the X axis is saturated on the client surface. + * @param sat_y Whether the Y axis is saturated on the client surface. + * @param port_w Touch-port width in pixels. + * @param port_h Touch-port height in pixels. + */ + static void + abs_mouse_sync_estimate(const std::shared_ptr &input, bool client_idle, bool sat_x, bool sat_y, float port_w, float port_h) { + const auto &fb = platf::kms_cursor_feedback(); + + const auto seq = fb.seq.load(); + if (seq == 0 || seq == input->abs_mouse.seq_last) { + return; + } + input->abs_mouse.seq_last = seq; + + const auto phys_w = static_cast(fb.desktop_w.load()); + const auto phys_h = static_cast(fb.desktop_h.load()); + const auto logical_w = static_cast(fb.logical_w.load()); + const auto logical_h = static_cast(fb.logical_h.load()); + if (phys_w <= 0.0f || phys_h <= 0.0f || logical_w <= 0.0f || logical_h <= 0.0f) { + return; + } + + // Desktop physical pixels -> compositor logical pixels. + const auto real_x = static_cast(fb.x.load()) * (logical_w / phys_w); + const auto real_y = static_cast(fb.y.load()) * (logical_h / phys_h); + + if (client_idle) { + input->abs_mouse.host_x = std::clamp(real_x, 0.0f, port_w - 1.0f); + input->abs_mouse.host_y = std::clamp(real_y, 0.0f, port_h - 1.0f); + } + else { + if (sat_x) input->abs_mouse.host_x = std::clamp(real_x, 0.0f, port_w - 1.0f); + if (sat_y) input->abs_mouse.host_y = std::clamp(real_y, 0.0f, port_h - 1.0f); + } + } + + /** + * @brief Emulate relative mouse movement from absolute coordinates. + * + * Absolute motion arrives as PointerMotionAbsolute in the compositor, which + * moves the cursor but does NOT update assistive features that only track + * relative motion, e.g. the COSMIC screen magnifier focal point + * (pop-os/cosmic-comp #2760). The first event anchors the cursor absolutely; + * subsequent events are converted to relative deltas. + * + * Dead reckoning is the only thing in the smooth motion path (1:1, no + * latency); the estimate is resynced with the real cursor position while the + * client is idle. Phantom "walls" (movement blocked in one direction until + * pushed back) come from the client saturating a coordinate in its own + * surface while the host cursor sits mid-screen, so a saturated axis simply + * targets the matching host edge — the primary loop itself drives the cursor + * there, continuously and idempotently. + * + * @param input The input context. + * @param abs_port Absolute-coordinate touch port. + * @param event The client mouse event (raw and touch-port coordinates). + * @param touch_port_dim_x Touch-port width in pixels. + * @param touch_port_dim_y Touch-port height in pixels. + */ + static void + abs_mouse_as_relative(const std::shared_ptr &input, const platf::touch_port_t &abs_port, const abs_mouse_event_t &event, + int touch_port_dim_x, int touch_port_dim_y) { + const auto port_w = static_cast(touch_port_dim_x); + const auto port_h = static_cast(touch_port_dim_y); + + const auto &x = event.x; + const auto &y = event.y; + + // Saturation bands on the client's own surface. + constexpr float kRawEdge = 8.0f; + const auto at_left = x <= kRawEdge; + const auto at_right = x >= event.width - kRawEdge; + const auto at_top = y <= kRawEdge; + const auto at_bottom = y >= event.height - kRawEdge; + + // Client target in touch-port units; a saturated axis targets the host edge. + auto target_x = std::clamp(event.tpcoords.first, 0.0f, port_w - 1.0f); + auto target_y = std::clamp(event.tpcoords.second, 0.0f, port_h - 1.0f); + if (at_left) target_x = 0.0f; + else if (at_right) target_x = port_w - 1.0f; + if (at_top) target_y = 0.0f; + else if (at_bottom) target_y = port_h - 1.0f; + + // Idle detection watches the client's own motion (raw coordinates), so a + // cursor pinned against its edge still counts as idle once it stops. + const auto now = std::chrono::steady_clock::now(); + if (std::fabs(x - input->abs_mouse.last_raw_x) > 0.01f || + std::fabs(y - input->abs_mouse.last_raw_y) > 0.01f) { + input->abs_mouse.last_client_move = now; + } + input->abs_mouse.last_raw_x = x; + input->abs_mouse.last_raw_y = y; + + if (!input->abs_mouse.initialized) { + platf::abs_mouse(platf_input, abs_port, event.tpcoords.first, event.tpcoords.second); + input->abs_mouse.initialized = true; + input->abs_mouse.host_x = target_x; + input->abs_mouse.host_y = target_y; + return; + } + + constexpr auto kIdleMs = std::chrono::milliseconds(150); + const auto client_idle = now - input->abs_mouse.last_client_move > kIdleMs; + abs_mouse_sync_estimate(input, client_idle, at_left || at_right, at_top || at_bottom, port_w, port_h); + + // Primary loop: drive the estimate toward the (possibly edge-overridden) + // target. Idempotent while resting inside the edge band. + input->abs_mouse.frac_x += target_x - input->abs_mouse.host_x; + input->abs_mouse.frac_y += target_y - input->abs_mouse.host_y; + + const auto delta_x = static_cast(input->abs_mouse.frac_x); + const auto delta_y = static_cast(input->abs_mouse.frac_y); + input->abs_mouse.frac_x -= delta_x; + input->abs_mouse.frac_y -= delta_y; + + if (delta_x || delta_y) { + platf::move_mouse(platf_input, delta_x, delta_y); + input->abs_mouse.host_x = std::clamp(input->abs_mouse.host_x + delta_x, 0.0f, port_w - 1.0f); + input->abs_mouse.host_y = std::clamp(input->abs_mouse.host_y + delta_y, 0.0f, port_h - 1.0f); + } + } + /** * @brief Forward a client input packet directly to the platform backend. * @@ -831,7 +996,12 @@ namespace input { touch_port_dim_y }; - platf::abs_mouse(platf_input, abs_port, tpcoords->first, tpcoords->second); + if (!config::input.absolute_mouse_as_relative) { + platf::abs_mouse(platf_input, abs_port, tpcoords->first, tpcoords->second); + return; + } + + abs_mouse_as_relative(input, abs_port, { *tpcoords, x, y, width, height }, touch_port_dim_x, touch_port_dim_y); } /** diff --git a/src/platform/common.h b/src/platform/common.h index dcb126878b9..219911f4f47 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -5,6 +5,7 @@ #pragma once // standard includes +#include #include #include #include @@ -74,6 +75,29 @@ namespace nvenc { } namespace platf { + // Real cursor position feedback published by the capture pipeline. + // The KMS backend reads the cursor plane position once per captured frame and + // publishes it here so the input path can close the loop of the abs->rel + // mouse conversion (see config: absolute_mouse_as_relative). Coordinates are + // in desktop physical pixels; the logical extents allow rescaling to the + // compositor's logical space. seq starts at 0 and is bumped on every update, + // so consumers can tell fresh values from stale ones. + struct kms_cursor_feedback_t { + std::atomic_int32_t x { -1 }; + std::atomic_int32_t y { -1 }; + std::atomic_int32_t desktop_w { 0 }; ///< Physical width of the streamed output. + std::atomic_int32_t desktop_h { 0 }; ///< Physical height of the streamed output. + std::atomic_int32_t logical_w { 0 }; ///< Logical width of the streamed output. + std::atomic_int32_t logical_h { 0 }; ///< Logical height of the streamed output. + std::atomic_uint64_t seq { 0 }; + }; + + /** + * @brief Access the process-wide cursor feedback instance. + */ + kms_cursor_feedback_t& + kms_cursor_feedback(); + // Limited by bits in activeGamepadMask constexpr auto MAX_GAMEPADS = 16; ///< Maximum number of simultaneously tracked gamepads. diff --git a/src/platform/linux/kmsgrab.cpp b/src/platform/linux/kmsgrab.cpp index 9575e2c6097..f8b19dcf12d 100644 --- a/src/platform/linux/kmsgrab.cpp +++ b/src/platform/linux/kmsgrab.cpp @@ -1251,6 +1251,33 @@ namespace platf { captured_cursor.dst_w = *prop_crtc_w; captured_cursor.dst_h = *prop_crtc_h; + // Publish the real cursor position for the abs->rel input conversion + // (see config: absolute_mouse_as_relative). Cursor-plane CRTC + // coordinates are CRTC-local physical pixels; add the output's desktop + // offset and publish the output extents so the consumer can rescale + // to logical touch-port units. + auto &cursor_fb = platf::kms_cursor_feedback(); + cursor_fb.x.store(offset_x + *prop_crtc_x); + cursor_fb.y.store(offset_y + *prop_crtc_y); + cursor_fb.desktop_w.store(width); + cursor_fb.desktop_h.store(height); + cursor_fb.logical_w.store(logical_width); + cursor_fb.logical_h.store(logical_height); + cursor_fb.seq.fetch_add(1); + + // Publish the real cursor position for the abs->rel input conversion + // (see config: absolute_mouse_as_relative). Cursor-plane CRTC + // coordinates are CRTC-local physical pixels; add the output's desktop + // offset and publish the output extents so the consumer can rescale + // to logical units. + platf::kms_cursor_x.store(offset_x + *prop_crtc_x, std::memory_order_relaxed); + platf::kms_cursor_y.store(offset_y + *prop_crtc_y, std::memory_order_relaxed); + platf::kms_desktop_w.store(width, std::memory_order_relaxed); + platf::kms_desktop_h.store(height, std::memory_order_relaxed); + platf::kms_logical_w.store(logical_width, std::memory_order_relaxed); + platf::kms_logical_h.store(logical_height, std::memory_order_relaxed); + platf::kms_cursor_seq.fetch_add(1, std::memory_order_release); + // We're technically cheating a bit here by assuming that we can detect // changes to the cursor plane via property adjustments. If this isn't // true, we'll really have to mmap() the dmabuf and draw that every time.