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

@@ -15,6 +15,7 @@ import '../../common.dart';
import '../../common/widgets/dialog.dart';
import '../../common/widgets/toolbar.dart';
import '../../models/model.dart';
import '../../models/input_model.dart';
import '../../models/platform_model.dart';
import '../../common/shared_state.dart';
import '../../utils/image.dart';
@@ -90,6 +91,10 @@ class _RemotePageState extends State<RemotePage>
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
// Debounce timer for pointer lock center updates during window events.
// Uses kDefaultPointerLockCenterThrottleMs from consts.dart for the duration.
Timer? _pointerLockCenterDebounceTimer;
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
// to identify the toolbar instance and its callback function.
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
@@ -169,6 +174,16 @@ class _RemotePageState extends State<RemotePage>
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController?.onSelected?.call(widget.id);
});
// Register callback to cancel debounce timer when relative mouse mode is disabled
_ffi.inputModel.onRelativeMouseModeDisabled =
_cancelPointerLockCenterDebounceTimer;
}
/// Cancel the pointer lock center debounce timer
void _cancelPointerLockCenterDebounceTimer() {
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = null;
}
@override
@@ -184,6 +199,13 @@ class _RemotePageState extends State<RemotePage>
_rawKeyFocusNode.unfocus();
}
stateGlobal.isFocused.value = false;
// When window loses focus, temporarily release relative mouse mode constraints
// to allow user to interact with other applications normally.
// The cursor will be re-hidden and re-centered when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.onWindowBlur();
}
}
@override
@@ -194,6 +216,12 @@ class _RemotePageState extends State<RemotePage>
_isWindowBlur = false;
}
stateGlobal.isFocused.value = true;
// Restore relative mouse mode constraints when window regains focus.
if (_ffi.inputModel.relativeMouseMode.value) {
_rawKeyFocusNode.requestFocus();
_ffi.inputModel.onWindowFocus();
}
}
@override
@@ -205,6 +233,8 @@ class _RemotePageState extends State<RemotePage>
_isWindowBlur = false;
}
WakelockManager.enable(_uniqueKey);
// Update pointer lock center when window is restored
_updatePointerLockCenterIfNeeded();
}
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
@@ -212,12 +242,50 @@ class _RemotePageState extends State<RemotePage>
void onWindowMaximize() {
super.onWindowMaximize();
WakelockManager.enable(_uniqueKey);
// Update pointer lock center when window is maximized
_updatePointerLockCenterIfNeeded();
}
@override
void onWindowResize() {
super.onWindowResize();
// Update pointer lock center when window is resized
_updatePointerLockCenterIfNeeded();
}
@override
void onWindowMove() {
super.onWindowMove();
// Update pointer lock center when window is moved
_updatePointerLockCenterIfNeeded();
}
/// Update pointer lock center with debouncing to avoid excessive updates
/// during rapid window move/resize events.
void _updatePointerLockCenterIfNeeded() {
if (!_ffi.inputModel.relativeMouseMode.value) return;
// Cancel any pending update and schedule a new one (debounce pattern)
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = Timer(
const Duration(milliseconds: kDefaultPointerLockCenterThrottleMs),
() {
if (!mounted) return;
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.updatePointerLockCenter();
}
},
);
}
@override
void onWindowMinimize() {
super.onWindowMinimize();
WakelockManager.disable(_uniqueKey);
// Release cursor constraints when minimized
if (_ffi.inputModel.relativeMouseMode.value) {
_ffi.inputModel.onWindowBlur();
}
}
@override
@@ -243,6 +311,16 @@ class _RemotePageState extends State<RemotePage>
// https://github.com/flutter/flutter/issues/64935
super.dispose();
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
// Defensive cleanup: ensure host system-key propagation is reset even if
// MouseRegion.onExit never fired (e.g., tab closed while cursor inside).
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = null;
// Clear callback reference to prevent memory leaks and stale references
_ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
_ffi.textureModel.onRemotePageDispose(closeSession);
if (closeSession) {
// ensure we leave this session, this is a double check
@@ -344,10 +422,15 @@ class _RemotePageState extends State<RemotePage>
}
}(),
// Use Overlay to enable rebuild every time on menu button click.
_ffi.ffiModel.pi.isSet.isTrue
? Overlay(
initialEntries: [OverlayEntry(builder: remoteToolbar)])
: remoteToolbar(context),
// Hide toolbar when relative mouse mode is active to prevent
// cursor from escaping to toolbar area.
Obx(() => _ffi.inputModel.relativeMouseMode.value
? const Offstage()
: _ffi.ffiModel.pi.isSet.isTrue
? Overlay(initialEntries: [
OverlayEntry(builder: remoteToolbar)
])
: remoteToolbar(context)),
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
],
),
@@ -415,6 +498,7 @@ class _RemotePageState extends State<RemotePage>
//
}
}
// See [onWindowBlur].
if (!isWindows) {
if (!_rawKeyFocusNode.hasFocus) {
@@ -440,6 +524,7 @@ class _RemotePageState extends State<RemotePage>
//
}
}
// See [onWindowBlur].
if (!isWindows) {
_ffi.inputModel.enterOrLeave(false);
@@ -487,32 +572,39 @@ class _RemotePageState extends State<RemotePage>
Widget getBodyForDesktop(BuildContext context) {
var paints = <Widget>[
MouseRegion(onEnter: (evt) {
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
}, onExit: (evt) {
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
}, child: LayoutBuilder(builder: (context, constraints) {
final c = Provider.of<CanvasModel>(context, listen: false);
Future.delayed(Duration.zero, () => c.updateViewStyle());
final peerDisplay = CurrentDisplayState.find(widget.id);
return Obx(
() => _ffi.ffiModel.pi.isSet.isFalse
? Container(color: Colors.transparent)
: Obx(() {
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
return ImagePaint(
id: widget.id,
zoomCursor: _zoomCursor,
cursorOverImage: _cursorOverImage,
keyboardEnabled: _keyboardEnabled,
remoteCursorMoved: _remoteCursorMoved,
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
child, enterView, leaveView),
ffi: _ffi,
);
}),
);
}))
MouseRegion(
onEnter: (evt) {
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
},
onExit: (evt) {
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
},
child: _ViewStyleUpdater(
canvasModel: _ffi.canvasModel,
inputModel: _ffi.inputModel,
child: Builder(builder: (context) {
final peerDisplay = CurrentDisplayState.find(widget.id);
return Obx(
() => _ffi.ffiModel.pi.isSet.isFalse
? Container(color: Colors.transparent)
: Obx(() {
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
return ImagePaint(
id: widget.id,
zoomCursor: _zoomCursor,
cursorOverImage: _cursorOverImage,
keyboardEnabled: _keyboardEnabled,
remoteCursorMoved: _remoteCursorMoved,
listenerBuilder: (child) =>
_buildRawTouchAndPointerRegion(
child, enterView, leaveView),
ffi: _ffi,
);
}),
);
}),
),
)
];
if (!_ffi.canvasModel.cursorEmbedded) {
@@ -541,6 +633,63 @@ class _RemotePageState extends State<RemotePage>
bool get wantKeepAlive => true;
}
/// A widget that tracks the view size and updates CanvasModel.updateViewStyle()
/// and InputModel.updateImageWidgetSize() only when size actually changes.
/// This avoids scheduling post-frame callbacks on every LayoutBuilder rebuild.
class _ViewStyleUpdater extends StatefulWidget {
final CanvasModel canvasModel;
final InputModel inputModel;
final Widget child;
const _ViewStyleUpdater({
Key? key,
required this.canvasModel,
required this.inputModel,
required this.child,
}) : super(key: key);
@override
State<_ViewStyleUpdater> createState() => _ViewStyleUpdaterState();
}
class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> {
Size? _lastSize;
bool _callbackScheduled = false;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final maxHeight = constraints.maxHeight;
// Guard against infinite constraints (e.g., unconstrained ancestor).
if (!maxWidth.isFinite || !maxHeight.isFinite) {
return widget.child;
}
final newSize = Size(maxWidth, maxHeight);
if (_lastSize != newSize) {
_lastSize = newSize;
// Schedule the update for after the current frame to avoid setState during build.
// Use _callbackScheduled flag to prevent accumulating multiple callbacks
// when size changes rapidly before any callback executes.
if (!_callbackScheduled) {
_callbackScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((_) {
_callbackScheduled = false;
final currentSize = _lastSize;
if (mounted && currentSize != null) {
widget.canvasModel.updateViewStyle();
widget.inputModel.updateImageWidgetSize(currentSize);
}
});
}
}
return widget.child;
},
);
}
}
class ImagePaint extends StatefulWidget {
final FFI ffi;
final String id;
@@ -605,21 +754,24 @@ class _ImagePaintState extends State<ImagePaint> {
cursor: cursorOverImage.isTrue
? c.cursorEmbedded
? SystemMouseCursors.none
: keyboardEnabled.isTrue
? (() {
if (remoteCursorMoved.isTrue) {
_lastRemoteCursorMoved = true;
return SystemMouseCursors.none;
} else {
if (_lastRemoteCursorMoved) {
_lastRemoteCursorMoved = false;
_firstEnterImage.value = true;
}
return _buildCustomCursor(
context, getCursorScale());
}
}())
: _buildDisabledCursor(context, getCursorScale())
// Hide cursor when relative mouse mode is active
: widget.ffi.inputModel.relativeMouseMode.value
? SystemMouseCursors.none
: keyboardEnabled.isTrue
? (() {
if (remoteCursorMoved.isTrue) {
_lastRemoteCursorMoved = true;
return SystemMouseCursors.none;
} else {
if (_lastRemoteCursorMoved) {
_lastRemoteCursorMoved = false;
_firstEnterImage.value = true;
}
return _buildCustomCursor(
context, getCursorScale());
}
}())
: _buildDisabledCursor(context, getCursorScale())
: MouseCursor.defer,
onHover: (evt) {},
child: child);

View File

@@ -135,7 +135,13 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
body: DesktopTab(
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: const AddButton(),
tail: Row(
mainAxisSize: MainAxisSize.min,
children: [
_RelativeMouseModeHint(tabController: tabController),
const AddButton(),
],
),
selectedBorderColor: MyTheme.accent,
pageViewBuilder: (pageView) => pageView,
labelGetter: DesktopTab.tablabelGetter,
@@ -374,6 +380,8 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
loopCloseWindow();
}
ConnectionTypeState.delete(id);
// Clean up relative mouse mode state for this peer.
stateGlobal.relativeMouseModeState.remove(id);
_update_remote_count();
}
@@ -548,3 +556,69 @@ class _ConnectionTabPageState extends State<ConnectionTabPage> {
return returnValue;
}
}
/// A widget that displays a hint in the tab bar when relative mouse mode is active.
/// This helps users remember how to exit relative mouse mode.
class _RelativeMouseModeHint extends StatelessWidget {
final DesktopTabController tabController;
const _RelativeMouseModeHint({Key? key, required this.tabController})
: super(key: key);
@override
Widget build(BuildContext context) {
return Obx(() {
// Check if there are any tabs
if (tabController.state.value.tabs.isEmpty) {
return const SizedBox.shrink();
}
// Get current selected tab's RemotePage
final selectedTabInfo = tabController.state.value.selectedTabInfo;
if (selectedTabInfo.page is! RemotePage) {
return const SizedBox.shrink();
}
final remotePage = selectedTabInfo.page as RemotePage;
final String peerId = remotePage.id;
// Use global state to check relative mouse mode (synced from InputModel).
// This avoids timing issues with FFI registration.
final isRelativeMouseMode =
stateGlobal.relativeMouseModeState[peerId] ?? false;
if (!isRelativeMouseMode) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange.withOpacity(0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.mouse,
size: 14,
color: Colors.orange[700],
),
const SizedBox(width: 4),
Text(
translate(
'rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'),
style: TextStyle(
fontSize: 11,
color: Colors.orange[700],
),
),
],
),
);
});
}
}

View File

@@ -593,7 +593,6 @@ class _DesktopTabState extends State<DesktopTab>
Widget _buildBar() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: GestureDetector(