feat: Add relative mouse mode (#13928)

* feat: Add relative mouse mode

- Add "Relative Mouse Mode" toggle in desktop toolbar and bind to InputModel
- Implement relative mouse movement path: Flutter pointer deltas -> `type: move_relative` -> new `MOUSE_TYPE_MOVE_RELATIVE` in Rust
- In server input service, simulate relative movement via Enigo and keep latest cursor position in sync
- Track pointer-lock center in Flutter (local widget + screen coordinates) and re-center OS cursor after each relative move
- Update pointer-lock center on window move/resize/restore/maximize and when remote display geometry changes
- Hide local cursor when relative mouse mode is active (both Flutter cursor and OS cursor), restore on leave/disable
- On Windows, clip OS cursor to the window rect while in relative mode and release clip when leaving/turning off
- Implement platform helpers: `get_cursor_pos`, `set_cursor_pos`, `show_cursor`, `clip_cursor` (no-op clip/hide on Linux for now)
- Add keyboard shortcut Ctrl+Alt+Shift+M to toggle relative mode (enabled by default, works on all platforms)
- Remove `enable-relative-mouse-shortcut` config option - shortcut is now always available when keyboard permission is granted
- Handle window blur/focus/minimize events to properly release/restore cursor constraints
- Add MOUSE_TYPE_MASK constant and unit tests for mouse event constants

Note: Relative mouse mode state is NOT persisted to config (session-only).
Note: On Linux, show_cursor and clip_cursor are no-ops; cursor hiding is handled by Flutter side.

Signed-off-by: fufesou <linlong1266@gmail.com>

* feat(mouse): relative mouse mode, exit hint

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(relative mouse): shortcut

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-01-09 10:03:14 +08:00
committed by GitHub
parent 3a9084006f
commit 998b75856d
90 changed files with 3089 additions and 165 deletions

View File

@@ -5173,9 +5173,13 @@ impl Retina {
#[inline]
fn on_mouse_event(&mut self, e: &mut MouseEvent, current: usize) {
let evt_type = e.mask & 0x7;
if evt_type == crate::input::MOUSE_TYPE_WHEEL {
// x and y are always 0, +1 or -1
let evt_type = e.mask & crate::input::MOUSE_TYPE_MASK;
// Delta-based events do not contain absolute coordinates.
// Avoid applying Retina coordinate scaling to them.
if evt_type == crate::input::MOUSE_TYPE_WHEEL
|| evt_type == crate::input::MOUSE_TYPE_TRACKPAD
|| evt_type == crate::input::MOUSE_TYPE_MOVE_RELATIVE
{
return;
}
let Some(d) = self.displays.get(current) else {
@@ -5421,6 +5425,9 @@ mod raii {
.unwrap()
.on_connection_close(self.0);
}
// Clear per-connection state to avoid stale behavior if conn ids are reused.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
clear_relative_mouse_active(self.0);
AUTHED_CONNS.lock().unwrap().retain(|c| c.conn_id != self.0);
let remote_count = AUTHED_CONNS
.lock()

View File

@@ -26,6 +26,7 @@ use std::{
thread,
time::{self, Duration, Instant},
};
#[cfg(windows)]
use winapi::um::winuser::WHEEL_DELTA;
@@ -447,7 +448,36 @@ lazy_static::lazy_static! {
static ref KEYS_DOWN: Arc<Mutex<HashMap<KeysDown, Instant>>> = Default::default();
static ref LATEST_PEER_INPUT_CURSOR: Arc<Mutex<Input>> = Default::default();
static ref LATEST_SYS_CURSOR_POS: Arc<Mutex<(Option<Instant>, (i32, i32))>> = Arc::new(Mutex::new((None, (INVALID_CURSOR_POS, INVALID_CURSOR_POS))));
// Track connections that are currently using relative mouse movement.
// Used to disable whiteboard/cursor display for all events while in relative mode.
static ref RELATIVE_MOUSE_CONNS: Arc<Mutex<std::collections::HashSet<i32>>> = Default::default();
}
#[inline]
fn set_relative_mouse_active(conn: i32, active: bool) {
let mut lock = RELATIVE_MOUSE_CONNS.lock().unwrap();
if active {
lock.insert(conn);
} else {
lock.remove(&conn);
}
}
#[inline]
fn is_relative_mouse_active(conn: i32) -> bool {
RELATIVE_MOUSE_CONNS.lock().unwrap().contains(&conn)
}
/// Clears the relative mouse mode state for a connection.
///
/// This must be called when an authenticated connection is dropped (during connection teardown)
/// to avoid leaking the connection id in `RELATIVE_MOUSE_CONNS` (a `Mutex<HashSet<i32>>`).
/// Callers are responsible for invoking this on disconnect.
#[inline]
pub(crate) fn clear_relative_mouse_active(conn: i32) {
set_relative_mouse_active(conn, false);
}
static EXITING: AtomicBool = AtomicBool::new(false);
const MOUSE_MOVE_PROTECTION_TIMEOUT: Duration = Duration::from_millis(1_000);
@@ -644,8 +674,8 @@ async fn set_uinput_resolution(minx: i32, maxx: i32, miny: i32, maxy: i32) -> Re
pub fn is_left_up(evt: &MouseEvent) -> bool {
let buttons = evt.mask >> 3;
let evt_type = evt.mask & 0x7;
return buttons == 1 && evt_type == 2;
let evt_type = evt.mask & MOUSE_TYPE_MASK;
buttons == MOUSE_BUTTON_LEFT && evt_type == MOUSE_TYPE_UP
}
#[cfg(windows)]
@@ -1003,8 +1033,16 @@ pub fn handle_mouse_(
handle_mouse_simulation_(evt, conn);
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if _show_cursor {
handle_mouse_show_cursor_(evt, conn, _username, _argb);
{
let evt_type = evt.mask & MOUSE_TYPE_MASK;
// Relative (delta) mouse events do not include absolute coordinates, so
// whiteboard/cursor rendering must be disabled during relative mode to prevent
// incorrect cursor/whiteboard updates. We check both is_relative_mouse_active(conn)
// (connection already in relative mode from prior events) and evt_type (current
// event is relative) to guard against the first relative event before the flag is set.
if _show_cursor && !is_relative_mouse_active(conn) && evt_type != MOUSE_TYPE_MOVE_RELATIVE {
handle_mouse_show_cursor_(evt, conn, _username, _argb);
}
}
}
@@ -1020,7 +1058,7 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
#[cfg(windows)]
crate::platform::windows::try_change_desktop();
let buttons = evt.mask >> 3;
let evt_type = evt.mask & 0x7;
let evt_type = evt.mask & MOUSE_TYPE_MASK;
let mut en = ENIGO.lock().unwrap();
#[cfg(target_os = "macos")]
en.set_ignore_flags(enigo_ignore_flags());
@@ -1048,6 +1086,8 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
}
match evt_type {
MOUSE_TYPE_MOVE => {
// Switching back to absolute movement implicitly disables relative mouse mode.
set_relative_mouse_active(conn, false);
en.mouse_move_to(evt.x, evt.y);
*LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input {
conn,
@@ -1056,6 +1096,28 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
y: evt.y,
};
}
// MOUSE_TYPE_MOVE_RELATIVE: Relative mouse movement for gaming/3D applications.
// Each client independently decides whether to use relative mode.
// Multiple clients can mix absolute and relative movements without conflict,
// as the server simply applies the delta to the current cursor position.
MOUSE_TYPE_MOVE_RELATIVE => {
set_relative_mouse_active(conn, true);
// Clamp delta to prevent extreme/malicious values from reaching OS APIs.
// This matches the Flutter client's kMaxRelativeMouseDelta constant.
const MAX_RELATIVE_MOUSE_DELTA: i32 = 10000;
let dx = evt.x.clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA);
let dy = evt.y.clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA);
en.mouse_move_relative(dx, dy);
// Get actual cursor position after relative movement for tracking
if let Some((x, y)) = crate::get_cursor_pos() {
*LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input {
conn,
time: get_time(),
x,
y,
};
}
}
MOUSE_TYPE_DOWN => match buttons {
MOUSE_BUTTON_LEFT => {
allow_err!(en.mouse_down(MouseButton::Left));
@@ -1154,7 +1216,7 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn handle_mouse_show_cursor_(evt: &MouseEvent, conn: i32, username: String, argb: u32) {
let buttons = evt.mask >> 3;
let evt_type = evt.mask & 0x7;
let evt_type = evt.mask & MOUSE_TYPE_MASK;
match evt_type {
MOUSE_TYPE_MOVE => {
whiteboard::update_whiteboard(
@@ -1170,11 +1232,22 @@ pub fn handle_mouse_show_cursor_(evt: &MouseEvent, conn: i32, username: String,
}
MOUSE_TYPE_UP => {
if buttons == MOUSE_BUTTON_LEFT {
// Some clients intentionally send button events without coordinates.
// Fall back to the last known cursor position to avoid jumping to (0, 0).
// TODO(protocol): (0, 0) is a valid screen coordinate. Consider using a dedicated
// sentinel value (e.g. INVALID_CURSOR_POS) or a protocol-level flag to distinguish
// "coordinates not provided" from "coordinates are (0, 0)". Impact is minor since
// this only affects whiteboard rendering and clicking exactly at (0, 0) is rare.
let (x, y) = if evt.x == 0 && evt.y == 0 {
get_last_input_cursor_pos()
} else {
(evt.x, evt.y)
};
whiteboard::update_whiteboard(
whiteboard::get_key_cursor(conn),
whiteboard::CustomEvent::Cursor(whiteboard::Cursor {
x: evt.x as _,
y: evt.y as _,
x: x as _,
y: y as _,
argb,
btns: buttons,
text: username,