Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,35 @@ editing the `conf` file in a text editor. Use the examples as reference.
</tr>
</table>

### absolute_mouse_as_relative

<table>
<tr>
<td>Description</td>
<td colspan="2">
When enabled, absolute mouse positions from Moonlight clients are converted to relative motion.
<br>
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.
<br>
This can be useful on compositors whose assistive features (e.g. screen magnifiers) only track relative
pointer motion.
</td>
</tr>
<tr>
<td>Default</td>
<td colspan="2">@code{}
disabled
@endcode</td>
</tr>
<tr>
<td>Example</td>
<td colspan="2">@code{}
absolute_mouse_as_relative = enabled
@endcode</td>
</tr>
</table>

### keybindings

<table>
Expand Down
2 changes: 2 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};

/**
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
172 changes: 171 additions & 1 deletion src/input.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
};
Expand Down Expand Up @@ -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<float, float> 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_t> &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<float>(fb.desktop_w.load());
const auto phys_h = static_cast<float>(fb.desktop_h.load());
const auto logical_w = static_cast<float>(fb.logical_w.load());
const auto logical_h = static_cast<float>(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<float>(fb.x.load()) * (logical_w / phys_w);
const auto real_y = static_cast<float>(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_t> &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<float>(touch_port_dim_x);
const auto port_h = static_cast<float>(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<int>(input->abs_mouse.frac_x);
const auto delta_y = static_cast<int>(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.
*
Expand Down Expand Up @@ -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);
}

/**
Expand Down
24 changes: 24 additions & 0 deletions src/platform/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#pragma once

// standard includes
#include <atomic>
#include <bitset>
#include <filesystem>
#include <functional>
Expand Down Expand Up @@ -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.

Expand Down
27 changes: 27 additions & 0 deletions src/platform/linux/kmsgrab.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down