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

@@ -32,9 +32,33 @@ const OS_LOWER_MACOS: &str = "macos";
#[allow(dead_code)]
const OS_LOWER_ANDROID: &str = "android";
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
static KEYBOARD_HOOKED: AtomicBool = AtomicBool::new(false);
// Track key down state for relative mouse mode exit shortcut.
// macOS: Cmd+G (track G key)
// Windows/Linux: Ctrl+Alt (track whichever modifier was pressed last)
// This prevents the exit from retriggering on OS key-repeat.
#[cfg(all(feature = "flutter", any(target_os = "windows", target_os = "macos", target_os = "linux")))]
static EXIT_SHORTCUT_KEY_DOWN: AtomicBool = AtomicBool::new(false);
// Track whether relative mouse mode is currently active.
// This is set by Flutter via set_relative_mouse_mode_state() and checked
// by the rdev grab loop to determine if exit shortcuts should be processed.
#[cfg(all(feature = "flutter", any(target_os = "windows", target_os = "macos", target_os = "linux")))]
static RELATIVE_MOUSE_MODE_ACTIVE: AtomicBool = AtomicBool::new(false);
/// Set the relative mouse mode state from Flutter.
/// This is called when entering or exiting relative mouse mode.
#[cfg(all(feature = "flutter", any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub fn set_relative_mouse_mode_state(active: bool) {
RELATIVE_MOUSE_MODE_ACTIVE.store(active, Ordering::SeqCst);
// Reset exit shortcut state when mode changes to avoid stale state
if !active {
EXIT_SHORTCUT_KEY_DOWN.store(false, Ordering::SeqCst);
}
}
#[cfg(feature = "flutter")]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
static IS_RDEV_ENABLED: AtomicBool = AtomicBool::new(false);
@@ -82,7 +106,7 @@ pub mod client {
GrabState::Run => {
#[cfg(windows)]
update_grab_get_key_name(keyboard_mode);
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
KEYBOARD_HOOKED.swap(true, Ordering::SeqCst);
#[cfg(target_os = "linux")]
@@ -94,7 +118,7 @@ pub mod client {
release_remote_keys(keyboard_mode);
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
KEYBOARD_HOOKED.swap(false, Ordering::SeqCst);
#[cfg(target_os = "linux")]
@@ -266,6 +290,136 @@ fn get_keyboard_mode() -> String {
"legacy".to_string()
}
/// Check if exit shortcut for relative mouse mode is active.
/// Exit shortcuts (only exits, not toggles):
/// - macOS: Cmd+G
/// - Windows/Linux: Ctrl+Alt (triggered when both are pressed)
/// Note: This shortcut is only available in Flutter client. Sciter client does not support relative mouse mode.
#[cfg(feature = "flutter")]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
fn is_exit_relative_mouse_shortcut(key: Key) -> bool {
let modifiers = MODIFIERS_STATE.lock().unwrap();
#[cfg(target_os = "macos")]
{
// macOS: Cmd+G to exit
if key != Key::KeyG {
return false;
}
let meta = *modifiers.get(&Key::MetaLeft).unwrap_or(&false)
|| *modifiers.get(&Key::MetaRight).unwrap_or(&false);
return meta;
}
#[cfg(not(target_os = "macos"))]
{
// Windows/Linux: Ctrl+Alt to exit
// Triggered when Ctrl is pressed while Alt is down, or Alt is pressed while Ctrl is down
let is_ctrl_key = key == Key::ControlLeft || key == Key::ControlRight;
let is_alt_key = key == Key::Alt || key == Key::AltGr;
if !is_ctrl_key && !is_alt_key {
return false;
}
let ctrl = *modifiers.get(&Key::ControlLeft).unwrap_or(&false)
|| *modifiers.get(&Key::ControlRight).unwrap_or(&false);
let alt = *modifiers.get(&Key::Alt).unwrap_or(&false)
|| *modifiers.get(&Key::AltGr).unwrap_or(&false);
// When Ctrl is pressed and Alt is already down, or vice versa
(is_ctrl_key && alt) || (is_alt_key && ctrl)
}
}
/// Notify Flutter to exit relative mouse mode.
/// Note: This is Flutter-only. Sciter client does not support relative mouse mode.
#[cfg(feature = "flutter")]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
fn notify_exit_relative_mouse_mode() {
let session_id = flutter::get_cur_session_id();
flutter::push_session_event(&session_id, "exit_relative_mouse_mode", vec![]);
}
/// Handle relative mouse mode shortcuts in the rdev grab loop.
/// Returns true if the event should be blocked from being sent to the peer.
#[cfg(feature = "flutter")]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
#[inline]
fn can_exit_relative_mouse_mode_from_grab_loop() -> bool {
// Only process exit shortcuts when relative mouse mode is actually active.
// This prevents blocking Ctrl+Alt (or Cmd+G) when not in relative mouse mode.
if !RELATIVE_MOUSE_MODE_ACTIVE.load(Ordering::SeqCst) {
return false;
}
let Some(session) = flutter::get_cur_session() else {
return false;
};
// Only for remote desktop sessions.
if !session.is_default() {
return false;
}
// Must have keyboard permission and not be in view-only mode.
if !*session.server_keyboard_enabled.read().unwrap() {
return false;
}
let lc = session.lc.read().unwrap();
if lc.view_only.v {
return false;
}
// Peer must support relative mouse mode.
crate::common::is_support_relative_mouse_mode_num(lc.version)
}
#[cfg(feature = "flutter")]
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
#[inline]
fn should_block_relative_mouse_shortcut(key: Key, is_press: bool) -> bool {
if !KEYBOARD_HOOKED.load(Ordering::SeqCst) {
return false;
}
// Determine which key to track for key-up blocking based on platform
#[cfg(target_os = "macos")]
let is_tracked_key = key == Key::KeyG;
#[cfg(not(target_os = "macos"))]
let is_tracked_key = key == Key::ControlLeft
|| key == Key::ControlRight
|| key == Key::Alt
|| key == Key::AltGr;
// Block key up if key down was blocked (to avoid orphan key up event on remote).
// This must be checked before clearing the flag below.
if is_tracked_key && !is_press && EXIT_SHORTCUT_KEY_DOWN.swap(false, Ordering::SeqCst) {
return true;
}
// Exit relative mouse mode shortcuts:
// - macOS: Cmd+G
// - Windows/Linux: Ctrl+Alt
// Guard it to supported/eligible sessions to avoid blocking the chord unexpectedly.
if is_exit_relative_mouse_shortcut(key) {
if !can_exit_relative_mouse_mode_from_grab_loop() {
return false;
}
if is_press {
// Only trigger exit on transition from "not pressed" to "pressed".
// This prevents retriggering on OS key-repeat.
if !EXIT_SHORTCUT_KEY_DOWN.swap(true, Ordering::SeqCst) {
notify_exit_relative_mouse_mode();
}
}
return true;
}
false
}
fn start_grab_loop() {
std::env::set_var("KEYBOARD_ONLY", "y");
#[cfg(any(target_os = "windows", target_os = "macos"))]
@@ -278,6 +432,12 @@ fn start_grab_loop() {
let _scan_code = event.position_code;
let _code = event.platform_code as KeyCode;
#[cfg(feature = "flutter")]
if should_block_relative_mouse_shortcut(key, is_press) {
return None;
}
let res = if KEYBOARD_HOOKED.load(Ordering::SeqCst) {
client::process_event(&get_keyboard_mode(), &event, None);
if is_press {
@@ -337,9 +497,14 @@ fn start_grab_loop() {
#[cfg(target_os = "linux")]
if let Err(err) = rdev::start_grab_listen(move |event: Event| match event.event_type {
EventType::KeyPress(key) | EventType::KeyRelease(key) => {
let is_press = matches!(event.event_type, EventType::KeyPress(_));
if let Key::Unknown(keycode) = key {
log::error!("rdev get unknown key, keycode is {:?}", keycode);
} else {
#[cfg(feature = "flutter")]
if should_block_relative_mouse_shortcut(key, is_press) {
return None;
}
client::process_event(&get_keyboard_mode(), &event, None);
}
None