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

@@ -83,7 +83,10 @@ class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
cursorModel: _cursorModel,
),
if (virtualMouseMode.showVirtualJoystick)
VirtualJoystick(cursorModel: _cursorModel),
VirtualJoystick(
cursorModel: _cursorModel,
inputModel: _inputModel,
),
FloatingLeftRightButton(
isLeft: true,
inputModel: _inputModel,
@@ -674,12 +677,18 @@ class _QuarterCirclePainter extends CustomPainter {
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
// Virtual joystick sends the absolute movement for now.
// Maybe we need to change it to relative movement in the future.
// Virtual joystick can send either absolute movement (via updatePan)
// or relative movement (via sendMobileRelativeMouseMove) depending on the
// InputModel.relativeMouseMode setting.
class VirtualJoystick extends StatefulWidget {
final CursorModel cursorModel;
final InputModel inputModel;
const VirtualJoystick({super.key, required this.cursorModel});
const VirtualJoystick({
super.key,
required this.cursorModel,
required this.inputModel,
});
@override
State<VirtualJoystick> createState() => _VirtualJoystickState();
@@ -694,6 +703,10 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
final double _moveStep = 3.0;
final double _speed = 1.0;
/// Scale factor for relative mouse movement sensitivity.
/// Higher values result in faster cursor movement on the remote machine.
static const double _kRelativeMouseScale = 3.0;
// One-shot timer to detect a drag gesture
Timer? _dragStartTimer;
// Periodic timer for continuous movement
@@ -701,6 +714,9 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
Size? _lastScreenSize;
bool _isPressed = false;
/// Check if relative mouse mode is enabled.
bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
@override
void initState() {
super.initState();
@@ -746,6 +762,18 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
);
}
/// Send movement delta to remote machine.
/// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
void _sendMovement(Offset delta) {
if (_useRelativeMouse) {
widget.inputModel.sendMobileRelativeMouseMove(
delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
} else {
// In absolute mode, use cursorModel.updatePan which tracks position.
widget.cursorModel.updatePan(delta, Offset.zero, false);
}
}
void _stopSendEventTimer() {
_dragStartTimer?.cancel();
_continuousMoveTimer?.cancel();
@@ -773,7 +801,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
// The movement is small for a gentle start.
final initialDelta = _offsetToPanDelta(_offset);
if (initialDelta.distance > 0) {
widget.cursorModel.updatePan(initialDelta, Offset.zero, false);
_sendMovement(initialDelta);
}
// 2. Start a one-shot timer to check if the user is holding for a drag.
@@ -784,10 +812,7 @@ class _VirtualJoystickState extends State<VirtualJoystick> {
_continuousMoveTimer =
periodic_immediate(const Duration(milliseconds: 20), () async {
if (_offset != Offset.zero) {
widget.cursorModel.updatePan(
_offsetToPanDelta(_offset) * _moveStep * _speed,
Offset.zero,
false);
_sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
}
});
});

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
class GestureIcons {
@@ -39,11 +41,13 @@ class GestureHelp extends StatefulWidget {
{Key? key,
required this.touchMode,
required this.onTouchModeChange,
required this.virtualMouseMode})
required this.virtualMouseMode,
this.inputModel})
: super(key: key);
final bool touchMode;
final OnTouchModeChange onTouchModeChange;
final VirtualMouseMode virtualMouseMode;
final InputModel? inputModel;
@override
State<StatefulWidget> createState() =>
@@ -61,6 +65,14 @@ class _GestureHelpState extends State<GestureHelp> {
_selectedIndex = _touchMode ? 1 : 0;
}
/// Helper to exit relative mouse mode when certain conditions are met.
/// This reduces code duplication across multiple UI callbacks.
void _exitRelativeMouseModeIf(bool condition) {
if (condition) {
widget.inputModel?.setRelativeMouseMode(false);
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
@@ -103,6 +115,8 @@ class _GestureHelpState extends State<GestureHelp> {
_selectedIndex = index ?? 0;
_touchMode = index == 0 ? false : true;
widget.onTouchModeChange(_touchMode);
// Exit relative mouse mode when switching to touch mode
_exitRelativeMouseModeIf(_touchMode);
}
});
},
@@ -117,12 +131,18 @@ class _GestureHelpState extends State<GestureHelp> {
onChanged: (value) async {
if (value == null) return;
await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {});
},
),
InkWell(
onTap: () async {
await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {});
},
child: Text(translate('Show virtual mouse')),
@@ -196,6 +216,10 @@ class _GestureHelpState extends State<GestureHelp> {
if (value == null) return;
await _virtualMouseMode
.toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {});
},
),
@@ -203,6 +227,10 @@ class _GestureHelpState extends State<GestureHelp> {
onTap: () async {
await _virtualMouseMode
.toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {});
},
child: Text(
@@ -211,6 +239,39 @@ class _GestureHelpState extends State<GestureHelp> {
],
)),
),
// Relative mouse mode option - only visible when joystick is shown
if (!_touchMode &&
_virtualMouseMode.showVirtualMouse &&
_virtualMouseMode.showVirtualJoystick &&
widget.inputModel != null)
Obx(() => Transform.translate(
offset: const Offset(-10.0, -24.0),
child: Padding(
// Indent further for 'Relative mouse mode'
padding: const EdgeInsets.only(left: 48.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: widget.inputModel!
.relativeMouseMode.value,
onChanged: (value) {
if (value == null) return;
widget.inputModel!
.setRelativeMouseMode(value);
},
),
InkWell(
onTap: () {
widget.inputModel!
.toggleRelativeMouseMode();
},
child: Text(
translate('Relative mouse mode')),
),
],
)),
)),
],
),
),