mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-02-17 14:07:28 +08:00
* 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>
1062 lines
36 KiB
Dart
1062 lines
36 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math' as math;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_hbb/main.dart';
|
|
import 'package:flutter_hbb/utils/relative_mouse_accumulator.dart';
|
|
import 'package:get/get.dart';
|
|
|
|
import '../common.dart';
|
|
import '../consts.dart';
|
|
import 'platform_model.dart';
|
|
|
|
class RelativeMouseModel {
|
|
final SessionID sessionId;
|
|
final RxBool enabled;
|
|
|
|
final bool Function() keyboardPerm;
|
|
final bool Function() isViewCamera;
|
|
final String Function() peerVersion;
|
|
final String? Function() peerPlatform;
|
|
|
|
final Map<String, dynamic> Function(Map<String, dynamic> msg) modify;
|
|
|
|
final bool Function() getPointerInsideImage;
|
|
final void Function(bool inside) setPointerInsideImage;
|
|
|
|
RelativeMouseModel({
|
|
required this.sessionId,
|
|
required this.enabled,
|
|
required this.keyboardPerm,
|
|
required this.isViewCamera,
|
|
required this.peerVersion,
|
|
required this.peerPlatform,
|
|
required this.modify,
|
|
required this.getPointerInsideImage,
|
|
required this.setPointerInsideImage,
|
|
});
|
|
|
|
final RelativeMouseAccumulator _accumulator = RelativeMouseAccumulator();
|
|
|
|
// Native relative mouse mode support (macOS only)
|
|
// Uses CGAssociateMouseAndMouseCursorPosition to lock cursor and NSEvent monitor for raw delta.
|
|
static MethodChannel? _hostChannel;
|
|
// The currently active model receiving native mouse delta events.
|
|
// Note: Race condition between multiple sessions is not a concern here because
|
|
// when relative mouse mode is active, the cursor is locked and the user cannot
|
|
// switch to another session window. The user must first exit relative mouse mode
|
|
// (via Cmd+G on macOS or Ctrl+Alt on Windows/Linux) before they can interact
|
|
// with a different session.
|
|
static RelativeMouseModel? _activeNativeModel;
|
|
static bool _hostChannelInitialized = false;
|
|
|
|
/// Initialize the host channel for native relative mouse mode.
|
|
/// This should be called once when the app starts on macOS.
|
|
static void initHostChannel() {
|
|
if (!isMacOS) return;
|
|
if (_hostChannelInitialized) return;
|
|
_hostChannelInitialized = true;
|
|
|
|
_hostChannel = const MethodChannel('org.rustdesk.rustdesk/host');
|
|
_hostChannel!.setMethodCallHandler((call) async {
|
|
if (call.method == 'onMouseDelta') {
|
|
final args = call.arguments as Map<dynamic, dynamic>;
|
|
final dx = args['dx'] as int;
|
|
final dy = args['dy'] as int;
|
|
_activeNativeModel?._onNativeMouseDelta(dx, dy);
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
|
|
// TODO(perf): Consider routing native delta through RelativeMouseAccumulator/throttle
|
|
// if high-polling mice (e.g. 1000Hz+) cause message flooding on the network.
|
|
void _onNativeMouseDelta(int dx, int dy) {
|
|
if (!enabled.value) return;
|
|
// Send directly to remote without accumulator (native already provides integer deltas)
|
|
_sendMouseMessageToSession({
|
|
'type': 'move_relative',
|
|
'x': '$dx',
|
|
'y': '$dy',
|
|
});
|
|
}
|
|
|
|
Future<bool> _enableNativeRelativeMouseMode() async {
|
|
if (!isMacOS) return false;
|
|
if (_hostChannel == null) {
|
|
initHostChannel();
|
|
if (_hostChannel == null) return false;
|
|
}
|
|
|
|
// Defensive guard: prevent overwriting an already-active native session.
|
|
// In practice, this should not happen because when relative mouse mode is active,
|
|
// the cursor is locked and the user cannot switch to another session window.
|
|
// The user must first exit relative mouse mode (via Cmd+G on macOS or Ctrl+Alt on
|
|
// Windows/Linux) before interacting with a different session.
|
|
if (_activeNativeModel != null && _activeNativeModel != this) {
|
|
debugPrint(
|
|
'[RelMouse] Another model already has native relative mouse mode active');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
final result =
|
|
await _hostChannel!.invokeMethod('enableNativeRelativeMouseMode');
|
|
if (result == true) {
|
|
_activeNativeModel = this;
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[RelMouse] Failed to enable native relative mouse mode: $e');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future<void> _disableNativeRelativeMouseMode() async {
|
|
if (!isMacOS) return;
|
|
if (_hostChannel == null) return;
|
|
|
|
// Only the owning model should disable native mode to avoid
|
|
// one session inadvertently disrupting another's native relative mouse state.
|
|
if (_activeNativeModel != this) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await _hostChannel!.invokeMethod('disableNativeRelativeMouseMode');
|
|
} catch (e) {
|
|
debugPrint('[RelMouse] Failed to disable native relative mouse mode: $e');
|
|
} finally {
|
|
if (_activeNativeModel == this) {
|
|
_activeNativeModel = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Whether native relative mouse mode is currently active for this model
|
|
bool get _isNativeRelativeMouseModeActive =>
|
|
isMacOS && _activeNativeModel == this;
|
|
|
|
// Pointer lock center in LOCAL widget coordinates (for delta calculation)
|
|
Offset? _pointerLockCenterLocal;
|
|
// Pointer lock center in SCREEN coordinates (for OS cursor re-centering)
|
|
Offset? _pointerLockCenterScreen;
|
|
// Pointer region top-left in Flutter view coordinates.
|
|
// Computed from PointerEvent.position - PointerEvent.localPosition.
|
|
Offset? _pointerRegionTopLeftGlobal;
|
|
// Last pointer position in LOCAL widget coordinates (fallback when center is not ready).
|
|
Offset? _lastPointerLocalPos;
|
|
|
|
// Track whether we currently have an OS-level cursor clip active (Windows only).
|
|
// TODO(accuracy): Revisit window/client/border clipping math if users report misaligned
|
|
// clipping on custom or maximized window decorations. Consider using platform APIs
|
|
// (e.g. GetClientRect on Windows) instead of Flutter's window coordinates.
|
|
bool _cursorClipApplied = false;
|
|
|
|
// Track whether a recenter operation is in progress to prevent overlapping calls.
|
|
bool _recenterInProgress = false;
|
|
|
|
// Request token for async enable operation to prevent stale callbacks.
|
|
// Incremented on each enable attempt, callbacks check if token still matches.
|
|
int _enableRequestId = 0;
|
|
|
|
// Throttle buffer for batching mouse move messages (reduces network flooding).
|
|
int _pendingDeltaX = 0;
|
|
int _pendingDeltaY = 0;
|
|
Timer? _throttleTimer;
|
|
static const Duration _throttleInterval = Duration(milliseconds: 16);
|
|
|
|
// Size of the remote image widget (for center calculation)
|
|
Size? _imageWidgetSize;
|
|
|
|
// Debounce timestamp for relative mouse mode toggle to prevent race conditions
|
|
// between Rust rdev grab loop and Flutter keyboard handling.
|
|
DateTime? _lastToggle;
|
|
|
|
// Track key down state for exit shortcut.
|
|
// macOS: Cmd+G - track G key
|
|
// Windows/Linux: Ctrl+Alt - track whichever modifier was pressed last
|
|
// When key down is blocked (shortcut triggered), we also need to block
|
|
// the corresponding key up to avoid orphan key up events being sent to remote.
|
|
bool _exitShortcutKeyDown = false;
|
|
|
|
// Callback to cancel external throttle timer when relative mouse mode is disabled.
|
|
VoidCallback? onDisabled;
|
|
|
|
bool get isSupported {
|
|
// On Linux/Wayland, cursor warping is not supported, hide the option entirely.
|
|
if (isDesktop && isLinux && bind.mainCurrentIsWayland()) {
|
|
return false;
|
|
}
|
|
// Relative mouse mode is unsupported on remote Linux:
|
|
// 1. Long-press key events are unsupported.
|
|
// 2. The Wayland display server lacks cursor warping support.
|
|
final platform = peerPlatform();
|
|
if (platform == kPeerPlatformLinux) {
|
|
return false;
|
|
}
|
|
final v = peerVersion();
|
|
if (v.isEmpty) return false;
|
|
return versionCmp(v, kMinVersionForRelativeMouseMode) >= 0;
|
|
}
|
|
|
|
Size? get imageWidgetSize => _imageWidgetSize;
|
|
|
|
void updateImageWidgetSize(Size size) {
|
|
_imageWidgetSize = size;
|
|
if (enabled.value) {
|
|
_pointerLockCenterLocal = Offset(size.width / 2, size.height / 2);
|
|
}
|
|
}
|
|
|
|
void updatePointerRegionTopLeftGlobal(PointerEvent e) {
|
|
_pointerRegionTopLeftGlobal = e.position - e.localPosition;
|
|
}
|
|
|
|
/// Shared helper for handling exit shortcut for relative mouse mode.
|
|
/// Returns true if the event was handled and should not be forwarded.
|
|
///
|
|
/// Exit shortcuts (only work when relative mouse mode is active):
|
|
/// - macOS: Cmd+G
|
|
/// - Windows/Linux: Ctrl+Alt (any order - triggered when both are pressed)
|
|
///
|
|
/// [logicalKey] - the logical key of the event
|
|
/// [isKeyUp] - whether the event is a key up event
|
|
/// [isKeyDown] - whether the event is a key down event
|
|
/// [ctrlPressed], [altPressed], [commandPressed] - modifier states
|
|
bool _handleExitShortcut({
|
|
required LogicalKeyboardKey logicalKey,
|
|
required bool isKeyUp,
|
|
required bool isKeyDown,
|
|
required bool ctrlPressed,
|
|
required bool altPressed,
|
|
required bool commandPressed,
|
|
}) {
|
|
if (!isDesktop || !keyboardPerm() || isViewCamera()) return false;
|
|
|
|
// Only handle exit shortcuts when relative mouse mode is active
|
|
if (!enabled.value) return false;
|
|
|
|
// Block key up if key down was blocked (to avoid orphan key up event on remote).
|
|
if (isKeyUp && _exitShortcutKeyDown) {
|
|
_exitShortcutKeyDown = false;
|
|
return true;
|
|
}
|
|
|
|
if (!isKeyDown) return false;
|
|
|
|
// macOS: Cmd+G to exit
|
|
if (isMacOS) {
|
|
final isGKey = logicalKey == LogicalKeyboardKey.keyG;
|
|
if (isGKey && commandPressed) {
|
|
_exitShortcutKeyDown = true;
|
|
setRelativeMouseMode(false);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Windows/Linux: Ctrl+Alt to exit
|
|
// Triggered when both modifiers are pressed (check on either Ctrl or Alt key down)
|
|
final isCtrlKey = logicalKey == LogicalKeyboardKey.controlLeft ||
|
|
logicalKey == LogicalKeyboardKey.controlRight;
|
|
final isAltKey = logicalKey == LogicalKeyboardKey.altLeft ||
|
|
logicalKey == LogicalKeyboardKey.altRight;
|
|
|
|
// When Ctrl is pressed and Alt is already down, or vice versa
|
|
if ((isCtrlKey && altPressed) || (isAltKey && ctrlPressed)) {
|
|
_exitShortcutKeyDown = true;
|
|
setRelativeMouseMode(false);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool handleKeyEvent(
|
|
KeyEvent e, {
|
|
required bool ctrlPressed,
|
|
required bool shiftPressed,
|
|
required bool altPressed,
|
|
required bool commandPressed,
|
|
}) {
|
|
return _handleExitShortcut(
|
|
logicalKey: e.logicalKey,
|
|
isKeyUp: e is KeyUpEvent,
|
|
isKeyDown: e is KeyDownEvent,
|
|
ctrlPressed: ctrlPressed,
|
|
altPressed: altPressed,
|
|
commandPressed: commandPressed,
|
|
);
|
|
}
|
|
|
|
/// Handle raw key events for relative mouse mode.
|
|
/// Returns true if the event was handled and should not be forwarded.
|
|
bool handleRawKeyEvent(RawKeyEvent e) {
|
|
final modifiers = e.data;
|
|
return _handleExitShortcut(
|
|
logicalKey: e.logicalKey,
|
|
isKeyUp: e is RawKeyUpEvent,
|
|
isKeyDown: e is RawKeyDownEvent,
|
|
ctrlPressed: modifiers.isControlPressed,
|
|
altPressed: modifiers.isAltPressed,
|
|
commandPressed: modifiers.isMetaPressed,
|
|
);
|
|
}
|
|
|
|
void onEnterOrLeaveImage(bool enter) {
|
|
if (!enabled.value) return;
|
|
|
|
// Keep the shared pointer-in-image flag in sync.
|
|
setPointerInsideImage(enter);
|
|
|
|
// macOS native mode: cursor is locked by CGAssociateMouseAndMouseCursorPosition,
|
|
// no need for recenter logic.
|
|
if (_isNativeRelativeMouseModeActive) {
|
|
return;
|
|
}
|
|
|
|
if (!enter) {
|
|
_releaseCursorClip();
|
|
return;
|
|
}
|
|
|
|
// Windows: clip cursor to window rect
|
|
// Linux: use recenter method
|
|
updatePointerLockCenter().then((_) {
|
|
_recenterMouse();
|
|
});
|
|
}
|
|
|
|
void onWindowBlur() {
|
|
if (!enabled.value) return;
|
|
|
|
// Focus can change while the pointer is outside the window (e.g. taskbar activation).
|
|
// Do not rely on the previous "pointer inside" state across focus boundaries.
|
|
setPointerInsideImage(false);
|
|
// macOS native mode: don't call _releaseCursorClip as it would break CGAssociateMouseAndMouseCursorPosition
|
|
if (!_isNativeRelativeMouseModeActive) {
|
|
_releaseCursorClip();
|
|
}
|
|
}
|
|
|
|
void onWindowFocus() {
|
|
if (!enabled.value) return;
|
|
|
|
// macOS native mode: cursor is already locked
|
|
if (_isNativeRelativeMouseModeActive) {
|
|
setPointerInsideImage(false);
|
|
return;
|
|
}
|
|
|
|
// Guard: image widget size must be available for proper center calculation.
|
|
if (_imageWidgetSize == null) {
|
|
_disableWithCleanup();
|
|
return;
|
|
}
|
|
|
|
// Fail-safe: keep cursor usable on focus gain. Pointer lock will be re-engaged
|
|
// on the next pointer enter/move/hover inside the remote image.
|
|
setPointerInsideImage(false);
|
|
_releaseCursorClip();
|
|
|
|
// Best-effort: refresh center so the next engage is immediate.
|
|
updatePointerLockCenter();
|
|
}
|
|
|
|
void toggleRelativeMouseMode() {
|
|
final now = DateTime.now();
|
|
if (_lastToggle != null &&
|
|
now.difference(_lastToggle!).inMilliseconds <
|
|
kRelativeMouseModeToggleDebounceMs) {
|
|
return;
|
|
}
|
|
_lastToggle = now;
|
|
setRelativeMouseMode(!enabled.value);
|
|
}
|
|
|
|
bool setRelativeMouseMode(bool value) {
|
|
// Web is not supported due to Pointer Lock API integration complexity with Flutter's input system
|
|
if (isWeb) {
|
|
return false;
|
|
}
|
|
|
|
if (value) {
|
|
if (!keyboardPerm() || isViewCamera()) {
|
|
return false;
|
|
}
|
|
|
|
if (isDesktop && _imageWidgetSize == null) {
|
|
// Desktop only: Ensure image widget size is available for proper center calculation.
|
|
showToast(translate('rel-mouse-not-ready-tip'));
|
|
return false;
|
|
}
|
|
|
|
if (!isSupported) {
|
|
// Check server version support before enabling.
|
|
showToast(translate('rel-mouse-not-supported-peer-tip'));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (value) {
|
|
try {
|
|
if (isDesktop) {
|
|
final requestId = ++_enableRequestId;
|
|
if (isMacOS) {
|
|
// macOS: Use native relative mouse mode with CGAssociateMouseAndMouseCursorPosition
|
|
// This locks the cursor in place and provides raw delta via NSEvent monitor.
|
|
_enableNativeRelativeMouseMode().then((success) {
|
|
// Guard against stale callback: user may have toggled off relative mode
|
|
// while the async enable was in progress.
|
|
if (_enableRequestId != requestId) {
|
|
return;
|
|
}
|
|
if (success) {
|
|
_completeEnableRelativeMouseMode();
|
|
}
|
|
// Note: _enableNativeRelativeMouseMode already handles its own cleanup on failure
|
|
});
|
|
} else {
|
|
// Windows/Linux: Use Flutter-based cursor recenter approach
|
|
if (!getPointerInsideImage()) {
|
|
_releaseCursorClip();
|
|
}
|
|
|
|
updatePointerLockCenter().then((_) => _recenterMouse()).then((_) {
|
|
if (_enableRequestId != requestId) {
|
|
return;
|
|
}
|
|
_completeEnableRelativeMouseMode();
|
|
}).catchError((e) {
|
|
if (_enableRequestId != requestId) {
|
|
return;
|
|
}
|
|
debugPrint('[RelMouse] Platform setup failed: $e');
|
|
_resetState();
|
|
});
|
|
}
|
|
} else {
|
|
// Mobile: enable immediately (no platform-specific setup needed)
|
|
_completeEnableRelativeMouseMode();
|
|
}
|
|
} catch (e) {
|
|
_disableWithCleanup();
|
|
return false;
|
|
}
|
|
} else {
|
|
// Best-effort marker for Rust rdev grab loop (ESC behavior).
|
|
// Bypass keyboardPerm check to ensure Rust state is always synced,
|
|
// even if permission was revoked while relative mode was active.
|
|
_sendMouseMessageToSession(
|
|
{
|
|
'relative_mouse_mode': '0',
|
|
},
|
|
disableRelativeOnError: false,
|
|
bypassKeyboardPerm: true,
|
|
);
|
|
|
|
// Desktop only: cursor manipulation
|
|
if (isDesktop) {
|
|
if (isMacOS) {
|
|
// macOS: Disable native relative mouse mode
|
|
// This already calls CGAssociateMouseAndMouseCursorPosition(1) to re-associate mouse
|
|
_disableNativeRelativeMouseMode();
|
|
} else {
|
|
_releaseCursorClip();
|
|
}
|
|
}
|
|
enabled.value = false;
|
|
_resetState();
|
|
onDisabled?.call();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// Called when platform setup completes successfully to finalize enabling relative mouse mode.
|
|
void _completeEnableRelativeMouseMode() {
|
|
enabled.value = true;
|
|
|
|
// Show toast notification so user knows how to exit relative mouse mode (desktop only).
|
|
if (isDesktop) {
|
|
showToast(
|
|
translate('rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'),
|
|
alignment: Alignment.center);
|
|
}
|
|
|
|
// Best-effort marker for Rust rdev grab loop (ESC behavior) and peer/server state.
|
|
// This uses a no-op delta so it does not move the remote cursor.
|
|
// Intentionally fire-and-forget: we don't block enabling on this marker message.
|
|
// Failures are logged but do not disable relative mouse mode.
|
|
_sendMouseMessageToSession(
|
|
{
|
|
'relative_mouse_mode': '1',
|
|
'type': 'move_relative',
|
|
'x': '0',
|
|
'y': '0',
|
|
},
|
|
disableRelativeOnError: false,
|
|
).catchError((e) {
|
|
debugPrint('[RelMouse] Failed to send enable marker: $e');
|
|
return false;
|
|
});
|
|
}
|
|
|
|
// Flag to skip the first mouse move event after recenter (it's the recenter itself).
|
|
bool _skipNextMouseMove = false;
|
|
|
|
/// Handle relative mouse movement based on current local pointer position.
|
|
/// Returns true if the event was handled in relative mode, false otherwise.
|
|
bool handleRelativeMouseMove(Offset localPosition) {
|
|
if (!enabled.value) return false;
|
|
|
|
// macOS: Native mode handles delta via callback, skip Flutter-based handling.
|
|
if (_isNativeRelativeMouseModeActive) {
|
|
return true;
|
|
}
|
|
|
|
// Pointer move/hover implies we're inside the remote image.
|
|
_ensurePointerLockEngaged();
|
|
|
|
// Skip the mouse move event triggered by recenter operation itself.
|
|
if (_skipNextMouseMove) {
|
|
_skipNextMouseMove = false;
|
|
_lastPointerLocalPos = localPosition;
|
|
return true;
|
|
}
|
|
|
|
final lastLocal = _lastPointerLocalPos;
|
|
_lastPointerLocalPos = localPosition;
|
|
|
|
// Linux-specific: Proactive recenter check before processing delta.
|
|
// On Linux, we don't have clip_cursor, so if the cursor moves too fast
|
|
// it may escape the window before _recenterIfNearEdge can catch it.
|
|
// Check now and recenter immediately if needed.
|
|
if (isLinux) {
|
|
_recenterIfNearEdgeLinux(localPosition);
|
|
}
|
|
|
|
// Calculate delta from last position (not from center).
|
|
// This avoids issues with CGWarpMouseCursorPosition integer rounding.
|
|
if (lastLocal != null) {
|
|
final delta = localPosition - lastLocal;
|
|
if (delta.dx != 0 || delta.dy != 0) {
|
|
sendRelativeMouseMove(delta.dx, delta.dy);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// Linux-specific: More aggressive recenter check to prevent cursor escape.
|
|
/// Called synchronously before processing mouse delta to ensure cursor stays within bounds.
|
|
void _recenterIfNearEdgeLinux(Offset localPosition) {
|
|
final size = _imageWidgetSize;
|
|
if (size == null) return;
|
|
|
|
final edgeThreshold = _calculateEdgeThreshold(size);
|
|
|
|
final nearLeft = localPosition.dx < edgeThreshold;
|
|
final nearRight = localPosition.dx > size.width - edgeThreshold;
|
|
final nearTop = localPosition.dy < edgeThreshold;
|
|
final nearBottom = localPosition.dy > size.height - edgeThreshold;
|
|
|
|
if (nearLeft || nearRight || nearTop || nearBottom) {
|
|
_recenterMouse();
|
|
}
|
|
}
|
|
|
|
void sendRelativeMouseMove(double dx, double dy) {
|
|
if (!isDesktop) return;
|
|
|
|
final delta = _accumulator.add(dx, dy, maxDelta: kMaxRelativeMouseDelta);
|
|
if (delta == null) return;
|
|
|
|
// Buffer the delta for throttled sending.
|
|
_pendingDeltaX += delta.x;
|
|
_pendingDeltaY += delta.y;
|
|
|
|
// Start or refresh the throttle timer.
|
|
if (_throttleTimer == null || !_throttleTimer!.isActive) {
|
|
_throttleTimer = Timer(_throttleInterval, () => _flushPendingDelta());
|
|
}
|
|
}
|
|
|
|
Future<void> _flushPendingDelta() async {
|
|
if (!isDesktop) return;
|
|
if (_pendingDeltaX == 0 && _pendingDeltaY == 0) return;
|
|
|
|
final x = _pendingDeltaX;
|
|
final y = _pendingDeltaY;
|
|
_pendingDeltaX = 0;
|
|
_pendingDeltaY = 0;
|
|
|
|
final ok = await _sendMouseMessageToSession({
|
|
'type': 'move_relative',
|
|
'x': '$x',
|
|
'y': '$y',
|
|
});
|
|
if (!ok) return;
|
|
|
|
// Only recenter when mouse is near the edge of the image widget.
|
|
// This allows smooth mouse movement without constant recentering.
|
|
_recenterIfNearEdge();
|
|
}
|
|
|
|
// Edge threshold parameters for recenter detection.
|
|
// Threshold is calculated as: min(maxThreshold, min(width, height) * fraction)
|
|
static const double _edgeThresholdFraction = 0.1; // 10% of smaller dimension
|
|
static const double _edgeThresholdMax =
|
|
100.0; // Maximum threshold in logical pixels
|
|
static const double _edgeThresholdMin =
|
|
20.0; // Minimum threshold for very small widgets
|
|
|
|
// Linux-specific edge threshold parameters (more aggressive to prevent cursor escape).
|
|
// On Linux, we don't have clip_cursor capability, so we need to recenter earlier
|
|
// to prevent the cursor from escaping the window when moving fast.
|
|
static const double _edgeThresholdFractionLinux =
|
|
0.25; // 25% of smaller dimension
|
|
static const double _edgeThresholdMaxLinux =
|
|
200.0; // Larger maximum threshold for Linux
|
|
static const double _edgeThresholdMinLinux =
|
|
50.0; // Larger minimum threshold for Linux
|
|
|
|
/// Calculate dynamic edge threshold based on widget size.
|
|
double _calculateEdgeThreshold(Size size) {
|
|
final smallerDimension = math.min(size.width, size.height);
|
|
if (isLinux) {
|
|
// Use more aggressive thresholds on Linux to prevent cursor escape.
|
|
final dynamicThreshold = smallerDimension * _edgeThresholdFractionLinux;
|
|
return dynamicThreshold.clamp(
|
|
_edgeThresholdMinLinux, _edgeThresholdMaxLinux);
|
|
}
|
|
final dynamicThreshold = smallerDimension * _edgeThresholdFraction;
|
|
// Clamp between min and max thresholds
|
|
return dynamicThreshold.clamp(_edgeThresholdMin, _edgeThresholdMax);
|
|
}
|
|
|
|
/// Recenter the cursor only if it's near the edge of the image widget.
|
|
void _recenterIfNearEdge() {
|
|
final lastPos = _lastPointerLocalPos;
|
|
final size = _imageWidgetSize;
|
|
if (lastPos == null || size == null) return;
|
|
|
|
// Dynamic threshold based on widget size
|
|
final edgeThreshold = _calculateEdgeThreshold(size);
|
|
|
|
final nearLeft = lastPos.dx < edgeThreshold;
|
|
final nearRight = lastPos.dx > size.width - edgeThreshold;
|
|
final nearTop = lastPos.dy < edgeThreshold;
|
|
final nearBottom = lastPos.dy > size.height - edgeThreshold;
|
|
|
|
if (nearLeft || nearRight || nearTop || nearBottom) {
|
|
_recenterMouse();
|
|
}
|
|
}
|
|
|
|
/// Send mouse button event without position (for relative mouse mode).
|
|
Future<void> sendRelativeMouseButton(Map<String, dynamic> evt) async {
|
|
if (!enabled.value) return;
|
|
_ensurePointerLockEngaged();
|
|
|
|
final rawType = evt['type'];
|
|
final rawButtons = evt['buttons'];
|
|
if (rawType is! String || rawButtons is! int) return;
|
|
|
|
final type = _mouseEventTypeToPeer(rawType);
|
|
if (type.isEmpty) return;
|
|
|
|
final buttons = mouseButtonsToPeer(rawButtons);
|
|
if (buttons.isEmpty) return;
|
|
|
|
await _sendMouseMessageToSession({
|
|
'type': type,
|
|
'buttons': buttons,
|
|
});
|
|
}
|
|
|
|
static String _mouseEventTypeToPeer(String type) {
|
|
switch (type) {
|
|
case 'mousedown':
|
|
return kMouseEventTypeDown;
|
|
case 'mouseup':
|
|
return kMouseEventTypeUp;
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
Future<bool> _sendMouseMessageToSession(
|
|
Map<String, dynamic> msg, {
|
|
bool disableRelativeOnError = true,
|
|
bool bypassKeyboardPerm = false,
|
|
}) async {
|
|
if (!bypassKeyboardPerm && !keyboardPerm()) return false;
|
|
if (isViewCamera()) return false;
|
|
|
|
try {
|
|
await bind.sessionSendMouse(
|
|
sessionId: sessionId,
|
|
msg: json.encode(modify(msg)),
|
|
);
|
|
return true;
|
|
} catch (e) {
|
|
debugPrint('[RelMouse] Error sending mouse message: $e');
|
|
if (disableRelativeOnError && enabled.value) {
|
|
_disableWithCleanup();
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Retry parameters for cursor re-centering.
|
|
static const int _recenterMaxRetries = 3;
|
|
static const Duration _recenterRetryDelay = Duration(milliseconds: 100);
|
|
|
|
/// Recenter the cursor to the pointer lock center.
|
|
/// Fire-and-forget safe: prevents overlapping calls and catches errors internally.
|
|
Future<void> _recenterMouse() async {
|
|
// Prevent overlapping recenter operations under high-frequency mouse moves.
|
|
if (_recenterInProgress) return;
|
|
_recenterInProgress = true;
|
|
|
|
try {
|
|
if (!enabled.value) return;
|
|
if (!getPointerInsideImage()) return;
|
|
|
|
final center = _pointerLockCenterScreen;
|
|
if (center == null) {
|
|
return;
|
|
}
|
|
|
|
for (int attempt = 0; attempt < _recenterMaxRetries; attempt++) {
|
|
// Check preconditions before each attempt.
|
|
if (!enabled.value || !getPointerInsideImage()) return;
|
|
|
|
final ok = bind.mainSetCursorPosition(
|
|
x: center.dx.toInt(),
|
|
y: center.dy.toInt(),
|
|
);
|
|
if (ok) {
|
|
// Skip the next mouse move event - it's triggered by the recenter itself.
|
|
_skipNextMouseMove = true;
|
|
return;
|
|
}
|
|
|
|
// Wait before retrying (except on the last attempt).
|
|
if (attempt < _recenterMaxRetries - 1) {
|
|
await Future.delayed(_recenterRetryDelay);
|
|
}
|
|
}
|
|
|
|
// All attempts failed.
|
|
_disableWithCleanup();
|
|
showToast(translate('rel-mouse-lock-failed-tip'));
|
|
} catch (e, st) {
|
|
debugPrint('[RelMouse] Unexpected error in _recenterMouse: $e\n$st');
|
|
} finally {
|
|
_recenterInProgress = false;
|
|
}
|
|
}
|
|
|
|
Future<void> updatePointerLockCenter({Offset? localCenter}) async {
|
|
if (!isDesktop) return;
|
|
|
|
// Null safety check for kWindowId.
|
|
if (kWindowId == null) {
|
|
if (enabled.value) {
|
|
_disableWithCleanup();
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final wc = WindowController.fromWindowId(kWindowId!);
|
|
final frame = await wc.getFrame();
|
|
|
|
if (frame.width <= 0 || frame.height <= 0) {
|
|
if (enabled.value) {
|
|
_disableWithCleanup();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (localCenter != null) {
|
|
_pointerLockCenterLocal = localCenter;
|
|
} else if (_imageWidgetSize != null) {
|
|
_pointerLockCenterLocal = Offset(
|
|
_imageWidgetSize!.width / 2,
|
|
_imageWidgetSize!.height / 2,
|
|
);
|
|
} else {
|
|
if (enabled.value) {
|
|
_disableWithCleanup();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Calculate screen coordinates for OS cursor positioning.
|
|
// Use PlatformDispatcher instead of deprecated ui.window.
|
|
final view = ui.PlatformDispatcher.instance.views.firstOrNull;
|
|
if (view == null) {
|
|
debugPrint('[RelMouse] No view available for coordinate calculation');
|
|
if (enabled.value) {
|
|
_disableWithCleanup();
|
|
}
|
|
return;
|
|
}
|
|
final scale = view.devicePixelRatio;
|
|
|
|
if (_pointerRegionTopLeftGlobal != null && scale > 0) {
|
|
// On macOS, window frame and CGWarpMouseCursorPosition use points (not pixels).
|
|
// On Windows, they use pixels.
|
|
// Flutter's logical coordinates are in points on macOS.
|
|
final centerInView =
|
|
_pointerRegionTopLeftGlobal! + _pointerLockCenterLocal!;
|
|
|
|
// Calculate client area offset (excluding title bar and borders)
|
|
final clientPhysical = view.physicalSize;
|
|
|
|
// macOS: Window frame and CGWarpMouseCursorPosition both use points (not pixels).
|
|
// We convert clientPhysical (pixels) to points via `/ scale` to compute titleBarHeight,
|
|
// which is the difference between the total window height and the Flutter view height.
|
|
if (isMacOS) {
|
|
final clientHeightPoints = clientPhysical.height / scale;
|
|
final titleBarHeight = frame.height - clientHeightPoints;
|
|
|
|
_pointerLockCenterScreen = Offset(
|
|
frame.left + centerInView.dx,
|
|
frame.top + titleBarHeight + centerInView.dy,
|
|
);
|
|
} else {
|
|
// Windows/Linux: Use pixel coordinates. We estimate the client-area offset using
|
|
// a heuristic based on the difference between frame size and client physical size.
|
|
// This assumes symmetric horizontal borders (extraW / 2) and that the remaining
|
|
// vertical space (extraH - borderBottom) is the title bar height.
|
|
// Limitation: This heuristic may be inaccurate for maximized windows, custom window
|
|
// decorations, or when the OS uses different border styles.
|
|
// TODO: Replace this heuristic with platform API calls (e.g., GetClientRect on Windows)
|
|
// if precise client-area offsets are required.
|
|
final extraW = frame.width - clientPhysical.width;
|
|
final extraH = frame.height - clientPhysical.height;
|
|
final borderX = extraW > 0 ? extraW / 2 : 0.0;
|
|
final borderBottom = borderX;
|
|
final borderTop = extraH > borderBottom ? extraH - borderBottom : 0.0;
|
|
final clientTopLeftScreen =
|
|
Offset(frame.left + borderX, frame.top + borderTop);
|
|
|
|
// Calculate tentative center, then validate it's within frame bounds.
|
|
// This guards against heuristic inaccuracies (e.g., maximized windows).
|
|
final tentativeCenter = Offset(
|
|
clientTopLeftScreen.dx + centerInView.dx * scale,
|
|
clientTopLeftScreen.dy + centerInView.dy * scale,
|
|
);
|
|
final withinFrame = tentativeCenter.dx >= frame.left &&
|
|
tentativeCenter.dx <= frame.left + frame.width &&
|
|
tentativeCenter.dy >= frame.top &&
|
|
tentativeCenter.dy <= frame.top + frame.height;
|
|
_pointerLockCenterScreen = withinFrame
|
|
? tentativeCenter
|
|
: Offset(
|
|
frame.left + frame.width / 2, frame.top + frame.height / 2);
|
|
}
|
|
} else {
|
|
_pointerLockCenterScreen = Offset(
|
|
frame.left + frame.width / 2,
|
|
frame.top + frame.height / 2,
|
|
);
|
|
}
|
|
|
|
if (enabled.value && isWindows && getPointerInsideImage()) {
|
|
_applyCursorClipForFrame(frame);
|
|
} else if (enabled.value && isWindows && _cursorClipApplied) {
|
|
// Only release if we actually have a clip applied to avoid redundant FFI calls.
|
|
_releaseCursorClip();
|
|
}
|
|
// macOS: no clip_cursor (CGAssociateMouseAndMouseCursorPosition stops mouse events)
|
|
// Instead, we use recenter method like other platforms.
|
|
} catch (e) {
|
|
if (enabled.value) {
|
|
_disableWithCleanup();
|
|
} else {
|
|
_pointerLockCenterLocal = null;
|
|
_pointerLockCenterScreen = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
void _ensurePointerLockEngaged() {
|
|
if (!enabled.value) return;
|
|
if (!isDesktop) return;
|
|
|
|
setPointerInsideImage(true);
|
|
|
|
final needsCenter =
|
|
_pointerLockCenterLocal == null || _pointerLockCenterScreen == null;
|
|
// Windows only: cursor clip
|
|
final needsClip = isWindows && !_cursorClipApplied;
|
|
if (needsCenter || needsClip) {
|
|
updatePointerLockCenter()
|
|
.then((_) => _recenterMouse())
|
|
.catchError((Object e, StackTrace st) {
|
|
debugPrint('[RelMouse] updatePointerLockCenter failed: $e\n$st');
|
|
_disableWithCleanup();
|
|
});
|
|
}
|
|
}
|
|
|
|
void _applyCursorClipForFrame(Rect frame) {
|
|
if (!isWindows) return;
|
|
|
|
// Use PlatformDispatcher to get the device pixel ratio for proper scaling.
|
|
final view = ui.PlatformDispatcher.instance.views.firstOrNull;
|
|
final scale = view?.devicePixelRatio ?? 1.0;
|
|
|
|
// Get the Flutter view's physical size (client area in pixels).
|
|
final clientPhysical = view?.physicalSize ?? ui.Size.zero;
|
|
|
|
// Calculate the non-client area (OS window title bar, borders).
|
|
// frame includes the entire window (title bar + borders + client area).
|
|
final extraW = frame.width - clientPhysical.width;
|
|
final extraH = frame.height - clientPhysical.height;
|
|
|
|
// Assume symmetric horizontal borders.
|
|
final borderX = extraW > 0 ? extraW / 2 : 0.0;
|
|
// Bottom border is typically the same as side borders.
|
|
final borderBottom = borderX;
|
|
// OS window title bar height is the remaining vertical non-client space.
|
|
final borderTop = extraH > borderBottom ? extraH - borderBottom : 0.0;
|
|
|
|
// Calculate client area top-left in screen coordinates.
|
|
final clientTopLeftScreen =
|
|
Offset(frame.left + borderX, frame.top + borderTop);
|
|
|
|
int left, top, right, bottom;
|
|
|
|
// If we have precise image widget info, clip to the remote image area.
|
|
// This excludes the Flutter app's internal title bar and toolbar.
|
|
if (_pointerRegionTopLeftGlobal != null &&
|
|
_imageWidgetSize != null &&
|
|
scale > 0) {
|
|
// _pointerRegionTopLeftGlobal is in Flutter logical coordinates (relative to client area).
|
|
// Convert to screen physical coordinates.
|
|
left = (clientTopLeftScreen.dx + _pointerRegionTopLeftGlobal!.dx * scale)
|
|
.toInt();
|
|
top = (clientTopLeftScreen.dy + _pointerRegionTopLeftGlobal!.dy * scale)
|
|
.toInt();
|
|
right = (left + _imageWidgetSize!.width * scale).toInt();
|
|
bottom = (top + _imageWidgetSize!.height * scale).toInt();
|
|
} else {
|
|
// Fallback: clip to client area (excluding OS window decorations).
|
|
left = clientTopLeftScreen.dx.toInt();
|
|
top = clientTopLeftScreen.dy.toInt();
|
|
right = (frame.left + frame.width - borderX).toInt();
|
|
bottom = (frame.top + frame.height - borderBottom).toInt();
|
|
}
|
|
|
|
_cursorClipApplied = bind.mainClipCursor(
|
|
left: left,
|
|
top: top,
|
|
right: right,
|
|
bottom: bottom,
|
|
enable: true,
|
|
);
|
|
}
|
|
|
|
void _releaseCursorClip() {
|
|
if (!_cursorClipApplied) return;
|
|
_cursorClipApplied = false;
|
|
if (!isWindows) return;
|
|
|
|
bind.mainClipCursor(
|
|
left: 0,
|
|
top: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
enable: false,
|
|
);
|
|
}
|
|
|
|
void _resetState() {
|
|
// Flush any pending delta before clearing state.
|
|
// This ensures the last buffered movement is sent before values are zeroed.
|
|
// Fire-and-forget: we don't wait for the async send to complete.
|
|
if (_throttleTimer != null || _pendingDeltaX != 0 || _pendingDeltaY != 0) {
|
|
_throttleTimer?.cancel();
|
|
_throttleTimer = null;
|
|
if (_pendingDeltaX != 0 || _pendingDeltaY != 0) {
|
|
final x = _pendingDeltaX;
|
|
final y = _pendingDeltaY;
|
|
_pendingDeltaX = 0;
|
|
_pendingDeltaY = 0;
|
|
// Send without awaiting; skip recenter since we're disabling.
|
|
_sendMouseMessageToSession({
|
|
'type': 'move_relative',
|
|
'x': '$x',
|
|
'y': '$y',
|
|
}, disableRelativeOnError: false);
|
|
}
|
|
}
|
|
_accumulator.reset();
|
|
_pointerLockCenterLocal = null;
|
|
_pointerLockCenterScreen = null;
|
|
_pointerRegionTopLeftGlobal = null;
|
|
_lastPointerLocalPos = null;
|
|
_skipNextMouseMove = false;
|
|
setPointerInsideImage(false);
|
|
_cursorClipApplied = false;
|
|
_exitShortcutKeyDown = false;
|
|
}
|
|
|
|
/// Core cleanup logic shared by [_disableWithCleanup] and [dispose].
|
|
/// Sends disable message to Rust, releases platform resources, and resets state.
|
|
void _performCleanupCore() {
|
|
// Best-effort marker for Rust rdev grab loop (ESC behavior).
|
|
// Bypass keyboardPerm check to ensure Rust state is always synced.
|
|
_sendMouseMessageToSession(
|
|
{
|
|
'relative_mouse_mode': '0',
|
|
},
|
|
disableRelativeOnError: false,
|
|
bypassKeyboardPerm: true,
|
|
);
|
|
|
|
// macOS: Disable native relative mouse mode
|
|
// This already calls CGAssociateMouseAndMouseCursorPosition(1) to re-associate mouse
|
|
if (isMacOS) {
|
|
_disableNativeRelativeMouseMode();
|
|
} else {
|
|
_releaseCursorClip();
|
|
}
|
|
|
|
_resetState();
|
|
}
|
|
|
|
void _disableWithCleanup() {
|
|
_performCleanupCore();
|
|
enabled.value = false;
|
|
onDisabled?.call();
|
|
}
|
|
|
|
bool _disposed = false;
|
|
|
|
void dispose() {
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
|
|
_performCleanupCore();
|
|
_imageWidgetSize = null;
|
|
_lastToggle = null;
|
|
// Set enabled to false BEFORE calling onDisabled, consistent with _disableWithCleanup().
|
|
enabled.value = false;
|
|
// Trigger callback before clearing it, so external cleanup can run.
|
|
onDisabled?.call();
|
|
onDisabled = null;
|
|
}
|
|
}
|